// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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/` 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); }