Skip to content

Latest commit

 

History

59 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

go-aac

CI Go Reference codecov Go Version Latest tag OpenSSF Scorecard License: LGPL-2.1-or-later Sponsor

Pure-Go AAC-LC encoder and decoder, ported from FFmpeg's native AAC encoder and fixed-point decoder. No cgo and no external libraries in the published module.

It is the AAC member of a family of pure-Go audio libraries that also covers WAV, FLAC, Opus and M4A, and it presents the same API shape as its siblings, so a program that already speaks one of them speaks this one too.

Status

Every layer is validated against the C reference before it lands (see Approach), so the pieces marked done are done in the strong sense.

  • Encoder: complete for AAC-LC. All three FFmpeg coders (NMR, twoloop, fast), all four coding tools (TNS, PNS, M/S, I/S), mono and stereo, 44.1 and 48 kHz, ADTS output. The NMR coder is the default, as it is upstream at the pin (aac_coder is AAC_CODER_NMR, and the commit that made it so says the old coders will soon be removed). FFmpeg's own released docs still describe the older set, in which twoloop is the default and anmr is a different, experimental coder; that text is stale, not a contradiction.
  • Decoder: usable for AAC-LC. Pure fixed point, producing output identical to ffmpeg -c:a aac_fixed at the sample level. The public pcm.NewDecoder streams an ADTS (or raw plus ASC) AAC-LC stream to interleaved little-endian S16 PCM, matching the oracle byte for byte across the test corpus and on Apple afconvert output, with no cgo. Mono and stereo across the AAC-LC sample-rate range: the parser accepts the full MPEG-4 sample-rate table (7.35 to 96 kHz), and decode is byte-verified against the oracle at representative rates from 8 to 96 kHz. The encoder, separately, is restricted to 44.1 and 48 kHz. HE-AAC (SBR/PS) is rejected with a precise ErrUnsupportedSBR / ErrUnsupportedPS so a caller can hand it to an external decoder; 960-sample frames and channel configs above stereo are also not covered.

Quality tracks the C encoder closely. At 96/128/192 kbps stereo with the NMR coder on both sides, decoded PSNR is within +-0.04 dB of FFmpeg's own output and stream sizes within 0.22%.

On real field recordings the port slightly exceeds the C encoder at the same bitrate:

Recording (48 kHz mono, 128 kbps) go-aac FFmpeg (same coder)
120 s dawn chorus 85.44 dB 85.42 dB
15 s distant owl call 63.90 dB 63.87 dB

Not implemented: HE-AAC (SBR/PS), xHE-AAC, LATM, ER/LD/ELD profiles, multichannel beyond stereo, VBR (global_quality), MP4 muxing (the pure-Go go-m4a is the container companion).

Approach

go-aac is a faithful port of FFmpeg's AAC encoder and fixed-point decoder at a pinned commit (d09d5afc3a), kept honest by differential testing against the real C.

For each subsystem, a C harness links the pinned FFmpeg libraries, runs the actual FFmpeg function on identical input, and dumps its internals; the Go port must then reproduce them. That is a far sharper instrument than PSNR:

Harness What it pins Result
tools/cdump MDCT, KBD windows, LPC 1.17e-07 relative / bit-exact / 0
tools/gentables 31 codec tables byte-identical
tools/cquant quantizer search, codebook trellis, band encoding 128/128 band decisions, byte-identical bitstreams
tools/cpsy the 3GPP psychoacoustic model window decisions identical, bit reservoir exact
tools/cnmr the NMR Viterbi trellis and rate control bit-exact, tie-breaking included
tools/ctns, tools/ctwoloop TNS and the twoloop coder bit-exact
decoder gates LC symbol decode, int32 IMDCT, full reconstruction, s16 PCM 1,999,224 symbols + 12,969,984 reconstructed values byte-identical; s16 PCM identical to ffmpeg -c:a aac_fixed

PSNR cannot tell you that a psychoacoustic constant was misported, that a bit reservoir is drifting, or that a Viterbi path was suboptimal. These harnesses can, and they caught real bugs that would otherwise have shipped silently.

