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:
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()