Recursive AI

Convert WAV and MP3 to M4A (AAC) in C#

All oneaudio recipes

Audio.Transcode("in.wav", "out.m4a") encodes a WAV file to AAC in an M4A file in one call, and an MP3, Ogg, FLAC or any other format oneaudio reads converts the same way. The encoder is AAC-LC, written in C#: no FFmpeg, Media Foundation or native library. Pass your own WAV to the program; without one, it first writes a three-second test WAV so that it runs as is. The MP3 is a short synthetic test file from the oneaudio repository.

Terminal
dotnet new console -o ToM4a && cd ToM4a
dotnet add package RecursiveAI.OneAudio --version 1.7.1
t=https://raw.githubusercontent.com/recursive-ai-oss/oneaudio-csharp/v1.7.1/testdata
curl -L -o song.mp3 $t/mp3/small_44100_2_joint.mp3

Put this in Program.cs and run dotnet run (or dotnet run -- yourfile.wav):

C#
using OneAudio;
using OneAudio.Aac;
 
string wav = args.Length > 0 ? args[0] : "test.wav";
if (!File.Exists(wav))
    WriteTestWav(wav);
 
// WAV to M4A. The default is constant bit rate, 64 kbit/s per channel.
Audio.Transcode(wav, "out.m4a");
 
// MP3 to M4A is the same call: the output format comes from the extension.
Audio.Transcode("song.mp3", "song.m4a");
 
// Choose the bit rate (bits per second for all channels together).
var at96k = new TranscodeOptions { Aac = new AacEncoderSettings { Bitrate = 96_000 } };
Audio.Transcode(wav, "out-96k.m4a", at96k);
 
// Average bit rate: block sizes follow the music, the file averages the target.
var abr96k = new TranscodeOptions
{
    Aac = new AacEncoderSettings { Bitrate = 96_000, RateControl = AacRateControl.Abr },
};
Audio.Transcode(wav, "abr-96k.m4a", abr96k);
 
// .aac writes raw AAC with ADTS headers instead of an M4A file.
Audio.Transcode(wav, "out.aac");
 
string[] files = [wav, "out.m4a", "out-96k.m4a", "abr-96k.m4a", "out.aac",
                  "song.mp3", "song.m4a"];
foreach (string f in files)
{
    using IAudioDecoder d = Audio.Open(f);
    double seconds = d.LengthFrames / (double)d.Format.SampleRate;
    Console.WriteLine($"{f,-12}{new FileInfo(f).Length,9:N0} bytes {seconds,7:F3} s " +
                      $"{d.Format.SampleRate} Hz {d.Format.Channels} ch");
}
 
// A test WAV: 3 s of a fading C major chord with a little noise, 44.1 kHz stereo.
static void WriteTestWav(string path)
{
    var random = new Random(1);
    float[] s = new float[44100 * 3 * 2];
    for (int i = 0; i < s.Length; i++)
    {
        double t = i / 2 / 44100.0;
        double chord = Math.Sin(2 * Math.PI * 261.63 * t)
                     + Math.Sin(2 * Math.PI * 329.63 * t)
                     + Math.Sin(2 * Math.PI * 392.00 * t);
        double noise = 0.01 * (random.NextDouble() - 0.5);
        s[i] = (float)(0.25 * chord * Math.Exp(-t) + noise);
    }
    var format = new AudioFormat(44100, 2);
    var pcm16 = new WavEncoderSettings { SampleType = NativeSampleType.PcmSigned16 };
    using var wav = new WavEncoder(File.Create(path), format, pcm16, leaveOpen: false);
    wav.Write(s);
}
Text
test.wav      529,280 bytes   3.000 s 44100 Hz 2 ch
out.m4a        49,928 bytes   3.000 s 44100 Hz 2 ch
out-96k.m4a    37,796 bytes   3.000 s 44100 Hz 2 ch
abr-96k.m4a    37,172 bytes   3.000 s 44100 Hz 2 ch
out.aac        49,427 bytes   3.042 s 44100 Hz 2 ch
song.mp3       10,448 bytes   0.600 s 44100 Hz 2 ch
song.m4a       10,074 bytes   0.600 s 44100 Hz 2 ch

The M4A files decode to exactly the length that went in, 3.000 and 0.600 seconds: the encoder’s 1,024 frames of priming and the padding at the end are recorded in the file’s edit list and in an iTunes iTunSMPB tag, which players use for gapless playback. A raw .aac file has nowhere to record them, so it decodes a little longer.

Check the file

FFmpeg 8.0.1’s ffprobe reads out.m4a as AAC, LC profile, 44,100 Hz, 2 channels, 3.000000 s, and ffmpeg -v warning -i out.m4a -f null - decodes it without a warning. Headless Chrome 149 played out.m4a, out-96k.m4a, abr-96k.m4a, song.m4a and out.aac in an <audio> element: readyState 4, currentTime advancing, and a duration of 3 s for the 3-second M4A files.

Terminal
ffprobe -v error -of compact \
  -show_entries stream=codec_name,profile,sample_rate,channels,duration out.m4a
Text
stream|codec_name=aac|profile=LC|sample_rate=44100|channels=2|duration=3.000000

The M4A files are fast start: moov, the box a player needs before it can play, comes ahead of the audio in mdat, so playback can begin while the file is still downloading.

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

The constant and average bit rate files at 96 kbit/s differ, 37,796 and 37,172 bytes, and ffprobe measures their audio at 95.7 and 94.0 kbit/s. The project’s changelog says an ABR file “still averages the target within 5 %”.

Terminal
for f in out-96k.m4a abr-96k.m4a; do
  ffprobe -v error -of compact=p=0 -show_entries format=filename,size:stream=bit_rate $f
done
Text
bit_rate=95674
filename=out-96k.m4a|size=37796
bit_rate=94033
filename=abr-96k.m4a|size=37172

The project’s README says that “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)”. Encode AAC measures both cases.

Settings

  • .m4a, .mp4 and .m4b all write an M4A file (AudioFileFormat.Aac); .aac writes ADTS (AudioFileFormat.AacAdts). A stream has no extension, so set TranscodeOptions.Format.
  • AacEncoderSettings.Bitrate is for the whole stream: the default, 64,000 per channel, is 128 kbit/s in stereo. RateControl picks Cbr (the default), Abr (block sizes follow the music, averaging the target) or Vbr, with VbrQuality from 0 to 1. More in Encode AAC in C#.
  • TranscodeOptions.SampleRate and Channels convert on the way, as for any other output.
  • TranscodeOptions.FastStart (default true) puts moov before the audio in .m4a and .mp4 output; set it false to keep it at the end.
  • If a conversion fails, the partial output file is deleted.

On quality, the project’s changelog says that, “scored with ViSQOL v3 (audio mode, MOS-LQO) on 10 SQAM excerpts (5 stereo, 5 mono) not used for tuning, it is on par with ffmpeg 8.0.1’s native aac at the same nominal rates”, and that “the only point behind is stereo 192 (by 0.007; worst file 0.015).” The scores are in the changelog.

Limits

  • AAC-LC only, mono or stereo, at 8,000 to 48,000 Hz. Anything else throws ArgumentException naming the option to set, for example: AAC-LC output takes one or two channels, not 6; set TranscodeOptions.Channels. A 96 kHz source needs SampleRate = 48000.
  • No HE-AAC (SBR or parametric stereo) output, and no TNS, intensity stereo or LTP.
  • MP3 to M4A decodes and re-encodes, so it’s lossy twice. Start from WAV or FLAC when you have it.
  • No title, artist or cover art is written.

To decode M4A files, see Decode AAC, HE-AAC and M4A. Other outputs: WAV to FLAC and WAV to Opus.