Recursive AI

Encode AAC in C#: samples to M4A or ADTS

All oneaudio recipes

AacEncoder turns float samples your code holds (from a synthesizer, a game, a microphone or a decoder) into AAC-LC packets, and Mp4AudioWriter puts them in an M4A file. With Adts = true each packet carries its own header instead, so the packets can go straight onto a socket or into a .aac file. The samples here come from Wikipedia’s example Ogg file (music by JesseW, CC BY-SA 3.0); to convert a file to M4A in one call, see Convert WAV and MP3 to M4A.

Terminal
dotnet new console -o EncodeAac && cd EncodeAac
dotnet add package RecursiveAI.OneAudio --version 1.7.1
curl -L -o song.ogg https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg

Put this in Program.cs and run dotnet run:

C#
using OneAudio;
using OneAudio.Aac;
using OneAudio.Mp4;
 
// Interleaved float samples, full scale ±1. Any source will do.
AudioClip clip = Audio.Load("song.ogg");
int rate = clip.Format.SampleRate, channels = clip.Format.Channels;
Console.WriteLine($"input: {clip.Frames} frames, {rate} Hz, {channels} ch");
 
// One M4A file per rate control mode, all at 96 kbit/s.
AacRateControl[] modes = [AacRateControl.Cbr, AacRateControl.Abr, AacRateControl.Vbr];
foreach (AacRateControl mode in modes)
{
    var settings = new AacEncoderSettings
    {
        Bitrate = 96_000,
        RateControl = mode,   // Vbr uses VbrQuality, 0.5 by default
    };
    var encoder = new AacEncoder(rate, channels, settings);
    string path = mode.ToString().ToLowerInvariant() + ".m4a";
    long audioBytes;
    using (FileStream file = File.Create(path))
    using (var mp4 = Mp4AudioWriter.CreateAac(file, rate, channels,
               encoder.AudioSpecificConfig, AacEncoder.PrimingFrames))
    {
        mp4.FastStart = true;   // moov before the audio, for progressive playback
        audioBytes = Encode(encoder, clip.Samples.Span,
                            p => mp4.AddSample(p.Data.Span, p.Duration));
        mp4.Finish(encoder.FramesWritten);   // the edit list trims priming and padding
    }
    Report(path, encoder, audioBytes);
}
 
// ADTS headers instead: each packet stands alone, for streaming.
var adts = new AacEncoderSettings { Bitrate = 96_000, Adts = true };
var adtsEncoder = new AacEncoder(rate, channels, adts);
using (FileStream stream = File.Create("cbr.aac"))   // or a socket, a pipe...
{
    long bytes = Encode(adtsEncoder, clip.Samples.Span, p => stream.Write(p.Data.Span));
    stream.Close();
    Report("cbr.aac", adtsEncoder, bytes);
}
 
// Write takes whole 1024-frame blocks and returns how many frames it took.
static long Encode(AacEncoder enc, ReadOnlySpan<float> pcm, Action<AacPacket> output)
{
    long bytes = 0;
    while (pcm.Length > 0)
    {
        int frames = enc.Write(pcm);
        pcm = pcm[(frames * enc.Channels)..];
        while (enc.TryReadPacket(out AacPacket packet))
        {
            bytes += packet.Data.Length;
            output(packet);
        }
    }
    enc.Finish();   // pads the last block and flushes
    while (enc.TryReadPacket(out AacPacket packet))
    {
        bytes += packet.Data.Length;
        output(packet);
    }
    return bytes;
}
 
// The audio's bit rate: packet bytes over the time the packets cover.
static void Report(string path, AacEncoder enc, long audioBytes)
{
    double seconds = enc.UnitsProduced * 1024.0 / enc.SampleRate;
    using IAudioDecoder d = Audio.Open(path);
    Console.WriteLine($"{path}: {enc.UnitsProduced} packets, " +
                      $"{new FileInfo(path).Length:N0} bytes, " +
                      $"{audioBytes * 8 / seconds / 1000:F1} kbit/s");
    Console.WriteLine($"  low-pass {enc.Bandwidth} Hz, " +
                      $"decodes to {d.LengthFrames} frames");
}
Text
input: 269188 frames, 44100 Hz, 2 ch
cbr.m4a: 264 packets, 75,338 bytes, 95.8 kbit/s
  low-pass 16500 Hz, decodes to 269188 frames
abr.m4a: 264 packets, 74,802 bytes, 95.1 kbit/s
  low-pass 16500 Hz, decodes to 269188 frames
vbr.m4a: 264 packets, 76,292 bytes, 97.0 kbit/s
  low-pass 16500 Hz, decodes to 269188 frames
cbr.aac: 264 packets, 75,236 bytes, 98.2 kbit/s
  low-pass 16500 Hz, decodes to 270336 frames
  • Write returns the frames it took. It fills one block of 1,024 frames at a time, and a packet comes out once the block after it is in, because the encoder looks one block ahead to choose its windows. Finish pads the last block with silence and flushes the rest.
  • A packet’s Data is valid until the next call on the encoder: copy it or write it out before encoding more. After construction, Write and TryReadPacket allocate nothing.
  • Decoded AAC starts with 1,024 frames of priming (PrimingFrames) and ends with EndPaddingFrames of padding. Mp4AudioWriter.Finish(FramesWritten) records both in an edit list and an iTunes iTunSMPB tag, so the M4A decodes to exactly the input’s length. ADTS has nowhere to record them, so cbr.aac decodes 1,024 frames longer plus the padding.
  • FastStart = true, set before Finish, puts the moov box in front of the audio so a player can start before the download ends. Finish then moves the audio forward inside the file, so the stream must be readable as well as seekable (File.Create is). It is off by default on Mp4AudioWriter, and on by default for Audio.Transcode (TranscodeOptions.FastStart).

