nativeaudio

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

commit 99210bf9e9b5a659df56b0ecf9160a898234b012
parent bb7c6e9615e66cd0e9501c91af87f3539c1915d9
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 13:16:46 -0400

pcm: adapt the output to other audio stacks

Being the decoder inside someone else's audio stack is a much larger
audience than being the whole stack, and the two conventions in Go are
an io.Reader of s16le, which a Stream already is, and pairs of float64
samples, which nothing here produced.

pcm.NewStreamer converts to the latter and satisfies beep's Streamer and
StreamCloser without importing beep, because Go interfaces are
structural and neither side needs to know about the other. The tests
restate those interfaces locally and assert against them, so a signature
drifting on either side shows up as a build failure rather than a
runtime surprise.

It takes an io.Reader rather than a Stream, so the buffered API works
through a bytes.Reader, and Close delegates when the reader owns decoder
state. Frames split across reads are buffered, which matters because the
backend does return partial frames. Mono is written to both channels,
and more than two channels keeps the first two rather than mixing down,
which is a decision the caller should make.

Diffstat:
Ainternal/test/adapter_test.go | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apcm/pcm.go | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apcm/pcm_test.go | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 436 insertions(+), 0 deletions(-)

diff --git a/internal/test/adapter_test.go b/internal/test/adapter_test.go @@ -0,0 +1,73 @@ +package test + +import ( + "bytes" + "testing" + + "git.sr.ht/~jackmordaunt/nativeaudio/pcm" +) + +// TestAdapterOverRealAudio streams a real decode through the float64 +// adapter, which is how the composable audio libraries consume it. +func TestAdapterOverRealAudio(t *testing.T) { + d := newDecoder(t) + + buffered, format, err := d.Decode(compressed) + if err != nil { + t.Fatalf("Decode: %v", err) + } + wantFrames := len(buffered) / (format.Channels * format.BytesPerSample) + + // Once over the buffered output, and once over the incremental + // stream, which also exercises Close delegating to decoder state. + stream, err := d.Stream(compressed) + if err != nil { + t.Fatalf("Stream: %v", err) + } + + for _, tc := range []struct { + name string + streamer *pcm.Streamer + }{ + {"buffered", pcm.NewStreamer(bytes.NewReader(buffered), format)}, + {"streaming", pcm.NewStreamer(stream, format)}, + } { + t.Run(tc.name, func(t *testing.T) { + defer tc.streamer.Close() + + var ( + frames int + buf = make([][2]float64, 512) + peak float64 + ) + for { + n, ok := tc.streamer.Stream(buf) + for _, f := range buf[:n] { + for _, v := range f { + if v > peak { + peak = v + } + if v < -1 || v > 1 { + t.Fatalf("sample %v out of range", v) + } + } + } + frames += n + if !ok { + break + } + } + if err := tc.streamer.Err(); err != nil { + t.Fatalf("Err: %v", err) + } + if frames != wantFrames { + t.Errorf("got %d frames, want %d", frames, wantFrames) + } + // The fixture is music, so it must not be silence. + if peak <= 0 { + t.Error("decoded audio is entirely silent or negative") + } + t.Logf("%d frames, peak %.4f", frames, peak) + }) + } +} diff --git a/pcm/pcm.go b/pcm/pcm.go @@ -0,0 +1,162 @@ +// Package pcm adapts the PCM this module produces to the shapes other Go +// audio libraries consume. +// +// Stacks that take an [io.Reader] of signed 16-bit little-endian samples, +// such as oto and Ebitengine's audio package, need no adapter: a +// [nativeaudio.Stream] is already one, and buffered output can be wrapped +// in a [bytes.Reader]. +// +// Libraries built around composable streams want something else. They +// deal in pairs of float64 samples, so [Streamer] converts. It satisfies +// beep's Streamer and StreamCloser interfaces without importing beep, +// because Go interfaces are structural and neither side needs to know +// about the other. +package pcm + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + + "git.sr.ht/~jackmordaunt/nativeaudio" +) + +// sampleScale converts a signed 16-bit sample to the range [-1, 1). +const sampleScale = 1 << 15 + +// Streamer presents s16le PCM as pairs of float64 samples. +// +// Mono input is written to both channels. Input with more than two +// channels keeps the first two, because the interface this serves is +// stereo and mixing down is a decision the caller should make rather +// than inherit. +type Streamer struct { + r io.Reader + format nativeaudio.Format + + // partial holds the bytes of a frame split across two reads, since + // the underlying reader is free to stop mid-frame. + partial []byte + + buf []byte + err error + done bool +} + +// NewStreamer adapts s16le PCM read from r. +// +// Pass the [nativeaudio.Format] that came back with the audio. Taking a +// reader rather than a stream means the buffered API works too, through +// a [bytes.Reader] over the decoded PCM. +func NewStreamer(r io.Reader, format nativeaudio.Format) *Streamer { + return &Streamer{r: r, format: format} +} + +// Stream fills samples with as many frames as it can, returning the +// number written and whether streaming should continue. +// +// It reports false once the audio is exhausted or an error has occurred, +// which is the convention the consuming interface expects; the error +// itself comes from [Streamer.Err]. +func (s *Streamer) Stream(samples [][2]float64) (int, bool) { + if s.done || len(samples) == 0 { + return 0, false + } + if s.format.BytesPerSample != 2 { + s.fail(fmt.Errorf("pcm: unsupported sample size %d bytes, want 2", s.format.BytesPerSample)) + return 0, false + } + if s.format.Channels < 1 { + s.fail(fmt.Errorf("pcm: unsupported channel count %d", s.format.Channels)) + return 0, false + } + + var ( + frame = s.format.Channels * s.format.BytesPerSample + want = len(samples) * frame + ) + if cap(s.buf) < want { + s.buf = make([]byte, want) + } + s.buf = s.buf[:want] + + // Start from whatever was left over last time. + n := copy(s.buf, s.partial) + s.partial = s.partial[:0] + + read, err := io.ReadFull(s.r, s.buf[n:]) + n += read + switch { + case err == nil, errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF): + // A short read is normal: it means the audio ended. + default: + s.fail(err) + if n == 0 { + return 0, false + } + } + + frames := n / frame + if rest := n % frame; rest != 0 { + // Hold the trailing bytes for the next call. At the end of the + // audio they are a truncated frame and stay unplayed, which is + // the only sane reading of a file that ends mid-frame. + s.partial = append(s.partial[:0], s.buf[n-rest:n]...) + } + + for i := 0; i < frames; i++ { + base := i * frame + left := sample(s.buf[base:]) + right := left + if s.format.Channels > 1 { + right = sample(s.buf[base+2:]) + } + samples[i] = [2]float64{left, right} + } + + if frames == 0 { + s.done = true + return 0, false + } + // The reader is exhausted, so this is the last batch. + if err != nil { + s.done = true + } + return frames, true +} + +// Err returns the first error encountered, if any. Reaching the end of +// the audio is not an error. +func (s *Streamer) Err() error { + return s.err +} + +// Close closes the underlying reader when it is an [io.Closer], which is +// the case for a [nativeaudio.Stream]. Otherwise it does nothing. +func (s *Streamer) Close() error { + s.done = true + if c, ok := s.r.(io.Closer); ok { + return c.Close() + } + return nil +} + +// Format describes the audio being streamed, as sample rate and channel +// count. It is the format passed to [NewStreamer], unchanged. +func (s *Streamer) Format() nativeaudio.Format { + return s.format +} + +// fail records the first error and stops streaming. +func (s *Streamer) fail(err error) { + if s.err == nil { + s.err = err + } + s.done = true +} + +// sample reads one little-endian 16-bit sample as a float in [-1, 1). +func sample(b []byte) float64 { + return float64(int16(binary.LittleEndian.Uint16(b))) / sampleScale +} diff --git a/pcm/pcm_test.go b/pcm/pcm_test.go @@ -0,0 +1,201 @@ +package pcm_test + +import ( + "bytes" + "encoding/binary" + "io" + "math" + "testing" + + "git.sr.ht/~jackmordaunt/nativeaudio" + "git.sr.ht/~jackmordaunt/nativeaudio/pcm" +) + +// The interfaces beep defines, restated here. Asserting against a local +// copy pins the signatures the adapter has to match without taking on the +// dependency, so a change to either side shows up as a build failure. +type ( + beepStreamer interface { + Stream(samples [][2]float64) (n int, ok bool) + Err() error + } + beepStreamCloser interface { + beepStreamer + Close() error + } +) + +var _ beepStreamCloser = (*pcm.Streamer)(nil) + +// s16 encodes samples as little-endian 16-bit PCM. +func s16(samples ...int16) []byte { + var b bytes.Buffer + for _, s := range samples { + binary.Write(&b, binary.LittleEndian, s) + } + return b.Bytes() +} + +func stereo(rate int) nativeaudio.Format { + return nativeaudio.Format{SampleRate: rate, Channels: 2, BytesPerSample: 2} +} + +func mono(rate int) nativeaudio.Format { + return nativeaudio.Format{SampleRate: rate, Channels: 1, BytesPerSample: 2} +} + +// near reports whether two samples match within rounding. +func near(t *testing.T, a, b float64) bool { + t.Helper() + return math.Abs(a-b) < 1e-9 +} + +// TestStereoConversion checks the sample scaling and channel order. +func TestStereoConversion(t *testing.T) { + in := s16(0, 32767, -32768, 16384) + s := pcm.NewStreamer(bytes.NewReader(in), stereo(44100)) + + got := make([][2]float64, 4) + n, ok := s.Stream(got) + if !ok || n != 2 { + t.Fatalf("Stream: got n=%d ok=%v, want 2 frames", n, ok) + } + if err := s.Err(); err != nil { + t.Fatalf("Err: %v", err) + } + + want := [][2]float64{ + {0, 32767.0 / 32768.0}, + {-1, 0.5}, + } + for i := range want { + if !near(t, got[i][0], want[i][0]) || !near(t, got[i][1], want[i][1]) { + t.Errorf("frame %d: got %v, want %v", i, got[i], want[i]) + } + } +} + +// TestMonoIsDuplicated checks that one channel reaches both ears rather +// than being silently dropped or halving the frame count. +func TestMonoIsDuplicated(t *testing.T) { + in := s16(16384, -16384) + s := pcm.NewStreamer(bytes.NewReader(in), mono(22050)) + + got := make([][2]float64, 4) + n, ok := s.Stream(got) + if !ok || n != 2 { + t.Fatalf("Stream: got n=%d ok=%v, want 2 frames", n, ok) + } + for i, want := range []float64{0.5, -0.5} { + if !near(t, got[i][0], want) || !near(t, got[i][1], want) { + t.Errorf("frame %d: got %v, want both channels %v", i, got[i], want) + } + } +} + +// dribble returns at most n bytes per Read, so frames land split across +// calls. The underlying decoder is free to do this, and the earlier +// small-read test showed it does. +type dribble struct { + r io.Reader + n int +} + +func (d dribble) Read(p []byte) (int, error) { + if len(p) > d.n { + p = p[:d.n] + } + return d.r.Read(p) +} + +// TestPartialFramesAcrossReads streams through a reader that splits +// frames, and checks nothing is lost or misaligned. +func TestPartialFramesAcrossReads(t *testing.T) { + const frames = 100 + samples := make([]int16, 0, frames*2) + for i := 0; i < frames; i++ { + samples = append(samples, int16(i), int16(-i)) + } + in := s16(samples...) + + // Three bytes at a time never aligns with a four byte frame. + s := pcm.NewStreamer(dribble{r: bytes.NewReader(in), n: 3}, stereo(44100)) + + var got [][2]float64 + buf := make([][2]float64, 7) + for { + n, ok := s.Stream(buf) + got = append(got, buf[:n]...) + if !ok { + break + } + } + if err := s.Err(); err != nil { + t.Fatalf("Err: %v", err) + } + if len(got) != frames { + t.Fatalf("got %d frames, want %d", len(got), frames) + } + for i := range got { + wantL := float64(int16(i)) / 32768 + wantR := float64(int16(-i)) / 32768 + if !near(t, got[i][0], wantL) || !near(t, got[i][1], wantR) { + t.Fatalf("frame %d: got %v, want {%v %v}", i, got[i], wantL, wantR) + } + } +} + +// TestRejectsUnsupportedFormat ensures a format the adapter cannot honour +// is reported rather than producing quiet nonsense. +func TestRejectsUnsupportedFormat(t *testing.T) { + s := pcm.NewStreamer(bytes.NewReader(s16(1, 2, 3, 4)), nativeaudio.Format{ + SampleRate: 44100, Channels: 2, BytesPerSample: 3, + }) + if n, ok := s.Stream(make([][2]float64, 4)); ok || n != 0 { + t.Fatalf("Stream: got n=%d ok=%v, want it to refuse", n, ok) + } + if s.Err() == nil { + t.Fatal("Err: want an error for a 3 byte sample size") + } +} + +// closeSpy records whether the adapter closed what it was given. +type closeSpy struct { + io.Reader + closed bool +} + +func (c *closeSpy) Close() error { + c.closed = true + return nil +} + +// TestCloseDelegates covers the streaming case, where the reader owns +// decoder state that has to be released. +func TestCloseDelegates(t *testing.T) { + spy := &closeSpy{Reader: bytes.NewReader(s16(1, 2))} + s := pcm.NewStreamer(spy, stereo(44100)) + if err := s.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if !spy.closed { + t.Error("Close did not close the underlying reader") + } + + // A plain reader is not a closer, and that must not be an error. + if err := pcm.NewStreamer(bytes.NewReader(nil), stereo(44100)).Close(); err != nil { + t.Errorf("Close on a non-closer: %v", err) + } +} + +// TestEmptyInput checks the degenerate case reports no frames rather than +// blocking or panicking. +func TestEmptyInput(t *testing.T) { + s := pcm.NewStreamer(bytes.NewReader(nil), stereo(44100)) + if n, ok := s.Stream(make([][2]float64, 4)); ok || n != 0 { + t.Fatalf("Stream: got n=%d ok=%v, want no frames", n, ok) + } + if err := s.Err(); err != nil { + t.Fatalf("Err: %v", err) + } +}