commit 4204f85890a6af43cf105af3a3e14df76c12a741
parent debd8ef9f7a7621c307396ef79546bc5a084a245
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Fri, 18 Sep 2026 13:15:16 -0400
audio: replace the global API with a Decoder value
Start, End, Load and Decode operated on hidden package state guarded by
a mutex and an initialised flag. Two parts of a program could not each
hold their own decoder, and either could call End and tear the platform
out from under the other. Every decode also had to call Start
implicitly, so initialisation failures surfaced in unrelated places.
New returns a Decoder that owns the platform state and Close releases
it. Media Foundation refcounts startup against shutdown, so independent
decoders coexist, which a new test pins down. Use after Close returns
ErrClosed rather than misbehaving, and the read lock lets decodes run
concurrently while Close waits for those in flight.
The Media Foundation entry points are now resolved once per process
instead of on every startup, since the handles outlive any one decoder.
Diffstat:
8 files changed, 205 insertions(+), 81 deletions(-)
diff --git a/README.md b/README.md
@@ -25,36 +25,55 @@ take the performance hit of using a sub-process.
package main
import (
+ "log"
+
"git.sr.ht/~jackmordaunt/nativeaudio"
- "git.sr.ht/~jackmordaunt/nativeaudio/play"
)
func main() {
- // Decode to PCM and hand it to whatever audio stack you use.
- pcm, format, err := nativeaudio.Load("audio.m4a")
- _, _, _ = pcm, format, err
-
- // Or use the playback helper.
- play.File("audio.m4a")
+ d, err := nativeaudio.New()
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer d.Close()
+
+ pcm, format, err := d.DecodeFile("audio.m4a")
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Printf("%d bytes of PCM, %+v", len(pcm), format)
}
```
+Or, to just hear it:
+
+```go
+import "git.sr.ht/~jackmordaunt/nativeaudio/play"
+
+play.File("audio.m4a")
+```
+
## API
-- `Load(path)` and `Decode(data)` return s16le PCM and a `Format`. This is
- the core of the package and has no audio-output dependency.
+- `New()` returns a `Decoder` holding the platform state. Close it when you
+ are done. Independent parts of a program can each hold their own, and
+ closing one does not disturb another.
+- `Decoder.DecodeFile(path)` and `Decoder.Decode(data)` return s16le PCM
+ and a `Format`. The core package has no audio-output dependency.
- `Format` reports `SampleRate`, `Channels` and `BytesPerSample`, which is
always 2.
-- `Start()` and `End()` initialise and tear down platform state. The
- functions above call `Start()` for you; call `End()` when you are done.
+- A `Decoder` is safe for concurrent use, and `Close` waits for decodes
+ already in flight.
- `FFmpegLoad` and `FFmpegDecode` shell out to ffmpeg regardless of
- platform.
+ platform, and need no `Decoder`.
- The `play` subpackage plays PCM through oto on every platform. Its
context is fixed to the first file's sample rate and channel count for
the life of the process.
-v1.0.0 renamed `Format.BitDepth` to `BytesPerSample` and removed the
-Windows Media Foundation bindings from the public API.
+v1.0.0 replaced the package-level `Start`, `End`, `Load`, `Decode` and
+`Play` with a `Decoder` value and the `play` subpackage, renamed
+`Format.BitDepth` to `BytesPerSample`, and made the Windows Media
+Foundation bindings internal.
## TODO
diff --git a/audio.go b/audio.go
@@ -1,72 +1,90 @@
-// Package nativeaudio leverages native decoders for each supported OS
-// to decode raw PCM data.
-//
-// Where there are no native APIs to call we default to invoking ffmpeg.
+// Package nativeaudio decodes compressed audio into PCM using the
+// decoder each operating system already ships, falling back to ffmpeg
+// where there is no native API to call.
//
// Windows: Media Foundation
// macOS: AudioToolbox
// Linux: ffmpeg
+//
+// Output is always signed 16-bit little-endian PCM, which is directly
+// playable and is what the common Go audio stacks expect. The play
+// subpackage is a thin convenience over that for callers who just want
+// to hear a file.
package nativeaudio
import (
+ "errors"
+ "fmt"
"sync"
)
-var (
- mu sync.Mutex
- initialized bool = false
-)
+// ErrClosed is returned when a Decoder is used after Close.
+var ErrClosed = errors.New("nativeaudio: decoder is closed")
-// Start initializes any platform code required.
-func Start() error {
- mu.Lock()
- defer mu.Unlock()
- if initialized {
- return nil
- }
+// Decoder decodes compressed audio into PCM.
+//
+// Create one with New and release it with Close. A Decoder owns
+// whatever platform state the backend requires, which is why it is a
+// value rather than a set of package functions: two independent parts
+// of a program can hold their own without one tearing down the other.
+//
+// A Decoder is safe for concurrent use. Decodes may run in parallel,
+// and Close waits for those in flight to finish.
+type Decoder struct {
+ mu sync.RWMutex
+ closed bool
+}
+
+// New creates a Decoder, initialising any platform state the backend
+// needs. Call Close when you are finished with it.
+func New() (*Decoder, error) {
if err := start(); err != nil {
- return err
+ return nil, fmt.Errorf("initialising platform decoder: %w", err)
}
- initialized = true
- return nil
+ return &Decoder{}, nil
}
-// End cleans up platform code, if any.
-func End() error {
- mu.Lock()
- defer mu.Unlock()
- if !initialized {
+// Close releases the platform state held by the Decoder. It is
+// idempotent, and any further use of the Decoder returns ErrClosed.
+func (d *Decoder) Close() error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if d.closed {
return nil
}
+ d.closed = true
if err := end(); err != nil {
- return err
+ return fmt.Errorf("shutting down platform decoder: %w", err)
}
- initialized = false
return nil
}
-// Load compressed data, returning the uncompressed data as PCM data
-// (s16le) and details about the PCM required to playback correctly.
-func Load(path string) (uncompressed []byte, format Format, err error) {
- if err := Start(); err != nil {
- return nil, format, err
+// DecodeFile decodes the audio file at path, returning s16le PCM and
+// the format needed to play it back correctly.
+func (d *Decoder) DecodeFile(path string) (pcm []byte, format Format, err error) {
+ d.mu.RLock()
+ defer d.mu.RUnlock()
+ if d.closed {
+ return nil, format, ErrClosed
}
return load(path)
}
-// Decode compressed data, returning the uncompressed data as PCM data
-// (s16le) and details about the PCM required to playback correctly.
-func Decode(compressed []byte) (uncompressed []byte, format Format, err error) {
- if err := Start(); err != nil {
- return nil, format, err
+// Decode decodes compressed audio held in memory, returning s16le PCM
+// and the format needed to play it back correctly.
+func (d *Decoder) Decode(compressed []byte) (pcm []byte, format Format, err error) {
+ d.mu.RLock()
+ defer d.mu.RUnlock()
+ if d.closed {
+ return nil, format, ErrClosed
}
return decode(compressed)
}
-// Format describes the features of the associated PCM data necessary
-// for correct playback.
+// Format describes the PCM a decode produced, and is everything needed
+// to play it back correctly.
type Format struct {
SampleRate int // samples per second.
- Channels int // number channels.
- BytesPerSample int // bytes per sample; 2 for the s16le output this package produces.
+ Channels int // number of channels.
+ BytesPerSample int // bytes per sample; always 2, for the s16le output this package produces.
}
diff --git a/cmd/nativeaudio/main.go b/cmd/nativeaudio/main.go
@@ -26,7 +26,12 @@ func main() {
func run() error {
if out != "" {
- un, _, err := nativeaudio.Load(in)
+ d, err := nativeaudio.New()
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ un, _, err := d.DecodeFile(in)
if err != nil {
return fmt.Errorf("loading audio file: %w", err)
}
diff --git a/internal/example/main.go b/internal/example/main.go
@@ -12,7 +12,12 @@ func main() {
if err != nil {
panic(fmt.Errorf("reading file: %w", err))
}
- data, format, err := nativeaudio.Decode(by)
+ d, err := nativeaudio.New()
+ if err != nil {
+ panic(fmt.Errorf("creating decoder: %w", err))
+ }
+ defer d.Close()
+ data, format, err := d.Decode(by)
if err != nil {
panic(fmt.Errorf("decoding audio: %w", err))
}
diff --git a/internal/mf/mf.go b/internal/mf/mf.go
@@ -13,6 +13,7 @@ import (
"fmt"
"io"
"runtime"
+ "sync"
"syscall"
"unicode/utf16"
"unsafe"
@@ -808,7 +809,12 @@ var (
_MFCreateSourceReaderFromByteStream *windows.Proc
)
-func MFStartup(version, flags uintptr) (err error) {
+var loadOnce sync.Once
+
+// loadProcs resolves the DLLs and entry points once per process. The
+// handles stay valid for the life of the process, so repeated Startup
+// and Shutdown cycles reuse them.
+func loadProcs() (err error) {
_mfplat, err = windows.LoadDLL("Mfplat.dll")
if err != nil {
return fmt.Errorf("Mfplat.dll: %w", err)
@@ -850,6 +856,18 @@ func MFStartup(version, flags uintptr) (err error) {
return fmt.Errorf("MFCreateSourceReaderFromByteStream: %w", err)
}
+ return nil
+}
+
+// MFStartup initialises Media Foundation. The platform refcounts this
+// against MFShutdown, so callers must pair them.
+func MFStartup(version, flags uintptr) error {
+ var loadErr error
+ loadOnce.Do(func() { loadErr = loadProcs() })
+ if loadErr != nil {
+ return loadErr
+ }
+
r, _, _ := _MFStartup.Call(version, flags)
if r != S_OK {
return MFErr{Code: r}
diff --git a/internal/test/audio_test.go b/internal/test/audio_test.go
@@ -38,7 +38,7 @@ func getUncompressed() []byte {
// TestLoad ensures that output from the native decoders are close to
// the output of ffmpeg.
func TestLoad(t *testing.T) {
- by, f, err := nativeaudio.Load("compressed.m4a")
+ by, f, err := newDecoder(t).DecodeFile("compressed.m4a")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -65,7 +65,7 @@ func TestLoad(t *testing.T) {
// TestDecode ensures that output from the native decoders are similar to
// the output of ffmpeg.
func TestDecode(t *testing.T) {
- by, f, err := nativeaudio.Decode(compressed)
+ by, f, err := newDecoder(t).Decode(compressed)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -92,7 +92,7 @@ func TestDecode(t *testing.T) {
// TestDecodeCorrupt ensures that we get an error value on invalid input and
// that we don't crash the process.
func TestDecodeCorrupt(t *testing.T) {
- _, f, err := nativeaudio.Decode(corrupt)
+ _, f, err := newDecoder(t).Decode(corrupt)
if err == nil {
t.Fatalf("expected error for corrupt audio data, got nil")
}
@@ -104,15 +104,24 @@ func TestDecodeCorrupt(t *testing.T) {
func TestMemoryLeak(t *testing.T) {
runtime.MemProfileRate = 1
- for ii := 0; ii < 10; ii++ {
- by, f, err := nativeaudio.Load("compressed.m4a")
+ // Scoped so the Decoder itself is unreachable before the profile is
+ // taken. Holding it live would show up here as an allocation that
+ // was never freed, which is exactly what this test looks for.
+ func() {
+ d, err := nativeaudio.New()
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf("creating decoder: %v", err)
}
- _ = by
- _ = f
- _ = err
- }
+ defer d.Close()
+ for ii := 0; ii < 10; ii++ {
+ by, f, err := d.DecodeFile("compressed.m4a")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ _ = by
+ _ = f
+ }
+ }()
runtime.GC()
runtime.GC()
@@ -189,8 +198,10 @@ func abs(n int) int {
func BenchmarkDecode(b *testing.B) {
b.Run("native-decode", func(b *testing.B) {
+ d := newDecoder(b)
+ b.ResetTimer()
for ii := 0; ii < b.N; ii++ {
- by, f, err := nativeaudio.Decode(compressed)
+ by, f, err := d.Decode(compressed)
if err != nil {
b.Fatalf("unexpected error during decode: %v", err)
}
diff --git a/internal/test/decode_test.go b/internal/test/decode_test.go
@@ -3,6 +3,7 @@ package test
import (
"bytes"
"encoding/binary"
+ "errors"
"os"
"path/filepath"
"testing"
@@ -54,7 +55,7 @@ func TestDecodeFormats(t *testing.T) {
{48000, 2},
}
for _, c := range cases {
- by, f, err := nativeaudio.Decode(silentWAV(c.rate, c.channels, 1))
+ by, f, err := newDecoder(t).Decode(silentWAV(c.rate, c.channels, 1))
if err != nil {
t.Errorf("%d Hz %d ch: unexpected error: %v", c.rate, c.channels, err)
continue
@@ -71,28 +72,50 @@ func TestDecodeFormats(t *testing.T) {
// TestLoadMissingFile ensures a bad path is reported as an error.
func TestLoadMissingFile(t *testing.T) {
- if _, _, err := nativeaudio.Load(filepath.Join(t.TempDir(), "does-not-exist.m4a")); err == nil {
+ if _, _, err := newDecoder(t).DecodeFile(filepath.Join(t.TempDir(), "does-not-exist.m4a")); err == nil {
t.Fatal("expected error for missing file, got nil")
}
}
// TestStartEndCycle ensures the platform can be torn down and brought
// back up repeatedly, with a decode in between to prove each Start took.
-func TestStartEndCycle(t *testing.T) {
+func TestDecoderLifecycle(t *testing.T) {
+ // Several create-and-close cycles, with a decode in between to prove
+ // each New actually initialised the platform.
for i := 0; i < 3; i++ {
- if err := nativeaudio.Start(); err != nil {
- t.Fatalf("cycle %d: Start: %v", i, err)
+ d, err := nativeaudio.New()
+ if err != nil {
+ t.Fatalf("cycle %d: New: %v", i, err)
}
- if _, _, err := nativeaudio.Decode(compressed); err != nil {
+ if _, _, err := d.Decode(compressed); err != nil {
t.Fatalf("cycle %d: Decode: %v", i, err)
}
- if err := nativeaudio.End(); err != nil {
- t.Fatalf("cycle %d: End: %v", i, err)
+ if err := d.Close(); err != nil {
+ t.Fatalf("cycle %d: Close: %v", i, err)
+ }
+ if err := d.Close(); err != nil {
+ t.Fatalf("cycle %d: second Close should be a no-op: %v", i, err)
+ }
+ if _, _, err := d.Decode(compressed); !errors.Is(err, nativeaudio.ErrClosed) {
+ t.Fatalf("cycle %d: decode after close: want ErrClosed, got %v", i, err)
}
}
- // Leave the platform started for the other tests, as they expect.
- if err := nativeaudio.Start(); err != nil {
- t.Fatalf("final Start: %v", err)
+}
+
+// TestDecodersAreIndependent ensures closing one Decoder does not tear
+// the platform out from under another. This is the whole reason the API
+// is a value rather than a set of package functions.
+func TestDecodersAreIndependent(t *testing.T) {
+ a, err := nativeaudio.New()
+ if err != nil {
+ t.Fatalf("first New: %v", err)
+ }
+ b := newDecoder(t)
+ if err := a.Close(); err != nil {
+ t.Fatalf("closing first: %v", err)
+ }
+ if _, _, err := b.Decode(compressed); err != nil {
+ t.Fatalf("second decoder broke when the first closed: %v", err)
}
}
@@ -113,7 +136,7 @@ func TestDecodeCorruptMidStream(t *testing.T) {
}
done := make(chan result, 1)
go func() {
- by, _, err := nativeaudio.Decode(bad)
+ by, _, err := newDecoder(t).Decode(bad)
done <- result{len(by), err}
}()
select {
@@ -123,3 +146,18 @@ func TestDecodeCorruptMidStream(t *testing.T) {
t.Fatal("decode did not return within 30s")
}
}
+
+// newDecoder returns a Decoder that is closed when the test ends.
+func newDecoder(t testing.TB) *nativeaudio.Decoder {
+ t.Helper()
+ d, err := nativeaudio.New()
+ if err != nil {
+ t.Fatalf("creating decoder: %v", err)
+ }
+ t.Cleanup(func() {
+ if err := d.Close(); err != nil {
+ t.Errorf("closing decoder: %v", err)
+ }
+ })
+ return d
+}
diff --git a/play/play.go b/play/play.go
@@ -84,7 +84,12 @@ func PCM(pcm []byte, format nativeaudio.Format) error {
// File decodes an audio file and plays it once, synchronously.
func File(path string) error {
- pcm, format, err := nativeaudio.Load(path)
+ d, err := nativeaudio.New()
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ pcm, format, err := d.DecodeFile(path)
if err != nil {
return fmt.Errorf("decoding %q: %w", path, err)
}
@@ -93,7 +98,12 @@ func File(path string) error {
// Data decodes compressed audio and plays it once, synchronously.
func Data(compressed []byte) error {
- pcm, format, err := nativeaudio.Decode(compressed)
+ d, err := nativeaudio.New()
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ pcm, format, err := d.Decode(compressed)
if err != nil {
return fmt.Errorf("decoding: %w", err)
}