The internal packages are deliberately written in a C-shaped style, so they stay diffable against upstream FFmpeg, and every ported function carries a provenance comment naming its C origin. That constraint is temporary: with the AAC-LC port complete and the differential gates green, the idiomatic-Go rewrite happens alongside the optimization work. The public API is idiomatic Go today.

Install

go get github.com/tphakala/go-aac

Usage

The library has two layers, mirroring go-flac (flac + pcm) and go-opus (opus + oggopus).

pcm: the streaming layer

Interleaved little-endian integer PCM in, a self-framing ADTS stream out via io.Writer, or raw access units out through a callback for muxing. This is the right entry point for almost all callers.

import aacpcm "github.com/tphakala/go-aac/pcm"

cfg := aacpcm.Config{SampleRate: 48000, BitDepth: 16, Channels: 1, Bitrate: 96000}

// One shot (encoder drawn from a pool, safe for concurrent use):
err := aacpcm.EncodeInterleaved(w, cfg, pcmBytes)

// Or streaming, accepting any chunk size:
e, err := aacpcm.NewEncoder(w, cfg)
_, err = io.Copy(e, src)
err = e.Close()

Write accepts arbitrary chunk sizes and buffers partial samples internally, so io.Copy works with any buffer, including sizes that do not divide the sample stride.

The package name deliberately collides with go-flac/pcm; import it with an alias (aacpcm), which is ordinary Go practice and lets a consumer switch between the two encoders with the same call shape.

Muxing into MP4 or fragmented MP4 (CMAF) needs the opposite of ADTS: raw access units, boundaries reported out of band. FrameEncoder is that path, and it is the same pipeline, so the units are byte-identical to the ADTS stream's payloads.

fe, err := aacpcm.NewFrameEncoder(cfg)
asc := fe.AudioSpecificConfig() // esds DecoderSpecificInfo, valid before any audio
_ = asc                         // goes in the init segment's esds box
emit := func(au []byte, samples int) error {
    segment = append(segment, au...) // au is borrowed; copy or append
    return nil
}
err = fe.EncodeInterleaved(pcm, emit)
err = fe.Flush(emit) // drains the priming frame; fe.Delay() gives the elst media_time

Delay() and the per-unit samples count are both PCM samples per channel, so a muxer whose track timescale is not the sample rate scales them into media-timescale ticks first.

The emit callback has the same shape as go-flac's pcm.FrameEncoder, so a muxer's per-unit path is shared between the two codecs; the lifecycle differs, since AAC-LC needs a Flush to drain the priming frame where the FLAC frame encoder is one-shot.

Decoding mirrors go-flac's pcm.Decoder: an AAC-LC stream in via io.Reader, interleaved little-endian S16 PCM out.

d, err := aacpcm.NewDecoder(r) // ADTS by default, resynced past leading garbage
if err != nil {                // errors.Is: aacpcm.ErrCorruptStream, aacpcm.ErrUnsupported, or the precise ErrUnsupportedSBR / ErrUnsupportedPS
    return err
}
info := d.Info()       // SampleRate, Channels, Profile, valid immediately
_, err = io.Copy(w, d) // WriteTo drains the whole decode; Read fills any buffer

The decoded PCM is byte-identical to ffmpeg -c:a aac_fixed -f s16le on every LC stream tested, including Apple afconvert output, from 8 kHz up to 96 kHz. The decoder never panics on malformed input (it returns wrapped ErrCorruptStream or ErrUnsupported sentinels, with ErrUnsupportedSBR / ErrUnsupportedPS naming HE-AAC and HE-AACv2 specifically so a caller can hand those off to an external decoder) and runs at zero allocations per frame in steady state. Raw access units plus an AudioSpecificConfig are opt in via aacpcm.WithRawStream(asc).

aac: the low-level codec

Planar float32 frames in, raw AAC access units out, append-style and allocation-free in steady state.

import "github.com/tphakala/go-aac"

e, err := aac.NewEncoder(aac.EncoderConfig{SampleRate: 48000, Channels: 1, Bitrate: 128000})
au, err := e.EncodeFrame(au[:0], [][]float32{frame}) // up to aac.FrameSize (1024) samples

Raw access units are not self-framing. Use aac.AppendADTSHeader to build a streamable ADTS stream, or Encoder.AudioSpecificConfig to mux them elsewhere. Most muxing callers want pcm.FrameEncoder instead, which does the conversion, framing and priming for them.

