Posted in:

A while ago I wrote an article explaining how you can normalize audio using NAudio. Normalizing is a way of increasing the volume of an audio file by the largest possible amount without clipping.

However, I also mentioned in that article that in many cases, dynamic range compression is a better option. That's because in many audio files there are often a few stray peaks that are very loud which can make it impossible to bring up the overall gain without clipping. What we'd like to do is reduce the level of those peaks, giving us more headroom to increase the gain of the rest of the audio.

In this post, I'll show how we can implement a simple "limiter" which is essentially a compressor with a very steep compression ratio, that can be used on a variety of types of audio, including spoken word, to achieve a nice consistent volume level throughout.

An audio effects framework

NAudio has an interface called ISampleProvider which is ideal for our limiter effect, but I've created an Effect base class to make it simpler to implement effects.

The Effect class implements ISampleProvider, and uses the decorator pattern to take the source ISampleProvider in. It requires derived effect classes to implement a method called Sample which is called for every stereo or mono sample frame. There are also some optional methods you can implement, such as ParamsChanged which is called whenever the values of the effect parameters are modified to allow recalculation of any constants, and Block which is called before each block of samples is processed, for efficiency.

The design of this is inspired by the JSFX effects framework that is part of the REAPER DAW. It's essentially a DSL for implementing effects, which I've found very useful for implementing my own custom effects such as this trance gate. To make it easier to port a JSFX effect to C#, I've added some additional static helper methods to match the method names used by JSFX.

abstract class Effect : ISampleProvider
{
    private ISampleProvider source;
    private bool paramsChanged;
    public float SampleRate { get; set; }

    public Effect(ISampleProvider source)
    {
        this.source = source;
        SampleRate = source.WaveFormat.SampleRate;
    }

    protected void RegisterParameters(params EffectParameter[] parameters)
    {
        paramsChanged = true;
        foreach(var param in parameters)
        {
            param.ValueChanged += (s, a) => paramsChanged = true;
        }
    }

    protected abstract void ParamsChanged();

    public int Read(float[] samples, int offset, int count)
    {
        if (paramsChanged)
        {
            ParamsChanged();
            paramsChanged = false;
        }
        var samplesAvailable = source.Read(samples, offset, count);
        Block(samplesAvailable);
        if (WaveFormat.Channels == 1)
        {
            for (int n = 0; n < samplesAvailable; n++)
            {
                float right = 0.0f;
                Sample(ref samples[n], ref right);
            }
        }
        else if (WaveFormat.Channels == 2)
        {
            for (int n = 0; n < samplesAvailable; n+=2)
            {
                Sample(ref samples[n], ref samples[n+1]);
            }
        }
        return samplesAvailable;
    }

    public WaveFormat WaveFormat { get { return source.WaveFormat; } }
    public abstract string Name { get; }
    // helper base methods these are primarily to enable derived classes to use a similar
    // syntax to REAPER's JS effects
    protected const float log2db = 8.6858896380650365530225783783321f; // 20 / ln(10)
    protected const float db2log = 0.11512925464970228420089957273422f; // ln(10) / 20 
    protected static float min(float a, float b) { return Math.Min(a, b); }
    protected static float max(float a, float b) { return Math.Max(a, b); }
    protected static float abs(float a) { return Math.Abs(a); }
    protected static float exp(float a) { return (float)Math.Exp(a); }
    protected static float sqrt(float a) { return (float)Math.Sqrt(a); }
    protected static float sin(float a) { return (float)Math.Sin(a); }
    protected static float tan(float a) { return (float)Math.Tan(a); }
    protected static float cos(float a) { return (float)Math.Cos(a); }
    protected static float pow(float a, float b) { return (float)Math.Pow(a, b); }
    protected static float sign(float a) { return Math.Sign(a); }
    protected static float log(float a) { return (float)Math.Log(a); }
    protected static float PI { get { return (float)Math.PI; } }

    /// <summary>
    /// called before each block is processed
    /// </summary>
    /// <param name="samplesblock">number of samples in this block</param>
    public virtual void Block(int samplesblock)
    {
    }

    /// <summary>
    /// called for each sample
    /// </summary>
    protected abstract void Sample(ref float spl0, ref float spl1);

    public override string ToString()
    {
        return Name;
    }
}

You'll also notice that there is support for the concept of an EffectParameter. This is just a way of allowing users to adjust a parameter between a minimum and maximum and making sure that the effect is notified of any parameter changes.

class EffectParameter
{
    public float Min {get;}
    public float Max {get;}
    public string Description {get;}
    private float currentValue;
    public event EventHandler ValueChanged;
    public float CurrentValue 
    {
        get { return currentValue;}
        set 
        {
            if (value < Min || value > Max)
                throw new ArgumentOutOfRangeException(nameof(CurrentValue));
            if (currentValue != value)
                ValueChanged?.Invoke(this, EventArgs.Empty);
            currentValue = value;
        }
    }

    public EffectParameter(float defaultValue, float minimum, float maximum, string description)
    {
        Min = minimum;
        Max = maximum;
        Description = description;
        CurrentValue = defaultValue;
    }
}

A simple limiter

