nativeaudio

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

commit be6e3d2572b9cd2bcbfc0ea9b55b6f71774ea39c
parent 1f7e8a70d558e0b31085092b28648558188e9b21
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Thu, 17 Sep 2026 12:09:23 -0400

audio: share one oto playback context and wait for the buffer to drain

Windows and macOS each created an oto context inside play. oto permits
one context per process, so every Play after the first failed with
"context is already created". Both also returned as soon as the source
hit EOF, which is when oto has buffered the data rather than played it,
cutting up to half a second from the tail.

playback.go now holds the shared path: a context created on first use
and reused, a clear error for a later file whose sample rate or channel
count differs, and a wait on IsPlaying, which stays true until the buffer
has drained. Both platform play functions reduce to decode then playPCM.

The macOS build was not compiled for this change; its play body is a
two-line call into code exercised on Windows.

Diffstat:
Maudio_macos.go | 20+-------------------
Maudio_windows.go | 19+------------------
Aplayback.go | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 79 insertions(+), 37 deletions(-)

diff --git a/audio_macos.go b/audio_macos.go @@ -34,7 +34,6 @@ SInt64 AudioFileGetSizeProcImpl( */ import "C" import ( - "bytes" "errors" "fmt" "io" @@ -42,9 +41,6 @@ import ( "runtime" "sync/atomic" "unsafe" - - "git.sr.ht/~jackmordaunt/nativeaudio/internal" - "github.com/ebitengine/oto/v3" ) func start() error { @@ -60,21 +56,7 @@ func play(path string) error { if err != nil { return err } - ctx, ready, err := oto.NewContext(&oto.NewContextOptions{ - SampleRate: format.SampleRate, - ChannelCount: format.Channels, - Format: oto.FormatSignedInt16LE, - }) - if err != nil { - return fmt.Errorf("starting playback context: %w", err) - } - <-ready - done := make(chan any) - player := ctx.NewPlayer(internal.NewTriggerReader(bytes.NewReader(data), func() { close(done) })) - player.Play() - <-done - ctx.Suspend() - return player.Close() + return playPCM(data, format) } func load(path string) (_ []byte, f Format, _ error) { diff --git a/audio_windows.go b/audio_windows.go @@ -1,7 +1,6 @@ package nativeaudio import ( - "bytes" "fmt" "io" "os" @@ -10,8 +9,6 @@ import ( "unicode/utf16" "unsafe" - "git.sr.ht/~jackmordaunt/nativeaudio/internal" - "github.com/ebitengine/oto/v3" "golang.org/x/sys/windows" ) @@ -29,21 +26,7 @@ func play(path string) error { if err != nil { return fmt.Errorf("decoding: %w", err) } - ctx, ready, err := oto.NewContext(&oto.NewContextOptions{ - SampleRate: format.SampleRate, - ChannelCount: format.Channels, - Format: oto.FormatSignedInt16LE, - }) - if err != nil { - return fmt.Errorf("starting playback context: %w", err) - } - <-ready - done := make(chan any) - player := ctx.NewPlayer(internal.NewTriggerReader(bytes.NewReader(data), func() { close(done) })) - player.Play() - <-done - ctx.Suspend() - return player.Close() + return playPCM(data, format) } // load raw pcm data from the Windows Media Foundation. diff --git a/playback.go b/playback.go @@ -0,0 +1,77 @@ +//go:build windows || (darwin && cgo) + +package nativeaudio + +import ( + "bytes" + "fmt" + "sync" + "time" + + "github.com/ebitengine/oto/v3" +) + +var ( + playbackMu sync.Mutex + playbackCtx *oto.Context + playbackFormat Format +) + +// playbackContext returns the process-wide oto context, creating it on +// first use. oto permits exactly one context per process and fixes its +// sample rate and channel count at creation, so every file played after +// the first must share the first file's format. +func playbackContext(f Format) (*oto.Context, error) { + playbackMu.Lock() + defer playbackMu.Unlock() + if playbackCtx != nil { + if f != playbackFormat { + return nil, fmt.Errorf("playback context is fixed at %d Hz, %d channel(s) by the first file played; cannot play %d Hz, %d channel(s) in the same process", + playbackFormat.SampleRate, playbackFormat.Channels, f.SampleRate, f.Channels) + } + return playbackCtx, nil + } + ctx, ready, err := oto.NewContext(&oto.NewContextOptions{ + SampleRate: f.SampleRate, + ChannelCount: f.Channels, + Format: oto.FormatSignedInt16LE, + }) + if err != nil { + return nil, fmt.Errorf("starting playback context: %w", err) + } + <-ready + playbackCtx, playbackFormat = ctx, f + return ctx, nil +} + +// playPCM plays s16le PCM synchronously through the shared oto context, +// returning once the audio has finished. +func playPCM(data []byte, format Format) error { + if format.BitDepth != 2 { + return fmt.Errorf("playback: unsupported sample size %d bytes, want 2", format.BitDepth) + } + ctx, err := playbackContext(format) + if err != nil { + return err + } + if err := ctx.Resume(); err != nil { + return fmt.Errorf("resuming playback context: %w", err) + } + player := ctx.NewPlayer(bytes.NewReader(data)) + player.Play() + // IsPlaying stays true until the source is exhausted AND oto's + // 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. + for player.IsPlaying() { + time.Sleep(10 * time.Millisecond) + } + err = player.Err() + if cerr := player.Close(); err == nil && cerr != nil { + err = fmt.Errorf("closing player: %w", cerr) + } + if serr := ctx.Suspend(); err == nil && serr != nil { + err = fmt.Errorf("suspending playback context: %w", serr) + } + return err +}