From 63971dca3fa6d85d687e5de9e8ebd6509f6f0d4b Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 14 Oct 2026 15:59:36 -0500 Subject: [PATCH] Open a Sound's stream ahead of its first Play with Player.prepare An application can now find out when it loads its Sounds that there is no sound output, rather than at the first Play, and the first Play no longer waits on the sound server. A stream is opened paused, so a prepared Sound is off the server's schedule until it is played. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013GnDsfLDmzoHCS7p8w1dKf --- README.md | 8 +++- docs/adr/0001-one-stream-per-sound.md | 7 +-- src/player.zig | 64 ++++++++++++++++++++++++--- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e6afe3b..a8c3a2d 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,12 @@ it. memory with an hour of audio. - **Playing:** `play` takes a Sound and a volume from 0 to 1 and returns at once. Playing a Sound that is already playing does nothing and returns - `.dropped`. Different Sounds can play at the same time. -- **Releasing:** a Player keeps a stream open for each Sound it has played, + `.dropped`. Different Sounds can play at the same time. The first Play of a + Sound connects to the sound server, and that is where an error can come + from; `prepare` does it beforehand, so that an application learns at once + that there is no sound output and the first Play starts without the wait. +- **Releasing:** a Player keeps a stream open for each Sound it has played or + prepared, paused between Plays so that the next one starts at once. `release` closes it, and has to come before the Sound is freed; builds with safety checks assert that it did. `deinit` stops everything immediately. diff --git a/docs/adr/0001-one-stream-per-sound.md b/docs/adr/0001-one-stream-per-sound.md index fd1fc98..1199ac8 100644 --- a/docs/adr/0001-one-stream-per-sound.md +++ b/docs/adr/0001-one-stream-per-sound.md @@ -6,9 +6,10 @@ SPDX-License-Identifier: MIT # One stream per Sound A Player opens one output stream for each Sound, the first time that Sound is -played, at the Sound's own rate and channel count, and keeps it open and paused -between Plays until the Sound is Released. Both PipeWire and WASAPI fix a -stream's rate when it is opened and resample to the device themselves, so this +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. Both PipeWire and +WASAPI fix a stream's rate when it is opened and resample to the device +themselves, so this leaves resampling and mixing to the system — zig-chime has neither a resampler nor a mixer — and lets a Sound that is already playing simply drop the new Play. Each stream is fed by the sound server's own thread through a pull callback, diff --git a/src/player.zig b/src/player.zig index 227003d..1906dfe 100644 --- a/src/player.zig +++ b/src/player.zig @@ -3,8 +3,8 @@ //! An application's connection to the system's sound output. //! -//! A Player opens one stream for each Sound the first time it is played, at -//! the Sound's own rate and channel count, and keeps it open and paused +//! 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. @@ -111,8 +111,9 @@ pub fn PlayerWith(comptime Backend: type) type { /// 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, which is where an error - /// can come from; after that, playing cannot fail. + /// 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; @@ -123,6 +124,15 @@ pub fn PlayerWith(comptime Backend: type) type { 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; @@ -130,8 +140,8 @@ pub fn PlayerWith(comptime Backend: type) type { } /// Stops `sound` if it is playing and closes its stream, after which - /// it may be freed. A Sound this Player never played is already - /// released, and releasing it again does nothing. + /// 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; @@ -169,6 +179,8 @@ pub fn PlayerWith(comptime Backend: type) type { .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; @@ -297,6 +309,46 @@ test "a stream that cannot be opened is an error from play, and holds nothing" { 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); -- knotmirror 0.0.1