For this example, the limiter I've chosen to port is one shipped with REAPER and created by Schwa, who's the author of a whole host of super useful effects. This is nice and simple and the only modification I made was to allow larger gain boost values and set the brickwall default to -0.1dB.

class SoftLimiter : Effect
{
    public override string Name => "Soft Clipper/ Limiter";

    public EffectParameter Boost { get; } = new EffectParameter(0f, 0f, 18f, "Boost");
    public EffectParameter Brickwall { get; } = new EffectParameter(-0.1f, -3.0f, 1f, "Output Brickwall(dB)");
    public SoftLimiter(ISampleProvider source):base(source)
    {
        RegisterParameters(Boost, Brickwall);
    }

    private float amp_dB = 8.6562f;
    private float baseline_threshold_dB = -9f;
    private float a = 1.017f;
    private float b = -0.025f;
    private float boost_dB;
    private float limit_dB;
    private float threshold_dB;

    protected override void ParamsChanged()
    {
        boost_dB = Boost.CurrentValue;
        limit_dB = Brickwall.CurrentValue;
        threshold_dB = baseline_threshold_dB + limit_dB;
    }

    protected override void Sample(ref float spl0, ref float spl1)
    {
        var dB0 = amp_dB * log(abs(spl0)) + boost_dB;
        var dB1 = amp_dB * log(abs(spl1)) + boost_dB;

        if (dB0 > threshold_dB)
        {
            var over_dB = dB0 - threshold_dB;
            over_dB = a * over_dB + b * over_dB * over_dB;
            dB0 = min(threshold_dB + over_dB, limit_dB);
        }

        if (dB1 > threshold_dB)
        {
            var over_dB = dB1 - threshold_dB;
            over_dB = a * over_dB + b * over_dB * over_dB;
            dB1 = min(threshold_dB + over_dB, limit_dB);
        }

        spl0 = exp(dB0 / amp_dB) * sign(spl0);
        spl1 = exp(dB1 / amp_dB) * sign(spl1);
    }
}

Using the limiter

Using the limiter couldn't be easier. In this example we use an AudioFileReader to read the input file (this supports multiple file types including WAV, MP3 etc). Next we create an instance of SoftLimiter and set the Boost parameter to the amount of boost we want. Here I'm asking for 12dB of gain. Essentially this means that any audio below -12dB will be amplified without clipping, and the soft clipping will be applied to any audio above -12dB.

Finally we use WaveFileWriter.CreateWaveFile16 to write the limited audio into a 16 bit WAV file. Obviously you can use other NAudio supported output file formats if you want, such as using the MediaFoundationEncoder for MP3.

var inPath = @"C:\Users\mheath\Documents\my-input-file.wav";
var outPath =  @"C:\Users\mheath\Documents\my-output-file.wav";

using (var reader = new AudioFileReader(inPath))
{
    var limiter = new SoftLimiter(reader);
    limiter.Boost.CurrentValue = 12;
    
    WaveFileWriter.CreateWaveFile16(outPath, limiter);
}

Summary

With a basic effects framework in place, its not too hard to port an existing limiter algorithm from another language into C# and use it with NAudio. If you'd like to see more examples of effects ported to NAudio, take a look at these from an early version of my Skype Voice Changer application, where I took a bunch of JSFX effects and ported them to C#.

Want to get up to speed with the the fundamentals principles of digital audio and how to got about writing audio applications with NAudio? Be sure to check out my Pluralsight courses, Digital Audio Fundamentals, and Audio Programming with NAudio.

Comments

Comment by PAUL J MCMILLAN

Hi, Mark, I just want to thank you for this wonderful article. I implemented my code based on yours and it worked the first time!
Now, all I need to do is find a way to eliminate background noise. I've searched for articles on the web to no avail. Do you have any suggestions?

PAUL J MCMILLAN
Comment by Mark Heath

The simplest approach is to use a noise gate. NAudio doesn't come with one though, so you'd need to port an existing noise gate algorithm to C# to use it.

Mark Heath
Comment by PAUL J MCMILLAN

Hi mark, are you available for hire for a task/project? If so please contact me at+17705580005 and ask for paul.

PAUL J MCMILLAN
Comment by 753

The math here is a little beyond me; would it be alright to use this code in an open-source soundboard I'm working on?

753
Comment by Mark Heath

I don't think the original effect came with a license - I presume it's open source friendly, but can't be sure, it ships with REAPER, so you could ask on the forums there.

Mark Heath
Comment by Reece Ketley

Hi Mark, I've been trying to impliment this code with ASIO to do realtime softclipping as i have a soundsystem and i'd like to build a compact unit to do softclipping rather than running a fully fledged PC and using something like FreeClip. The premise of my idea is i could use netcore and a small form factor pc such as a Raspberry Pi4. I'd run freeclip on that but its x86 based and i really only need a simple method to take the ASIO in apply softclipping and then pipe it back out on the same ASIO device.
Could you possibly point me in the right direction with this if it's even possible? I've tried to use a buffered wave provider to get a SampleSource then apply the effect then copy the result to the output buffer to no result.
Any help would be greatly appreciated. (sorry for the long post)

Reece Ketley