nativeaudio

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

commit a76078f6f04315c96eda344fe139037836a55900
parent 29feeef5d399bb0613c8094f07d63a8e8cdeb8ec
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Sat, 19 Sep 2026 14:56:54 -0300

docs: describe the linked Linux backend and what linking implies

Dynamic linking answers the static-linking concern; distro builds
configured with --enable-gpl are the case worth flagging.

Diffstat:
MREADME.md | 24++++++++++++++++--------
Maudio.go | 19+++++++++++--------
2 files changed, 27 insertions(+), 16 deletions(-)

diff --git a/README.md b/README.md @@ -14,12 +14,21 @@ licenses for. Windows is implemented via the Media Foundation and macOS is implemented on AudioToolbox/AVFoundation. -Other platforms including Linux shell out to FFmpeg. +Linux links FFmpeg's libraries directly. Other platforms, and any build +without cgo, run the FFmpeg binary as a sub-process instead. -FFmpeg's libraries are LGPL, which Go's static linking does not sit well -with, and running it as a sub-process keeps both the licence question and -the patent question with whoever installed it. We take the performance hit -for that. +The linking is dynamic, which is the case LGPL exists to permit. The +original concern was Go's *static* linking, which LGPL genuinely does not +sit well with; dynamic linking does not raise it. Nothing is vendored or +redistributed either way, so the patent question still sits with whoever +installed FFmpeg. + +One caveat worth knowing. Many distributions build FFmpeg with +`--enable-gpl`, which makes the libraries GPL rather than LGPL; Arch's is +GPL-3.0. The FSF's position is that linking, statically or dynamically, +makes a combined work, so anyone shipping a binary linked against such a +build inherits that obligation. Building with `CGO_ENABLED=0` selects the +sub-process path, which is arm's length and raises no such question. `go get git.sr.ht/~jackmordaunt/nativeaudio` @@ -66,8 +75,8 @@ play.File("audio.m4a") - `Decoder.StreamFile(path)` and `Decoder.Stream(data)` return a `Stream`, an `io.Reader` over the same PCM, so a long track never has to sit in memory whole. `Format` is known before the first read. Close it when - done. Windows decodes incrementally and the ffmpeg backend pipes; macOS - currently decodes up front and serves from memory. + done. Windows and Linux decode incrementally, and the sub-process + backend pipes; macOS currently decodes up front and serves from memory. - `Format` reports `SampleRate`, `Channels` and `BytesPerSample`, which is always 2. - A `Decoder` is safe for concurrent use, and `Close` waits for decodes @@ -156,4 +165,3 @@ ship by default. ## TODO - [ ] macOS: decode incrementally rather than buffering behind `Stream` -- [ ] Linux: something better then shelling out to FFmpeg diff --git a/audio.go b/audio.go @@ -4,7 +4,10 @@ // // Windows: Media Foundation // macOS: AudioToolbox -// Linux: ffmpeg +// Linux: ffmpeg's libraries, linked +// +// Built without cgo, and on any other operating system, the ffmpeg +// binary is run as a subprocess instead. // // Output is always signed 16-bit little-endian PCM, which is directly // playable and is what the common Go audio stacks expect. The play @@ -126,9 +129,9 @@ type Format struct { // Stream decodes compressed audio held in memory, returning PCM through // an [io.Reader] rather than a single buffer. // -// The returned Stream must be closed. Only the Windows backend decodes -// incrementally today; elsewhere the audio is decoded up front and -// served from memory, which is correct but saves nothing. +// The returned Stream must be closed. The Windows and Linux backends +// decode incrementally; macOS and the subprocess fallback decode up +// front and serve from memory, which is correct but saves nothing. func (d *Decoder) Stream(compressed []byte) (*Stream, error) { d.mu.RLock() defer d.mu.RUnlock() @@ -147,10 +150,10 @@ func (d *Decoder) Stream(compressed []byte) (*Stream, error) { // StreamFile decodes the audio file at path, returning PCM through an // [io.Reader] rather than a single buffer. // -// The returned Stream must be closed. Backends that shell out to ffmpeg -// pipe the decode directly, so the PCM is never held whole; the Windows -// backend reads the compressed file into memory first, which is small -// next to the PCM it avoids buffering. +// The returned Stream must be closed. The Linux backend and the +// subprocess fallback read the file directly, so the PCM is never held +// whole; the Windows backend reads the compressed file into memory +// first, which is small next to the PCM it avoids buffering. func (d *Decoder) StreamFile(path string) (*Stream, error) { d.mu.RLock() defer d.mu.RUnlock()