nativeaudio

audio playback for Go
Log | Files | Refs | README | LICENSE

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:
Mplay/play.go | 25+++++++++++++++++++++++++
Aplay/play_test.go | 34++++++++++++++++++++++++++++++++++
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) + } + } +}