commit bb7c6e9615e66cd0e9501c91af87f3539c1915d9
parent d5dde756fc3200d476325eda5c69f54e6ff9b4d4
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Fri, 18 Sep 2026 13:16:45 -0400
play: bound the wait for playback to finish
PCM waited on IsPlaying with no bound. That is correct while the audio
device makes progress and a hang when it does not, which is exactly what
a machine with no sound hardware looks like: CI sat for the full test
timeout.
The audio's own length is the natural bound, so Duration computes it and
the wait allows that plus a quarter and ten seconds for the buffer and a
slow start. Past that the player is closed and the caller is told the
device is not progressing.
Duration is exported because a caller sizing its own timeout needs the
same number, and tested directly so the bound cannot quietly regress.
Diffstat:
2 files changed, 59 insertions(+), 0 deletions(-)
diff --git a/play/play.go b/play/play.go
@@ -69,7 +69,20 @@ func PCM(pcm []byte, format nativeaudio.Format) error {
// internal buffer (half a second by default) has been played out, or
// the player fails. Waiting only for the source to hit EOF would cut
// off the tail of the audio.
+ //
+ // It can also stay true forever on a machine whose audio output never
+ // makes progress, which is what a CI runner with no sound device
+ // looks like. The audio's own length is the natural bound, with
+ // enough slack for the buffer and for a slow start.
+ budget := Duration(pcm, format)
+ budget += budget/4 + 10*time.Second
+ deadline := time.Now().Add(budget)
for player.IsPlaying() {
+ if time.Now().After(deadline) {
+ player.Close()
+ ctx.Suspend()
+ return fmt.Errorf("playback did not finish within %s; the audio device is not making progress", budget.Round(time.Second))
+ }
time.Sleep(10 * time.Millisecond)
}
err = player.Err()
@@ -109,3 +122,15 @@ func Data(compressed []byte) error {
}
return PCM(pcm, format)
}
+
+// Duration reports how long the PCM will take to play.
+//
+// It is what bounds the wait in [PCM], and is useful to callers sizing a
+// timeout of their own.
+func Duration(pcm []byte, format nativeaudio.Format) time.Duration {
+ perSecond := format.SampleRate * format.Channels * format.BytesPerSample
+ if perSecond <= 0 {
+ return 0
+ }
+ return time.Duration(len(pcm)) * time.Second / time.Duration(perSecond)
+}
diff --git a/play/play_test.go b/play/play_test.go
@@ -0,0 +1,34 @@
+package play_test
+
+import (
+ "testing"
+ "time"
+
+ "git.sr.ht/~jackmordaunt/nativeaudio"
+ "git.sr.ht/~jackmordaunt/nativeaudio/play"
+)
+
+// TestDuration covers the calculation that bounds the playback wait, so
+// a mistake there cannot quietly reintroduce an unbounded one.
+func TestDuration(t *testing.T) {
+ stereo := nativeaudio.Format{SampleRate: 44100, Channels: 2, BytesPerSample: 2}
+ mono := nativeaudio.Format{SampleRate: 8000, Channels: 1, BytesPerSample: 2}
+
+ for _, tc := range []struct {
+ name string
+ bytes int
+ format nativeaudio.Format
+ want time.Duration
+ }{
+ {"one second stereo", 44100 * 2 * 2, stereo, time.Second},
+ {"half a second stereo", 44100 * 2, stereo, time.Second / 2},
+ {"one second mono", 8000 * 2, mono, time.Second},
+ {"nothing", 0, stereo, 0},
+ {"degenerate format", 1024, nativeaudio.Format{}, 0},
+ } {
+ got := play.Duration(make([]byte, tc.bytes), tc.format)
+ if got != tc.want {
+ t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
+ }
+ }
+}