Bit rate modes

  • Cbr (the default): every block is sized from Bitrate, with a bit reservoir for blocks that need more, so the stream averages the target. Suits streaming and fixed bandwidth.
  • Abr: block sizes follow how hard the music is to code, steered by a reservoir of about eight blocks, so they vary much more than with Cbr while the file still averages Bitrate.
  • Vbr: bits follow how hard each block is to code, scaled by VbrQuality (0 to 1, default 0.5; each 0.5 doubles the bits). Bitrate sets the bandwidth and the range, and the average depends on the music.
  • Bitrate is for all channels together; the default is 64,000 per channel. The low-pass (Bandwidth) follows the rate per channel unless you set it: 16.5 kHz at 96 kbit/s stereo, as above.

cbr.m4a and abr.m4a above are different files. FFmpeg 8.0.1’s ffprobe reads the same audio bit rates the program printed, 95.8 kbit/s for CBR and 95.1 for ABR, both within 1% of the 96 kbit/s target. The project’s changelog says only that an ABR file “still averages the target within 5 %”.

Terminal
for f in cbr.m4a abr.m4a vbr.m4a; do
  ffprobe -v error -of compact=p=0 -show_entries format=filename,size:stream=bit_rate $f
done
Text
bit_rate=95774
filename=cbr.m4a|size=75338
bit_rate=95074
filename=abr.m4a|size=74802
bit_rate=97019
filename=vbr.m4a|size=76292

The top-level boxes of each M4A, with moov ahead of the audio in mdat:

Terminal
ffprobe -v trace cbr.m4a 2>&1 | grep -o "type:'....' parent:'root'"
Text
type:'ftyp' parent:'root'
type:'moov' parent:'root'
type:'mdat' parent:'root'

Noise substitution and other decoders

NoiseSubstitution is on by default: bands above 4 kHz that would otherwise decode as silence are coded as noise of the right energy, and each decoder generates that noise itself. In the project’s README, “this library’s decode and ffmpeg’s agree to about 1e-7 outside noise-substituted bands, while those bands match in energy but not sample by sample (noise generation is decoder-specific; NoiseSubstitution = false makes every band deterministic)”. This program encodes the song both ways and compares oneaudio’s decode with FFmpeg 8.0.1’s, which it runs as ffmpeg -v error -i <file> -f f32le -:

C#
using System.Diagnostics;
using System.Runtime.InteropServices;
using OneAudio;
using OneAudio.Aac;
 
foreach (bool pns in new[] { true, false })
{
    string path = pns ? "pns-on.m4a" : "pns-off.m4a";
    var aac = new AacEncoderSettings { Bitrate = 96_000, NoiseSubstitution = pns };
    Audio.Transcode("song.ogg", path, new TranscodeOptions { Aac = aac });
 
    float[] ours = Audio.Load(path).Samples.ToArray();
    float[] theirs = FFmpegDecode(path);
    int n = Math.Min(ours.Length, theirs.Length);   // ffmpeg keeps the end padding
    double max = 0, signal = 0, error = 0;
    for (int i = 0; i < n; i++)
    {
        double d = ours[i] - theirs[i];
        max = Math.Max(max, Math.Abs(d));
        signal += (double)ours[i] * ours[i];
        error += d * d;
    }
    Console.WriteLine($"{path}: largest difference {max:E1}, " +
                      $"SNR {10 * Math.Log10(signal / error):F1} dB");
}
 
// Interleaved 32-bit floats from ffmpeg's own AAC decoder.
static float[] FFmpegDecode(string path)
{
    var start = new ProcessStartInfo("ffmpeg", $"-v error -i {path} -f f32le -")
    {
        RedirectStandardOutput = true,
    };
    using Process ffmpeg = Process.Start(start)!;
    using var pcm = new MemoryStream();
    ffmpeg.StandardOutput.BaseStream.CopyTo(pcm);
    ffmpeg.WaitForExit();
    return MemoryMarshal.Cast<byte, float>(pcm.ToArray()).ToArray();
}
Text
pns-on.m4a: largest difference 8.4E-004, SNR 41.2 dB
pns-off.m4a: largest difference 4.5E-008, SNR 136.0 dB

With noise substitution off, the two decodes differ by rounding only. With it on, the difference is the two decoders’ own noise in the substituted bands. FFmpeg’s decode runs 124 frames longer because it keeps the padding at the end, so the comparison covers the frames both have.

Check the files

FFmpeg 8.0.1’s ffprobe reads every file as AAC, LC profile, 44,100 Hz, 2 channels: the M4A files at 6.104036 s, the input’s length, and cbr.aac at about 6.12 s, estimated from the bit rate, since raw ADTS has no index. ffmpeg -v warning -i <file> -f null - decodes them all with no error; for cbr.aac it only notes that the duration is estimated. Headless Chrome 149 played cbr.m4a, abr.m4a, vbr.m4a and cbr.aac in an <audio> element, readyState 4 with currentTime advancing.

Limits

  • Mono or stereo, at 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100 or 48000 Hz. Other values throw ArgumentException from the constructor; resample first with Audio.Resample or LoadOptions.SampleRate.
  • AAC-LC only: no HE-AAC, TNS, intensity stereo or LTP.
  • Mp4AudioWriter needs a seekable stream (readable too for FastStart) and writes one audio track. For live output, use ADTS.
  • One encoder per stream, not thread-safe.

The whole API, including FlacPacketEncoder and FLAC in MP4, is in the API overview.