Gapless playback

ADTS cannot signal encoder delay. Decoders emit roughly 1024 extra leading samples, and every AAC-in-ADTS stream behaves this way. Compute clip durations from the source PCM, not from the decoded AAC length.

For gapless, sample-accurate output, mux into a container that carries an edit list, feeding the muxer from pcm.FrameEncoder. go-m4a is the pure-Go MP4/M4A muxer and demuxer that pairs with go-aac for exactly this: it writes the encoder priming (aac.EncoderDelay, also pcm.FrameEncoder.Delay) into an elst edit list so playback is gapless, and reads .m4a files back into access units. Its aacm4a subpackage is a one-call bridge over go-aac, PCM to .m4a and back. No cgo and no external binaries.

Benchmarking

scripts/bench-encoders.sh compares go-aac against FFmpeg's native AAC encoder, the C this library is ported from, on the same input (encode single-threaded, one process, file in and file out), reporting wall time, CPU seconds, peak RSS and stream size. GOAAC_FFMPEG must point at the pinned oracle build; a distro FFmpeg is refused, because 7.x and earlier ship a different coder set whose anmr is not the nmr trellis this library ports. PROVENANCE.md carries the required configure recipe, including the -ffp-contract=off that is part of the pin.

GOAAC_FFMPEG=/path/to/pinned/ffmpeg scripts/bench-encoders.sh          # generated reproducible input
GOAAC_FFMPEG=/path/to/pinned/ffmpeg scripts/bench-encoders.sh my.wav   # your own WAV

Results on a 120 s 48 kHz mono recording at 128 kbps, single-threaded, over a real broadband recording that keeps the NMR search fully loaded; a sparse synthetic tone understates it. The ratio is CPU seconds, go-aac over FFmpeg; the FFmpeg CLI spawns helper threads, so CPU time compares more honestly than wall time. The x86_64 figures are pinned to the performance cores for stability on that hybrid part; the Pi 5 is a single core cluster and needs no pinning.

Coder Platform go-aac FFmpeg go/C
NMR (default) Raspberry Pi 5 38x realtime 38x 1.02x
NMR (default) x86_64 (i7-1260P) 90x 99x 1.12x
twoloop Raspberry Pi 5 42x 69x 1.65x
twoloop x86_64 (i7-1260P) 105x 164x 1.57x
fast Raspberry Pi 5 90x 138x 1.53x
fast x86_64 (i7-1260P) 203x 308x 1.55x

On the default NMR coder go-aac is at parity with the C in CPU time on the Pi 5 (1.02x) and within about 12% of it on the i7-1260P (1.12x), in a third to a half of the memory (roughly 4 MB peak RSS on the Pi and 6 to 8 MB on x86_64, against about 12 to 15 MB). The twoloop and fast coders stay modestly behind, roughly 1.5x to 1.65x on both platforms. Stream sizes track FFmpeg closely at the same bitrate, within about 0.001% for NMR and on the order of 0.01% for the other coders. These numbers moved a long way from the first baseline, where the NMR coder cost about twice the C's CPU time; the default SIMD kernels and the scalar-path work since then roughly doubled its throughput.

That closing was compiler auto-vectorization, now hand-written in Go. GCC emits 631 packed floating-point arithmetic instructions in aaccoder.o from plain C, concentrated in the NMR quantizer search; Go's compiler emits none anywhere in the equivalent package. Disabling FFmpeg's hand-written assembly (-cpuflags 0) changes AAC encoding by only about 1%, so the gap was never the asm. The default SIMD kernels below reproduce that vectorization for the NMR trellis and quantizer, which is what brought the default coder level with the C; twoloop and fast are not targeted as heavily and keep more of the gap. The scalar port remains the canonical reference.

Steady-state encoding is allocation-free (0 allocs/frame) for every coder, mono and stereo. Decoding is far cheaper, roughly 3000x real time for mono and 1500x for stereo at 48 kHz, and is likewise allocation-free per frame in steady state.

Profiling

