Loudness normalization sets a recording’s gain so that its measured loudness hits a target, in LUFS, without letting its true peak pass a ceiling. In C#, add the RecursiveAI.OneAudio package: Audio.Measure("in.wav") returns integrated loudness, loudness range and true peak per ITU-R BS.1770 and EBU R 128, and Audio.Normalize("in.wav", "out.wav") writes a copy at −14 LUFS with the true peak at or below −1 dBTP. Pass TargetLufs = -23 for the EBU R 128 broadcast target. OneAudio is pure C# with no native code, and its meter passes the EBU Tech 3341 and Tech 3342 test cases. Every sample was compiled and run against OneAudio 1.2.1 on .NET 10.
LUFS, true peak and loudness range
- Integrated loudness (LUFS) is the loudness of the whole programme, measured the way ITU-R BS.1770 describes: the signal is K-weighted (a filter that approximates how loud we hear different frequencies), averaged over 400 ms blocks, and gated, so silence and very quiet passages don’t pull the figure down. The gates are −70 LUFS absolute and 10 LU below the ungated level.
- True peak (dBTP) is the highest level of the reconstructed waveform, including peaks between samples, found by oversampling. It can be higher than the highest sample, and it is what clips after lossy encoding or in a D/A converter.
- Loudness range (LU), from EBU Tech 3342, describes how much the loudness varies over the programme. A compressed pop master has a small range, a film soundtrack a large one.
Two targets cover most work. EBU R 128 (version 5.0, November 2023) recommends normalizing programmes to −23.0 LUFS, within ±0.5 LU for most programmes, and says the true peak “shall not exceed −1 dBTP” during production. Spotify adjusts tracks to −14 dB LUFS according to ITU 1770 and advises masters below −1 dB true peak. OneAudio’s default of −14 LUFS and −1 dBTP matches that streaming practice, and every value is configurable.
Before you start
dotnet new console -o Loudness cd Loudness dotnet add package RecursiveAI.OneAudio --version 1.2.1
The samples use episode.wav, a 30-second 48 kHz stereo recording mixed quietly, and snare.wav, 20 seconds of quiet noise with sharp drum-like hits. Any WAV, MP3, Ogg Vorbis, Opus or FLAC file works.
Measure loudness
using OneAudio; using OneAudio.Loudness; // Integrated loudness (LUFS), loudness range (LU) and true peak (dBTP), per ITU-R BS.1770 / EBU R 128. LoudnessResult r = Audio.Measure("episode.wav"); Console.WriteLine($"integrated {r.IntegratedLufs:F1} LUFS"); Console.WriteLine($"range {r.LoudnessRange:F1} LU"); Console.WriteLine($"true peak {r.TruePeakDbtp:F1} dBTP"); Console.WriteLine($"max momentary {r.MaxMomentaryLufs:F1} LUFS, max short-term {r.MaxShortTermLufs:F1} LUFS");
integrated -24.3 LUFS range 2.8 LU true peak -15.1 dBTP max momentary -21.3 LUFS, max short-term -23.6 LUFS
As a cross-check, ffmpeg’s ebur128 filter with peak=true reports −24.3 LUFS integrated and a −15.1 dB true peak for the same file. Audio.Measure decodes the file as it reads it, so memory stays flat for long recordings. Pass truePeak: false when you only need the loudness, which makes the measurement much cheaper.
Normalize to −14 LUFS or −23 LUFS
using OneAudio; using OneAudio.Loudness; // Default target: -14 LUFS integrated, true peak at most -1 dBTP. NormalizeResult web = Audio.Normalize("episode.wav", "episode-14.wav"); Report("episode-14.wav", web); // Broadcast target from EBU R 128: -23 LUFS, true peak at most -1 dBTP. NormalizeResult broadcast = Audio.Normalize("episode.wav", "episode-23.wav", new NormalizeOptions { TargetLufs = -23, TruePeakCeilingDbtp = -1 }); Report("episode-23.wav", broadcast); // Sharp transients: reaching -14 LUFS would push the true peak over -1 dBTP, // so the gain stops at the ceiling and the file ends up quieter than the target. Report("snare-14.wav", Audio.Normalize("snare.wav", "snare-14.wav")); static void Report(string path, NormalizeResult n) { LoudnessResult after = Audio.Measure(path); Console.WriteLine($"{path}: {n.Before.IntegratedLufs:F1} LUFS, gain {n.GainDb:+0.0;-0.0} dB" + $"{(n.LimitedByPeak ? " (held back by the peak ceiling)" : "")} -> " + $"{after.IntegratedLufs:F1} LUFS, {after.TruePeakDbtp:F1} dBTP"); }
episode-14.wav: -24.3 LUFS, gain +10.3 dB -> -14.0 LUFS, -4.8 dBTP episode-23.wav: -24.3 LUFS, gain +1.3 dB -> -23.0 LUFS, -13.8 dBTP snare-14.wav: -20.9 LUFS, gain +3.0 dB (held back by the peak ceiling) -> -17.9 LUFS, -1.0 dBTP
- Normalization applies one gain to the whole file. It doesn’t compress or limit, so the dynamics and the loudness range stay as they were. Each output above measures at its target, to a tenth of a LU.
- The gain is cut back when the true peak would pass the ceiling.
snare.wavneeds +6.9 dB to reach −14 LUFS, but its hits would then peak near +3 dBTP, so it gets +3.0 dB, ends at −17.9 LUFS with a −1.0 dBTP peak, andLimitedByPeaktells you so. To get such material louder you need a limiter or compressor first, which is a mastering decision.TruePeakCeilingDbtp = nulldrops the ceiling, and then a boost may clip when the output is written as integers. Audio.Normalizemeasures, then decodes again to write, rather than holding the audio in memory. The output format comes from the extension (WAV, FLAC or Ogg Opus), andNormalizeOptions.Outputcan change the sample rate, channels or encoder settings in the same pass. The loudness is measured after that conversion.- Silence is left alone: there is no gain that brings it to a target.
- For audio already in memory,
Audio.Normalize(samples, format)scales aSpan<float>in place andAudio.Normalize(clip, out result)returns a new clip.
Measure while audio plays or streams
LoudnessMeter is the meter under Audio.Measure. Feed it interleaved floats in blocks of any size, and read momentary (400 ms), short-term (3 s) and integrated loudness at any time, for a loudness display, a live input or a mixer bus:
using OneAudio; using OneAudio.Loudness; // A meter you feed block by block: for live input, a mixer bus, or a progress display. using IAudioDecoder decoder = Audio.Open("episode.wav"); var meter = new LoudnessMeter(decoder.Format); // true peak and loudness range on int rate = decoder.Format.SampleRate, channels = decoder.Format.Channels; float[] block = new float[rate / 10 * channels]; // 100 ms per block long frames = 0; int read; while ((read = decoder.Read(block)) > 0) { meter.Process(block.AsSpan(0, read * channels)); frames += read; if (frames % (rate * 10) == 0) // every 10 seconds Console.WriteLine($"{frames / rate,3} s momentary {meter.MomentaryLufs:F1} " + $"short-term {meter.ShortTermLufs:F1} integrated {meter.IntegratedLufs:F1} LUFS"); } Console.WriteLine($"end integrated {meter.IntegratedLufs:F1} LUFS, range {meter.LoudnessRange:F1} LU, " + $"true peak {meter.TruePeakDbtp:F1} dBTP");
10 s momentary -23.9 short-term -23.8 integrated -24.0 LUFS 20 s momentary -27.4 short-term -25.9 integrated -24.6 LUFS 30 s momentary -30.0 short-term -24.3 integrated -24.3 LUFS end integrated -24.3 LUFS, range 2.8 LU, true peak -15.1 dBTP
- Everything is allocated in the constructor and
Processallocates nothing and takes no locks, so the meter can run on a real-time audio thread. - Results don’t depend on how the stream was split into blocks.
- Channels are weighted from the channel layout as BS.1770 specifies: 1.41 for surround channels, and the LFE channel left out of the loudness but not the true peak.
- The momentary reading drops at 30 s because the file fades out over its last half second.
- The OneAudio mixer can run the same meter on any bus. See measure loudness in the cookbook.
- To meter what actually reaches the speakers, the
RecursiveAI.OneAudio.Devicespackage runs a BS.1770 meter after its output limiter: setDeviceStreamOptions.Metering = trueand readoutput.Loudness. Playback itself is covered in play audio in C#.
Other options in .NET
A Stack Overflow question that comes up for this search, “Loudness Normalisation LUFS in C# with NAudio”, asks how to do it with NAudio. NAudio decodes and plays, but measuring LUFS needs a BS.1770 meter on top of it. R128Net 1.1.2 (MIT) is one: its README describes it as a C# port of libebur128 that measures momentary, short-term and integrated loudness, loudness range and sample and true peak. It takes samples rather than files, so reading the file and applying the gain are up to you. Following its README, with OneAudio decoding the file:
using OneAudio; using R128Net; // R128Net measures; it doesn't read files, so the samples come from a decoder. AudioClip clip = Audio.Load("episode.wav"); using LoudnessMeter meter = new(channels: clip.Format.Channels, sampleRate: clip.Format.SampleRate, LoudnessModes.Integrated | LoudnessModes.TruePeak); meter.AddFrames(clip.Samples.Span); double truePeak = Math.Max(meter.GetTruePeak(0), meter.GetTruePeak(1)); Console.WriteLine($"integrated {meter.IntegratedLoudness:F1} LUFS, true peak {20 * Math.Log10(truePeak):F1} dBTP");
integrated -24.3 LUFS, true peak -15.1 dBTPBoth meters agree on this file. If you only need a number and already decode audio elsewhere, a meter alone is enough. If you need decoding, measurement, gain and encoding in one step, Audio.Normalize does all four.
Where next
- Normalize loudness and measure loudness, the cookbook recipes.
- The API overview: the meter, channel weights, sample rates and the normalization helper.
- Resample audio in C# and choosing a C# audio library.