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.
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_coderisAAC_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 whichtwoloopis the default andanmris 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_fixedat the sample level. The publicpcm.NewDecoderstreams 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, 44.1 and 48 kHz. Not yet covered: HE-AAC (SBR/PS), 960-sample frames, and channel configs above stereo.
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).
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.
go get github.com/tphakala/go-aacThe library has two layers, mirroring go-flac
(flac + pcm) and go-opus
(opus + oggopus).
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_timeDelay() 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 { // classify with errors.Is against aacpcm.ErrCorruptStream or aacpcm.ErrUnsupported
return err
}
info := d.Info() // SampleRate, Channels, Profile, valid immediately
_, err = io.Copy(w, d) // WriteTo drains the whole decode; Read fills any bufferThe decoded PCM is byte-identical to ffmpeg -c:a aac_fixed -f s16le on every
LC stream tested, including Apple afconvert output. The decoder never panics on
malformed input (it returns wrapped ErrCorruptStream or ErrUnsupported
sentinels) and runs at zero allocations per frame in steady state. Raw access
units plus an AudioSpecificConfig are opt in via aacpcm.WithRawStream(asc).
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) samplesRaw 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.
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.
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.
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 WAVResults on a 120 s 48 kHz mono recording at 128 kbps, single-threaded. The ratio is CPU seconds, go-aac over FFmpeg; the FFmpeg CLI spawns helper threads, so CPU time compares more honestly than wall time.
| Coder | Platform | go-aac | FFmpeg | go/C |
|---|---|---|---|---|
| NMR (default) | Raspberry Pi 5 | 19x realtime | 40x | 2.09x |
| NMR (default) | x86_64 (i7-1260P) | 43x | 98x | 2.09x |
| NMR (default) | Apple M4 Pro | 68x | 179x | 2.64x |
| twoloop | x86_64 | 78x | 156x | 1.74x |
| fast | x86_64 | 143x | 308x | 1.87x |
go-aac runs at roughly twice the CPU time of the C and in about a third of the memory (4.1 MB peak RSS against 10.4 to 13.3 MB). Stream sizes match FFmpeg to within 0.001% for the NMR and fast coders at the same bitrate.
The gap is not FFmpeg's hand-written assembly: disabling it (-cpuflags 0)
changes AAC encoding by about 1%. It is compiler auto-vectorization. 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. Closing the gap therefore means hand-writing in Go what
C compilers generate for free. The optional SIMD kernels below take the first
steps; 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.
Building with -tags goaac_simd swaps two of the encoder's hottest kernels for
SIMD implementations built on
github.com/tphakala/simd: the NMR Viterbi
trellis search and the AbsPow34 magnitude transform (|x|^(3/4)). Both use NEON
on arm64. On x86_64 the trellis needs AVX2 and falls back to portable Go without
it, while AbsPow34 needs only AVX and has an SSE path below that. Every backend
is bit-identical, so the tagged build produces byte-identical output to the
scalar default and passes the same differential oracle gate, not a relaxed PSNR
tier.
Measured full-encode NMR speedups over the scalar default (128 kbps, single
recording, benchstat over interleaved rounds). Every percentage here is a
reduction in encode time, so 15% faster means the tagged build 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 Pi 5 row comes from a three-way interleaved run in one session on an
otherwise idle machine: the scalar default, a trellis-only build (this tree with
AbsPow34 held at its scalar kernel) and the full tagged 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 and have not been re-measured
since the scalar-path optimizations landed (#57). Both kernels are
byte-identical to the scalar port, so the tag is a pure speed knob with no
effect on output.
The default build stays pure Go, with no assembly in the binary and the simd
dependency linked only under the tag. The tag is opt-in and the scalar kernel
remains canonical.
The two builds differ only in speed, and because the tag is opt-in a downstream
release can end up on the scalar path 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; build with -tags goaac_simd for the SIMD ones")
}The answer describes what was compiled in, not what the CPU running the binary supports.
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.
If go-aac is useful to you, consider sponsoring its development.