Something went wrong. Try again.
Plays short audio files such as a terminal bell through PipeWire on Linux and WASAPI on Windows.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! An application's connection to the system's sound output.//!//! A Player opens one stream for each Sound the first time it is played or//! prepared, at the Sound's own rate and channel count, and keeps it open and paused//! between Plays until the Sound is released (docs/adr/0001). The system//! resamples and mixes; the Player only says what to play and how loud.
const std = @import("std");const Allocator = std.mem.Allocator;
const Sound = @import("Sound.zig");const backend_options = @import("backend/options.zig");
/// What became of a request to play.pub const Outcome = enum { /// The Sound started from the beginning. started, /// The Sound was already playing, and the request was ignored: a Dropped /// play. dropped,};
/// A Player over `Backend`: `backend.native` for the real thing, or/// `backend.Null` in tests. Its methods are for one thread at a time.pub fn PlayerWith(comptime Backend: type) type { return struct { const Player = @This();
gpa: Allocator, io: std.Io, name: []u8, environ: ?std.process.Environ, /// One per Sound played and not yet released. Few enough that a scan /// is the right lookup. voices: std.ArrayList(*Voice) = .empty,
pub const Error = Backend.Error || Allocator.Error;
pub const Options = struct { /// How the application appears in the system's volume mixer. name: []const u8 = "zig-chime", /// The process environment, which on Linux says where PipeWire's /// socket is; without it `/run/user/<uid>` is assumed. Zig hands /// it to `main`. Ignored on Windows. environ: ?std.process.Environ = null, };
const State = enum(u8) { idle, playing };
/// One Sound's stream, and where its Play has got to. /// /// `cursor` and `gain` are written by `play` only while the state is /// idle and read by the callback only while it is playing, and the /// release and acquire on `state` are what hand them across. const Voice = struct { sound: *Sound, stream: *Backend.Stream = undefined, state: std.atomic.Value(State) = .init(.idle), cursor: usize = 0, gain: f32 = 1,
/// On the backend's thread, against its deadline: no allocation, /// no locks. fn process(ctx: *anyopaque, planes: []const []f32, frames: u32) void { const voice: *Voice = @ptrCast(@alignCast(ctx)); if (voice.state.load(.acquire) != .playing) { for (planes) |plane| @memset(plane[0..frames], 0); return; } const sound = voice.sound; const channels: usize = sound.channels; const n = @min(frames, sound.frames() - voice.cursor); const from = sound.samples[voice.cursor * channels ..][0 .. n * channels]; for (planes, 0..) |plane, c| { if (c < channels) { for (plane[0..n], 0..) |*s, i| s.* = from[i * channels + c] * voice.gain; @memset(plane[n..frames], 0); } else @memset(plane[0..frames], 0); } voice.cursor += n; if (voice.cursor == sound.frames()) { // Paused before it is marked idle, so that a `play` seeing // idle always unpauses after this has paused. Backend.pause(voice.stream); voice.state.store(.idle, .release); } } };
pub fn init(gpa: Allocator, io: std.Io, options: Options) Allocator.Error!Player { return .{ .gpa = gpa, .io = io, .name = try gpa.dupe(u8, options.name), .environ = options.environ, }; }
/// Stops everything at once, without waiting for it to finish. pub fn deinit(player: *Player) void { for (player.voices.items) |voice| player.close(voice); player.voices.deinit(player.gpa); player.gpa.free(player.name); player.* = undefined; }
/// Plays `sound` from the start at `volume`, from 0 (silent) to 1 (as /// recorded), and returns at once. If it is already playing nothing /// happens and the answer is `.dropped`. /// /// The first Play of a Sound opens its stream, unless `prepare` already /// has, which is where an error can come from; after that, playing /// cannot fail. pub fn play(player: *Player, sound: *Sound, volume: f32) Error!Outcome { const voice = player.find(sound) orelse try player.open(sound); if (voice.state.load(.acquire) == .playing) return .dropped; voice.cursor = 0; voice.gain = if (std.math.isNan(volume)) 0 else std.math.clamp(volume, 0, 1); voice.state.store(.playing, .release); Backend.unpause(voice.stream); return .started; }
/// Opens `sound`'s stream, paused, as its first Play would, without /// playing it: so that a sound output that cannot be reached shows up /// now rather than at the first Play, and the first Play does not wait /// on the sound server. Does nothing if the stream is already open. /// The Sound then has to be released like one that has been played. pub fn prepare(player: *Player, sound: *Sound) Error!void { if (player.find(sound) == null) _ = try player.open(sound); }
/// Whether `sound` is in the middle of a Play. pub fn isPlaying(player: *Player, sound: *const Sound) bool { const voice = player.find(sound) orelse return false; return voice.state.load(.acquire) == .playing; }
/// Stops `sound` if it is playing and closes its stream, after which /// it may be freed. A Sound this Player never played or prepared is /// already released, and releasing it again does nothing. pub fn release(player: *Player, sound: *Sound) void { for (player.voices.items, 0..) |voice, i| { if (voice.sound != sound) continue; player.close(voice); _ = player.voices.swapRemove(i); return; } }
/// The stream `sound` plays through, if it has one: for tests driving /// `backend.Null`. pub fn streamOf(player: *Player, sound: *const Sound) ?*Backend.Stream { const voice = player.find(sound) orelse return null; return voice.stream; }
fn find(player: *Player, sound: *const Sound) ?*Voice { for (player.voices.items) |voice| { if (voice.sound == sound) return voice; } return null; }
fn open(player: *Player, sound: *Sound) Error!*Voice { try player.voices.ensureUnusedCapacity(player.gpa, 1); const voice = try player.gpa.create(Voice); errdefer player.gpa.destroy(voice); voice.* = .{ .sound = sound }; // The callback can run before `stream` is set, but it touches the // stream only while playing, which cannot happen until this returns. voice.stream = try Backend.open(player.gpa, player.io, .{ .name = player.name, .environ = player.environ, .rate = sound.rate, .channels = sound.channels, .process = .{ .ctx = voice, .func = Voice.process }, }); // Idle until a Play, and taken off the sound server's schedule. Backend.pause(voice.stream); player.voices.appendAssumeCapacity(voice); if (std.debug.runtime_safety) sound.holders += 1; return voice; }
fn close(player: *Player, voice: *Voice) void { // The callback is never called again once this returns. Backend.close(voice.stream, player.io); if (std.debug.runtime_safety) voice.sound.holders -= 1; player.gpa.destroy(voice); } };}
const testing = std.testing;const Null = @import("backend/null.zig");const TestPlayer = PlayerWith(Null);
/// A Sound of `frames` frames whose every sample is its frame number plus one/// tenth of its channel, so that where each one ends up can be checked.fn rampSound(frames: usize, channels: u16) !Sound { const samples = try testing.allocator.alloc(f32, frames * channels); for (samples, 0..) |*s, i| s.* = @as(f32, @floatFromInt(i / channels)) + @as(f32, @floatFromInt(i % channels)) / 10; return .{ .samples = samples, .rate = 8000, .channels = channels };}
test "a Play runs to the end of the Sound, then the stream pauses" { var sound = try rampSound(5, 2); defer sound.deinit(testing.allocator); var player = try TestPlayer.init(testing.allocator, testing.io, .{ .name = "test" }); defer player.deinit(); defer player.release(&sound);
try testing.expectEqual(Outcome.started, try player.play(&sound, 1)); const stream = player.streamOf(&sound).?; try testing.expectEqual(@as(u32, 8000), stream.rate); try testing.expectEqualStrings("test", stream.name);
const first = try stream.pull(3); defer testing.allocator.free(first); try testing.expectEqualSlices(f32, &.{ 0, 0.1, 1, 1.1, 2, 2.1 }, first); try testing.expect(player.isPlaying(&sound));
// The last two frames, then silence for the rest of the cycle. const second = try stream.pull(3); defer testing.allocator.free(second); try testing.expectEqualSlices(f32, &.{ 3, 3.1, 4, 4.1, 0, 0 }, second); try testing.expect(!player.isPlaying(&sound)); try testing.expect(!stream.active);}
test "playing a Sound that is already playing is dropped" { var sound = try rampSound(4, 1); defer sound.deinit(testing.allocator); var player = try TestPlayer.init(testing.allocator, testing.io, .{}); defer player.deinit(); defer player.release(&sound);
try testing.expectEqual(Outcome.started, try player.play(&sound, 1)); const stream = player.streamOf(&sound).?; testing.allocator.free(try stream.pull(2)); // Dropped, not restarted: the Play carries on from where it was. try testing.expectEqual(Outcome.dropped, try player.play(&sound, 0.5)); const rest = try stream.pull(2); defer testing.allocator.free(rest); try testing.expectEqualSlices(f32, &.{ 2, 3 }, rest);
// Finished, so the next request starts it again, at the new volume. try testing.expectEqual(Outcome.started, try player.play(&sound, 0.5)); try testing.expect(stream.active); const again = try stream.pull(4); defer testing.allocator.free(again); try testing.expectEqualSlices(f32, &.{ 0, 0.5, 1, 1.5 }, again);}
test "volume is clamped to 0 through 1" { var sound = try rampSound(3, 1); defer sound.deinit(testing.allocator); var player = try TestPlayer.init(testing.allocator, testing.io, .{}); defer player.deinit(); defer player.release(&sound);
for ([_]f32{ 2, -1, std.math.nan(f32) }, [_]f32{ 1, 0, 0 }) |volume, gain| { _ = try player.play(&sound, volume); const out = try player.streamOf(&sound).?.pull(3); defer testing.allocator.free(out); try testing.expectEqualSlices(f32, &.{ 0, gain, 2 * gain }, out); }}
test "each Sound has a stream of its own, at its own format" { var bell = try rampSound(3, 1); defer bell.deinit(testing.allocator); var chime = try rampSound(3, 2); chime.rate = 44100; defer chime.deinit(testing.allocator); var player = try TestPlayer.init(testing.allocator, testing.io, .{}); defer player.deinit();
_ = try player.play(&bell, 1); _ = try player.play(&chime, 1); try testing.expect(player.streamOf(&bell).? != player.streamOf(&chime).?); try testing.expectEqual(@as(u16, 2), player.streamOf(&chime).?.channels); try testing.expectEqual(@as(u32, 44100), player.streamOf(&chime).?.rate); try testing.expect(player.isPlaying(&bell) and player.isPlaying(&chime));
player.release(&bell); try testing.expectEqual(@as(?*Null.Stream, null), player.streamOf(&bell)); try testing.expect(!player.isPlaying(&bell)); // Releasing twice, or a Sound never played, is harmless. player.release(&bell); player.release(&chime);}
test "a stream that cannot be opened is an error from play, and holds nothing" { var sound = try rampSound(3, 1); defer sound.deinit(testing.allocator); var player = try TestPlayer.init(testing.allocator, testing.io, .{}); defer player.deinit();
Null.fail_next_open = true; try testing.expectError(error.OutputUnavailable, player.play(&sound, 1)); try testing.expectEqual(@as(?*Null.Stream, null), player.streamOf(&sound)); // The next attempt opens it. try testing.expectEqual(Outcome.started, try player.play(&sound, 1)); player.release(&sound);}
test "prepare opens the stream paused, and the first Play only unpauses it" { var sound = try rampSound(2, 1); defer sound.deinit(testing.allocator); var player = try TestPlayer.init(testing.allocator, testing.io, .{}); defer player.deinit(); defer player.release(&sound);
try player.prepare(&sound); const stream = player.streamOf(&sound).?; try testing.expect(!stream.active); try testing.expect(!player.isPlaying(&sound)); if (std.debug.runtime_safety) try testing.expectEqual(@as(u32, 1), sound.holders);
// Again does nothing: the same stream, still paused. try player.prepare(&sound); try testing.expectEqual(stream, player.streamOf(&sound).?); try testing.expect(!stream.active); if (std.debug.runtime_safety) try testing.expectEqual(@as(u32, 1), sound.holders);
try testing.expectEqual(Outcome.started, try player.play(&sound, 1)); try testing.expectEqual(stream, player.streamOf(&sound).?); const out = try stream.pull(2); defer testing.allocator.free(out); try testing.expectEqualSlices(f32, &.{ 0, 1 }, out);}
test "a stream that cannot be opened is an error from prepare, and holds nothing" { var sound = try rampSound(3, 1); defer sound.deinit(testing.allocator); var player = try TestPlayer.init(testing.allocator, testing.io, .{}); defer player.deinit();
Null.fail_next_open = true; try testing.expectError(error.OutputUnavailable, player.prepare(&sound)); try testing.expectEqual(@as(?*Null.Stream, null), player.streamOf(&sound)); if (std.debug.runtime_safety) try testing.expectEqual(@as(u32, 0), sound.holders); try player.prepare(&sound); player.release(&sound);}
test "deinit stops what is playing and releases every Sound" { var sound = try rampSound(100, 1); defer sound.deinit(testing.allocator); var player = try TestPlayer.init(testing.allocator, testing.io, .{}); _ = try player.play(&sound, 1); player.deinit(); if (std.debug.runtime_safety) try testing.expectEqual(@as(u32, 0), sound.holders);}