nativeaudio

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

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

libav: decode by linking ffmpeg instead of spawning it

Output is bit-identical to the ffmpeg binary here, and decoding from
memory no longer needs a temporary file.

Diffstat:
Maudio_linux.go | 43++++++++++++++++++++++++++++++++++---------
Ainternal/libav/libav.go | 464+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 498 insertions(+), 9 deletions(-)

diff --git a/audio_linux.go b/audio_linux.go @@ -2,25 +2,50 @@ package nativeaudio +import ( + "git.sr.ht/~jackmordaunt/nativeaudio/internal/libav" +) + func start() error { - return nil + return libav.Startup() } func end() error { - return nil + return libav.Shutdown() } -// openStream decodes up front, because ffmpeg cannot read compressed -// audio from a pipe for every container we support. -func openStream(by []byte) (*Stream, error) { - pcm, format, err := FFmpegDecode(by) +// openStream decodes incrementally through libav. +// +// Unlike the subprocess path this needs no temporary file: a custom +// AVIOContext gives the demuxer a seekable view over the bytes, which +// is what containers like mp4 want and what a pipe cannot offer. +func openStream(compressed []byte) (*Stream, error) { + ls, err := libav.Open(compressed) if err != nil { return nil, err } - return newBufferedStream(pcm, format), nil + return adapt(ls), nil } -// openStreamFile pipes PCM straight out of ffmpeg. +// openStreamFile decodes the file incrementally, letting libav read it +// rather than buffering the compressed bytes first. func openStreamFile(path string) (*Stream, error) { - return FFmpegStream(path) + ls, err := libav.OpenFile(path) + if err != nil { + return nil, err + } + return adapt(ls), nil +} + +// adapt wraps a libav stream in the package's own Stream type. +func adapt(ls *libav.Stream) *Stream { + f := ls.Format() + return &Stream{ + r: ls, + format: Format{ + SampleRate: f.SampleRate, + Channels: f.Channels, + BytesPerSample: f.BytesPerSample, + }, + } } diff --git a/internal/libav/libav.go b/internal/libav/libav.go @@ -0,0 +1,464 @@ +//go:build linux && cgo + +// Package libav decodes compressed audio by linking ffmpeg's libraries +// directly, rather than shelling out to the ffmpeg binary. +// +// The decode pipeline lives in C. libav is a struct-field API, and the +// layout of AVFrame, AVCodecContext and friends changes between major +// versions, so letting the headers describe them at build time is the +// only way to stay correct across the libavcodec versions distributions +// actually ship. Go sees five functions over an opaque handle. +// +// Output matches what the subprocess path produces: interleaved s16le +// at the source's own sample rate and channel count. +package libav + +/* +#cgo pkg-config: libavformat libavcodec libavutil libswresample + +#include <libavcodec/avcodec.h> +#include <libavformat/avformat.h> +#include <libavutil/channel_layout.h> +#include <libavutil/opt.h> +#include <libswresample/swresample.h> +#include <stdint.h> +#include <stdlib.h> +#include <string.h> + +// Size of the buffer avio reads the compressed input through. Nothing +// depends on it beyond amortising the callback. +#define NA_IO_BUFFER 4096 + +typedef struct na_dec { + AVFormatContext *fmt; + AVCodecContext *codec; + SwrContext *swr; + AVPacket *pkt; + AVFrame *frame; + AVIOContext *avio; + + uint8_t *input; // owned copy of the compressed bytes; memory source only. + int64_t input_size; + int64_t input_pos; + + int index; // index of the audio stream being decoded. + int sample_rate; + int channels; + + uint8_t *out; // converted PCM waiting to be collected. + int out_cap; + int out_len; + int out_off; + + int drained; // demuxer exhausted and the decoder flushed. +} na_dec; + +static int na_read_packet(void *opaque, uint8_t *buf, int size) { + na_dec *d = (na_dec *)opaque; + int64_t remaining = d->input_size - d->input_pos; + if (remaining <= 0) { + return AVERROR_EOF; + } + if ((int64_t)size > remaining) { + size = (int)remaining; + } + memcpy(buf, d->input + d->input_pos, size); + d->input_pos += size; + return size; +} + +// na_seek backs the seeking that mp4 and friends need to read a moov +// atom that trails the audio. It is why decoding from memory uses a +// custom AVIOContext rather than a pipe: the subprocess path cannot +// seek a pipe, which is the whole reason it writes a temporary file. +static int64_t na_seek(void *opaque, int64_t offset, int whence) { + na_dec *d = (na_dec *)opaque; + int64_t pos; + if (whence == AVSEEK_SIZE) { + return d->input_size; + } + switch (whence) { + case SEEK_SET: pos = offset; break; + case SEEK_CUR: pos = d->input_pos + offset; break; + case SEEK_END: pos = d->input_size + offset; break; + default: return -1; + } + if (pos < 0 || pos > d->input_size) { + return -1; + } + d->input_pos = pos; + return pos; +} + +// na_init finds the audio stream and opens a decoder for it. The +// resampler is left until the first frame arrives, because a decoder is +// not obliged to report its sample format before it has decoded +// anything, while codecpar always knows the rate and channel count. +static int na_init(na_dec *d) { + const AVCodec *codec = NULL; + AVCodecParameters *par = NULL; + int err; + + if ((err = avformat_find_stream_info(d->fmt, NULL)) < 0) { + return err; + } + if ((err = av_find_best_stream(d->fmt, AVMEDIA_TYPE_AUDIO, -1, -1, &codec, 0)) < 0) { + return err; + } + d->index = err; + par = d->fmt->streams[d->index]->codecpar; + + if (!(d->codec = avcodec_alloc_context3(codec))) { + return AVERROR(ENOMEM); + } + if ((err = avcodec_parameters_to_context(d->codec, par)) < 0) { + return err; + } + if ((err = avcodec_open2(d->codec, codec, NULL)) < 0) { + return err; + } + if (!(d->pkt = av_packet_alloc()) || !(d->frame = av_frame_alloc())) { + return AVERROR(ENOMEM); + } + + d->sample_rate = par->sample_rate; + d->channels = par->ch_layout.nb_channels; + return 0; +} + +// na_convert resamples the decoded frame to interleaved s16le, building +// the resampler on first use from the frame's own description. +static int na_convert(na_dec *d) { + int channels, want, need, got; + uint8_t *plane[1]; + int err; + + if (!d->swr) { + AVChannelLayout out; + memset(&out, 0, sizeof(out)); + if ((err = av_channel_layout_copy(&out, &d->frame->ch_layout)) < 0) { + return err; + } + err = swr_alloc_set_opts2(&d->swr, + &out, AV_SAMPLE_FMT_S16, d->frame->sample_rate, + &d->frame->ch_layout, (enum AVSampleFormat)d->frame->format, d->frame->sample_rate, + 0, NULL); + av_channel_layout_uninit(&out); + if (err < 0) { + return err; + } + if ((err = swr_init(d->swr)) < 0) { + return err; + } + } + + channels = d->frame->ch_layout.nb_channels; + if ((want = swr_get_out_samples(d->swr, d->frame->nb_samples)) < 0) { + return want; + } + need = want * channels * 2; + if (need > d->out_cap) { + uint8_t *grown = av_realloc(d->out, need); + if (!grown) { + return AVERROR(ENOMEM); + } + d->out = grown; + d->out_cap = need; + } + + plane[0] = d->out; + got = swr_convert(d->swr, plane, want, (const uint8_t **)d->frame->extended_data, d->frame->nb_samples); + if (got < 0) { + return got; + } + d->out_len = got * channels * 2; + d->out_off = 0; + return 0; +} + +// na_next refills the pending buffer, returning AVERROR_EOF once the +// input is exhausted and the decoder has been flushed. +static int na_next(na_dec *d) { + int err; + + for (;;) { + err = avcodec_receive_frame(d->codec, d->frame); + if (err == 0) { + if ((err = na_convert(d)) < 0) { + return err; + } + if (d->out_len == 0) { + continue; + } + return 0; + } + if (err == AVERROR_EOF) { + return AVERROR_EOF; + } + if (err != AVERROR(EAGAIN)) { + return err; + } + if (d->drained) { + return AVERROR_EOF; + } + + err = av_read_frame(d->fmt, d->pkt); + if (err == AVERROR_EOF) { + d->drained = 1; + // A null packet tells the decoder to emit whatever it has + // buffered, which is where the tail of the audio comes from. + avcodec_send_packet(d->codec, NULL); + continue; + } + if (err < 0) { + return err; + } + if (d->pkt->stream_index != d->index) { + av_packet_unref(d->pkt); + continue; + } + err = avcodec_send_packet(d->codec, d->pkt); + av_packet_unref(d->pkt); + if (err < 0 && err != AVERROR(EAGAIN)) { + return err; + } + } +} + +static int na_read(na_dec *d, uint8_t *buf, int size) { + int n, err; + + if (d->out_off >= d->out_len) { + if ((err = na_next(d)) < 0) { + return err; + } + } + n = d->out_len - d->out_off; + if (n > size) { + n = size; + } + memcpy(buf, d->out + d->out_off, n); + d->out_off += n; + return n; +} + +static na_dec *na_alloc(void) { + return (na_dec *)av_mallocz(sizeof(na_dec)); +} + +static int na_open_file(na_dec *d, const char *path) { + int err = avformat_open_input(&d->fmt, path, NULL, NULL); + if (err < 0) { + return err; + } + return na_init(d); +} + +static int na_open_memory(na_dec *d, const uint8_t *data, int64_t size) { + uint8_t *iobuf; + int err; + + if (!(d->input = av_malloc(size))) { + return AVERROR(ENOMEM); + } + memcpy(d->input, data, size); + d->input_size = size; + + if (!(iobuf = av_malloc(NA_IO_BUFFER))) { + return AVERROR(ENOMEM); + } + if (!(d->avio = avio_alloc_context(iobuf, NA_IO_BUFFER, 0, d, na_read_packet, NULL, na_seek))) { + av_free(iobuf); + return AVERROR(ENOMEM); + } + if (!(d->fmt = avformat_alloc_context())) { + return AVERROR(ENOMEM); + } + d->fmt->pb = d->avio; + d->fmt->flags |= AVFMT_FLAG_CUSTOM_IO; + + if ((err = avformat_open_input(&d->fmt, NULL, NULL, NULL)) < 0) { + return err; + } + return na_init(d); +} + +static void na_free(na_dec *d) { + if (!d) { + return; + } + if (d->frame) av_frame_free(&d->frame); + if (d->pkt) av_packet_free(&d->pkt); + if (d->swr) swr_free(&d->swr); + if (d->codec) avcodec_free_context(&d->codec); + // A failed avformat_open_input has already freed the context and + // nulled it, so this covers both paths. + if (d->fmt) avformat_close_input(&d->fmt); + if (d->avio) { + // Custom IO owns its buffer; closing the format context does not + // reclaim it, and avio may have swapped it for a larger one. + av_freep(&d->avio->buffer); + avio_context_free(&d->avio); + } + if (d->input) av_freep(&d->input); + if (d->out) av_freep(&d->out); + av_free(d); +} +*/ +import "C" + +import ( + "errors" + "fmt" + "io" + "sync" + "unsafe" +) + +// Format describes the PCM a Stream produces. +type Format struct { + SampleRate int + Channels int + BytesPerSample int +} + +// Startup prepares the library. libav needs no global initialisation +// any more, so this only quietens its logging: libav writes diagnostics +// to stderr by default, and a library has no business doing that to its +// host. Decode failures are reported through error values instead. +func Startup() error { + C.av_log_set_level(C.AV_LOG_QUIET) + return nil +} + +// Shutdown releases global state. There is none to release. +func Shutdown() error { return nil } + +// Stream decodes incrementally, yielding interleaved s16le PCM. +type Stream struct { + mu sync.Mutex + dec *C.na_dec + format Format +} + +// Open decodes compressed audio held in memory. +// +// The bytes are copied into memory libav owns, which keeps them out of +// reach of the Go collector while the C callbacks read them. Compressed +// audio is roughly an order of magnitude smaller than the PCM it +// becomes, so the copy costs little next to what streaming saves. +func Open(compressed []byte) (*Stream, error) { + if len(compressed) == 0 { + return nil, errors.New("libav: no audio to decode") + } + dec := C.na_alloc() + if dec == nil { + return nil, errors.New("libav: allocating decoder") + } + rc := C.na_open_memory(dec, (*C.uint8_t)(unsafe.Pointer(&compressed[0])), C.int64_t(len(compressed))) + return finish(dec, rc, "decoding audio from memory") +} + +// OpenFile decodes the audio file at path. libav reads the file itself, +// so unlike the Media Foundation backend nothing is buffered up front. +func OpenFile(path string) (*Stream, error) { + dec := C.na_alloc() + if dec == nil { + return nil, errors.New("libav: allocating decoder") + } + cpath := C.CString(path) + defer C.free(unsafe.Pointer(cpath)) + rc := C.na_open_file(dec, cpath) + return finish(dec, rc, fmt.Sprintf("decoding %q", path)) +} + +// finish turns an open result into a Stream, releasing the decoder if +// the open failed or produced audio this package cannot represent. +func finish(dec *C.na_dec, rc C.int, what string) (*Stream, error) { + if rc < 0 { + C.na_free(dec) + return nil, fmt.Errorf("libav: %s: %s", what, averr(rc)) + } + channels := int(dec.channels) + if channels < 1 || channels > 2 { + C.na_free(dec) + return nil, fmt.Errorf("libav: can only handle {1,2} channels got %d", channels) + } + return &Stream{ + dec: dec, + format: Format{ + SampleRate: int(dec.sample_rate), + Channels: channels, + // Always 2: the resampler is configured for s16 regardless + // of what the source held. + BytesPerSample: 2, + }, + }, nil +} + +// Format describes the PCM this Stream produces. It is known as soon as +// the Stream is opened, before any audio is read. +func (s *Stream) Format() Format { return s.format } + +// Read fills p with decoded PCM, returning io.EOF once the audio is +// exhausted. +func (s *Stream) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.dec == nil { + return 0, io.EOF + } + n := C.na_read(s.dec, (*C.uint8_t)(unsafe.Pointer(&p[0])), C.int(len(p))) + switch { + case n == C.int(eof): + return 0, io.EOF + case n < 0: + return 0, fmt.Errorf("libav: decoding: %s", averr(n)) + } + return int(n), nil +} + +// Close releases the decoder. It is safe to call more than once, and +// abandoning a Stream before io.EOF is fine as long as it is closed. +// +// Nothing can wedge here the way Media Foundation can: the decode runs +// on the calling goroutine, so there is no worker to wait for and +// nothing in flight once Read has returned. +func (s *Stream) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.dec == nil { + return nil + } + C.na_free(s.dec) + s.dec = nil + return nil +} + +// eof is libav's AVERROR_EOF, computed once rather than spelled out, +// since the macro is a byte-order-dependent FourCC. +var eof = C.int(C.AVERROR_EOF) + +// averr renders a libav error code the way ffmpeg would report it. +func averr(code C.int) string { + buf := make([]byte, C.AV_ERROR_MAX_STRING_SIZE) + if C.av_strerror(code, (*C.char)(unsafe.Pointer(&buf[0])), C.size_t(len(buf))) < 0 { + return fmt.Sprintf("error %d", int(code)) + } + if i := bytesIndexZero(buf); i >= 0 { + buf = buf[:i] + } + return string(buf) +} + +func bytesIndexZero(b []byte) int { + for i, c := range b { + if c == 0 { + return i + } + } + return -1 +}