Profile on Linux. On darwin/arm64, pprof fabricates hot spots: it has parked 10% to 16% of encode on cheap bulk-memory leaves (encoding/binary.littleEndian.Uint16, runtime.memmove) that direct measurement puts at 0.2% and 0.04%, wrong by 47x and 260x, and the phantom moves between runs while landing on whatever streaming leaf is nearby. The same benchmarks on linux/amd64 and linux/arm64 are clean, and cumulative shares for large subsystems stay trustworthy on macOS; it is leaf attribution that is not. If you must profile on a Mac, reconcile every leaf number against call counts before acting on it: count the calls, microbenchmark the leaf at the shapes the real encode drives, multiply, and compare against encode wall time. A disagreement above about 2x means the profile is wrong, not the arithmetic. Run benchmark cases isolated and min-of-N when a number will feed a decision; a back-to-back batch produced a spurious 1.9x on one case here.

SIMD kernels (default, opt out with -tags noasm)

By default the encoder's hottest kernels are SIMD implementations built on github.com/tphakala/simd: the NMR Viterbi trellis search, the AbsPow34 magnitude transform (|x|^(3/4)), and the QuantizeBands quantizer. The simd library picks the widest path the CPU supports at runtime and falls back to pure Go on any CPU without it. All use NEON on arm64. On x86_64 the trellis needs AVX2 and falls back to portable Go without it, while AbsPow34 and QuantizeBands use AVX (AbsPow34 with an SSE path below that). Every backend is bit-identical, so the default build produces byte-identical output to the scalar path and passes the same differential oracle gate, not a relaxed PSNR tier.

Building with -tags noasm selects the pure-Go scalar kernels instead: no assembly in the binary and the simd dependency linked out entirely, for a smaller and more easily audited build. The scalar kernels stay canonical and are the reference the SIMD ones are gated against.

Measured full-encode NMR speedups of the SIMD default over the -tags noasm scalar build (128 kbps, single recording, benchstat over interleaved rounds). Every percentage here is a reduction in encode time, so 15% faster means the SIMD default spends 15% less time, not that it does 15% more work per second:

Platform SIMD trellis Both kernels
Raspberry Pi 5 (Cortex-A76, NEON) about 14% faster about 15% faster
x86_64 i7-1260P (AVX2) 22% faster about 24% faster

The trellis search is the larger lever. On top of it the AbsPow34 kernel adds roughly a further 2.7% on the i7-1260P (4.6% with the psychoacoustic tools disabled) and about 1.2% on the Pi 5 (1.3% with the tools disabled). Those increments are measured against the trellis-only build, not the scalar one, so they compound rather than add. The QuantizeBands quantizer is SIMD by default as well; its separate speedup is not broken out in the table above.

The Pi 5 row comes from a three-way interleaved run in one session on an otherwise idle machine: the -tags noasm scalar build, a trellis-only build (this tree with AbsPow34 held at its scalar kernel) and the full SIMD build, benchstat over 10 rounds each (n=10 per build, p=0.000). That gives 3.716 s, 3.210 s and 3.173 s per BenchmarkEncodeFrames pass over a 120 s recording, so 13.6% and 14.6% against the scalar. Those seconds time the codec alone in a warm loop, so they do not correspond to the realtime multiples in the bench-encoders.sh table above, which are whole-process wall clock over a different harness. All three kernels are byte-identical to the scalar port, so the choice is a pure speed knob with no effect on output.

The two builds differ only in speed, and because -tags noasm silently drops to the scalar path a downstream release can end up there unnoticed. aac.SIMDEnabled() reports which kernel set was compiled in, so a build or startup check can assert it:

if !aac.SIMDEnabled() {
    log.Println("go-aac: scalar kernels (built with -tags noasm); the default build has the SIMD ones")
}

The answer describes what was compiled in, not what the CPU running the binary supports.

Sponsor

go-aac is maintained in my own time. If it is useful to you or your project, you can support continued maintenance through GitHub Sponsors; sponsorship is entirely optional and never gates any feature.

Sponsor on GitHub

License

LGPL-2.1-or-later. go-aac is a derivative work of FFmpeg's LGPL-licensed AAC encoder and cannot be relicensed permissively. See LICENSE and PROVENANCE.md.

About

Pure-Go AAC-LC encoder and decoder, ported from FFmpeg. No cgo.

Topics

Resources

Security policy

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages