Recursive AI

Read and write WAV files in C#

Read WAV files in C# as floats or exact integers and write 16-bit, 24-bit and float WAV with optional dither, including RF64 for files over 4 GB, on any OS.

To read a WAV file in C#, add the RecursiveAI.OneAudio package and call Audio.Open("in.wav"): Read gives you float samples, ReadInteger the exact integers stored in the file, and Format says how they are stored. To write one, create a WavEncoder over a stream, call Write with float samples as often as you like, and Finish. Both handle 8-, 16-, 24- and 32-bit PCM, 32- and 64-bit float, WAVE_FORMAT_EXTENSIBLE and RF64, in managed code on any OS. Every sample below was compiled and run against OneAudio 1.2.1 on .NET 10.

Before you start

TerminalNuGet
dotnet new console -o WavFiles
cd WavFiles
dotnet add package RecursiveAI.OneAudio --version 1.2.1

OneAudio targets .NET 10 and is licensed MIT OR Apache-2.0. The samples use music-24bit-48k.wav, 30 seconds of 24-bit stereo at 48 kHz.

Read a WAV file

Program.csOneAudio 1.2.1 · .NET 10
using OneAudio;
 
// Open a WAV file: the format says how the samples are stored.
using IAudioDecoder wav = Audio.Open("music-24bit-48k.wav");
AudioFormat f = wav.Format;
Console.WriteLine($"{f.SampleRate} Hz, {f.Channels} ch, {f.NativeType}, {f.BitsPerSample} bits, {wav.LengthFrames} frames");
 
// As floats (full scale is -1 to +1) ...
wav.Seek(48000);                                   // 1 second in
float[] floats = new float[1024 * f.Channels];
int frames = wav.Read(floats);
Console.WriteLine($"frame 48000 as float: {floats[0]:F6}, {floats[1]:F6}");
 
// ... or as the exact integers in the file, for lossless PCM.
wav.Seek(48000);
int[] ints = new int[1024 * f.Channels];
frames = wav.ReadInteger(ints);
Console.WriteLine($"frame 48000 as 24-bit integers: {ints[0]}, {ints[1]}");
 
// The whole file in memory, as one array.
AudioClip clip = Audio.Load("music-24bit-48k.wav");
Console.WriteLine($"loaded {clip.Samples.Length} samples ({clip.Frames} frames)");
OutputConsole
48000 Hz, 2 ch, PcmSigned24, 24 bits, 1440000 frames
frame 48000 as float: 0.066345, 0.102448
frame 48000 as 24-bit integers: 556544, 859392
loaded 2880000 samples (1440000 frames)
  • Format holds the sample rate, channel count, channel layout (ChannelMask, from WAVE_FORMAT_EXTENSIBLE when present), NativeType and BitsPerSample. You don’t parse the header yourself, and you don’t need to know where the data chunk starts.
  • Samples are interleaved: frame i of channel c is at [i * channels + c]. Read and ReadInteger return the number of frames, not samples.
  • Read scales to ±1 full scale whatever the depth: 556,544 / 223 is 0.066345. ReadInteger returns the stored values; 8-bit files, which are unsigned on disk, come back re-centered to −128…127.
  • Audio.Open streams, so a file of any length reads in constant memory, and Seek is exact. Audio.Load reads it all into one array.
  • The format is detected from the bytes, so the same code reads FLAC, MP3, Ogg Vorbis and Opus. Exact integers exist only for lossless PCM, from WAV and FLAC files; HasNativeIntegers tells you whether a decoder has them.

Write a WAV file

Program.csOneAudio 1.2.1 · .NET 10
using OneAudio;
 
// Samples your code produces: one second of a 440 Hz tone, 48 kHz stereo.
float[] tone = new float[48000 * 2];
for (int i = 0; i < 48000; i++)
    tone[2 * i] = tone[2 * i + 1] = 0.25f * MathF.Sin(2 * MathF.PI * 440 * i / 48000f);
 
var format = new AudioFormat(48000, 2);
Write("tone-16.wav", new WavEncoderSettings());                                             // 16-bit PCM (default)
Write("tone-16-dither.wav", new WavEncoderSettings { Dither = true });                      // 16-bit with TPDF dither
Write("tone-24.wav", new WavEncoderSettings { SampleType = NativeSampleType.PcmSigned24 }); // 24-bit PCM
Write("tone-float.wav", new WavEncoderSettings { SampleType = NativeSampleType.IeeeFloat32 }); // 32-bit float
 
void Write(string path, WavEncoderSettings settings)
{
    using (FileStream file = File.Create(path))
    using (var wav = new WavEncoder(file, format, settings))
    {
        wav.Write(tone);   // call as often as you like
        wav.Finish();      // patches the sizes in the header
    }
    using IAudioDecoder check = Audio.Open(path);
    Console.WriteLine($"{path}: {new FileInfo(path).Length:N0} bytes, {check.Format.NativeType}, {check.LengthFrames} frames");
}
OutputConsole
tone-16.wav: 192,080 bytes, PcmSigned16, 48000 frames
tone-16-dither.wav: 192,080 bytes, PcmSigned16, 48000 frames
tone-24.wav: 288,104 bytes, PcmSigned24, 48000 frames
tone-float.wav: 384,116 bytes, IeeeFloat32, 48000 frames
  • Float input is scaled, rounded to nearest and clipped to the integer type; NaN becomes 0. Dither = true adds one LSB of triangular noise from a seeded generator first, which you want when reducing a mix to 16 bits. The same input and settings always give the same bytes.
  • Files with more than two channels or more than 16 bits are written as WAVE_FORMAT_EXTENSIBLE with the channel mask, as the format expects; that is why the 24-bit header is 24 bytes longer. Float files carry a fact chunk.
  • WriteInteger writes exact integers, and throws if a value doesn’t fit the stored depth.
  • To convert a file rather than write samples, Audio.Transcode("in.flac", "out.wav") does it in one call, with TranscodeOptions for the sample type, rate and channels.

Files over 4 GB and streams that can’t seek

A RIFF WAV file stores its sizes in 32 bits, so it can’t describe more than 4 GB of audio, about 4.1 hours of 24-bit stereo at 48 kHz. WavEncoder reserves room in the header, and when it finishes a seekable file that has grown past that limit it rewrites the header as RF64 (EBU Tech 3306); ForceRf64 = true writes RF64 from the start. The reader handles both.

On a stream that can’t seek, such as an HTTP response or a pipe, the encoder can’t go back to patch sizes. Pass TotalFrames when you know the length; otherwise the sizes are written as “unknown”, which OneAudio’s reader takes to mean “to the end of the stream”.

WAV files with NAudio

NAudio’s cross-platform NAudio.Core package has WAV and AIFF readers and writers, per its package README: WaveFileReader reads a file in its own format, AudioFileReader reads WAV as floats on every OS, and WaveFileWriter.CreateWaveFile16 writes a provider to a 16-bit file, as in its mixing guide:

Program.csNAudio 3.3.0 · Linux
using NAudio.Wave;
 
// NAudio: WaveFileReader for the file's own format, AudioFileReader for floats, WaveFileWriter to write.
using (var reader = new WaveFileReader("music-24bit-48k.wav"))
    Console.WriteLine($"{reader.WaveFormat}, {reader.SampleCount} frames");
 
using (var floats = new AudioFileReader("music-24bit-48k.wav"))
    WaveFileWriter.CreateWaveFile16("music-16bit-48k.wav", floats);
 
using var check = new WaveFileReader("music-16bit-48k.wav");
Console.WriteLine($"{check.WaveFormat}, {check.SampleCount} frames");
OutputConsole
24 bit PCM: 48000Hz 2 channels, 1440000 frames
16 bit PCM: 48000Hz 2 channels, 1440000 frames

If WAV is all you read and NAudio already plays your audio, that works. OneAudio gives you the exact integers next to the floats, writes 24-bit and float files with optional dither, switches to RF64 for long recordings by itself, and reads compressed formats through the same call on every OS. In the project’s published benchmarks, decoding 60 seconds of 16-bit stereo at 48 kHz took 3.48 ms and allocated 8 KB, and writing 60 seconds as 16-bit PCM took 2.8 ms (BenchmarkDotNet 0.15.8, AMD Ryzen 9 7950X3D, Linux, .NET 10.0.13).

Where next