plugins_audioEffects.js

/**
 * LittleJS Audio Effects Plugin
 * - Web Audio effects with a wet/dry mix: filter, reverb, delay, distortion, compressor
 * - Route a sound through one with sound.output = effect
 * - Route everything with setAudioMasterEffect(effect)
 * - Chain effects with effect.connect(nextEffect)
 * @namespace AudioEffects
 */

'use strict';

///////////////////////////////////////////////////////////////////////////////

// ramp an audio param to a value, cancelling anything already scheduled so stacked calls don't fight
function audioParamRamp(param, value, fadeTime=0)
{
    ASSERT(fadeTime >= 0, 'fadeTime must be positive or zero');
    const startTime = audioContext.currentTime;
    param.cancelScheduledValues(startTime);
    if (fadeTime)
    {
        param.setValueAtTime(param.value, startTime);
        param.linearRampToValueAtTime(value, startTime + fadeTime);
    }
    else
        param.value = value;
}

///////////////////////////////////////////////////////////////////////////////
/**
 * Base class for audio effects, an input and output with a wet/dry mix between them
 * - Sounds connect to input, output goes to the master gain until connect() moves it
 * - Subclasses put their nodes between input and the wet gain with connectEffect
 * @memberof AudioEffects
 * @example
 * const cave = new AudioReverb(3, 2);
 * footstep.output = cave; // every play of this sound is in the cave
 */
class AudioEffect
{
    /** Create an audio effect
     *  @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */
    constructor(mix=1)
    {
        ASSERT(isNumber(mix), 'mix must be a number');

        /** @property {GainNode} - Connect sounds to this node */
        this.input = audioContext.createGain();
        /** @property {GainNode} - This node carries the mixed result, send it somewhere with connect(), never by assigning here
         *  - Unlike sound.output, which is where a sound's audio goes and can be set to an effect */
        this.output = audioContext.createGain();
        /** @property {GainNode} - Level of the unprocessed signal */
        this.dryGain = audioContext.createGain();
        /** @property {GainNode} - Level of the processed signal */
        this.wetGain = audioContext.createGain();
        /** @property {number} - Wet/dry balance, 0 is fully dry and 1 is fully wet */
        this.mix = mix;

        this.input.connect(this.dryGain).connect(this.output);
        this.wetGain.connect(this.output);
        this.setMix(mix);

        // send the result to the speakers, connect() moves it into a chain instead
        this.output.connect(audioMasterGain);
    }

    /** Set the wet/dry balance
     *  @param {number} mix - 0 is fully dry and 1 is fully wet
     *  @param {number} [fadeTime] - Seconds to ramp over so the change doesn't click */
    setMix(mix, fadeTime=0)
    {
        ASSERT(isNumber(mix), 'mix must be a number');
        this.mix = mix = clamp(mix);
        this.rampParam(this.dryGain.gain, 1-mix, fadeTime);
        this.rampParam(this.wetGain.gain, mix, fadeTime);
    }

    /** Ramp one of this effect's params, keeping the effect running until the ramp is done
     *  - The browser drops an effect from rendering while nothing plays through it, which
     *    would freeze a ramp partway, so a silent source feeds the input for the ramp's length
     *  @param {AudioParam} param - The param to ramp
     *  @param {number} value - Where to ramp to
     *  @param {number} [fadeTime] - Seconds to ramp over, 0 sets the value at once
     *  @protected */
    rampParam(param, value, fadeTime=0)
    {
        audioParamRamp(param, value, fadeTime);
        if (!fadeTime) return;
        const keepAlive = new ConstantSourceNode(audioContext, { offset: 0 });
        keepAlive.connect(this.input);
        keepAlive.onended = ()=> keepAlive.disconnect();
        keepAlive.start();
        keepAlive.stop(audioContext.currentTime + fadeTime);
    }

    /** Send this effect's output into another effect or audio node instead of the speakers
     *  @param {AudioEffect|AudioNode} target - The next effect in the chain, or any audio node
     *  @return {AudioEffect|AudioNode} - The target, so chains read left to right */
    connect(target)
    {
        // an effect stands in for its input node, the same rule as sound.output
        const node = /** @type {AudioNode} */ (target && 'input' in target ? target.input : target);
        ASSERT(node && typeof node.connect === 'function', 'target must be an AudioEffect or AudioNode');
        this.output.disconnect();
        this.output.connect(node);
        return target;
    }

    /** Stop sending this effect's output anywhere */
    disconnect() { this.output.disconnect(); }

    /** Wire nodes between the input and the wet gain, for subclasses
     *  @param {AudioNode} first - Node the input connects to
     *  @param {AudioNode} [last=first] - Node that connects to the wet gain
     *  @protected */
    connectEffect(first, last=first)
    {
        this.input.connect(first);
        last.connect(this.wetGain);
    }
}

///////////////////////////////////////////////////////////////////////////////
/**
 * Filter effect, muffle sounds underwater or behind a wall
 * @extends AudioEffect
 * @memberof AudioEffects
 * @example
 * const muffle = new AudioFilter('lowpass', 400);
 * setAudioMasterEffect(muffle);
 * muffle.setFrequency(20000, .5); // sweep back to clear
 */
class AudioFilter extends AudioEffect
{
    /** Create a filter effect
     *  @param {BiquadFilterType} [type] - lowpass, highpass, bandpass, notch, etc.
     *  @param {number} [frequency] - Cutoff or center frequency in Hz
     *  @param {number} [q] - Resonance at the cutoff, higher is sharper
     *  @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */
    constructor(type='lowpass', frequency=1000, q=1, mix=1)
    {
        super(mix);
        ASSERT(isNumber(frequency) && frequency >= 0, 'frequency must be positive or zero');
        ASSERT(isNumber(q), 'q must be a number');

        /** @property {BiquadFilterNode} - The filter node */
        this.node = audioContext.createBiquadFilter();
        this.node.type = type;
        this.node.frequency.value = frequency;
        this.node.Q.value = q;
        this.connectEffect(this.node);
    }

    /** Set the cutoff or center frequency
     *  @param {number} frequency - Frequency in Hz
     *  @param {number} [fadeTime] - Seconds to sweep over */
    setFrequency(frequency, fadeTime=0)
    {
        ASSERT(isNumber(frequency) && frequency >= 0, 'frequency must be positive or zero');
        this.rampParam(this.node.frequency, frequency, fadeTime);
    }

    /** Set the resonance at the cutoff
     *  @param {number} q - Higher is sharper
     *  @param {number} [fadeTime] - Seconds to ramp over */
    setQ(q, fadeTime=0)
    {
        ASSERT(isNumber(q), 'q must be a number');
        this.rampParam(this.node.Q, q, fadeTime);
    }
}

///////////////////////////////////////////////////////////////////////////////
/**
 * Reverb effect, puts sounds in a room, cave, or hall
 * - The impulse response is generated, no audio file needed
 * @extends AudioEffect
 * @memberof AudioEffects
 * @example
 * const hall = new AudioReverb(4, 1.5, .4);
 * footstep.output = hall;
 */
class AudioReverb extends AudioEffect
{
    /** Create a reverb effect
     *  @param {number} [duration] - Seconds until the reverb tail is silent
     *  @param {number} [decay] - How quickly the tail fades, higher is faster
     *  @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */
    constructor(duration=2, decay=2, mix=.5)
    {
        super(mix);

        /** @property {ConvolverNode} - The convolver node */
        this.node = audioContext.createConvolver();
        this.setRoom(duration, decay);
        this.connectEffect(this.node);
    }

    /** Change the room by rebuilding the impulse response
     *  @param {number} duration - Seconds until the reverb tail is silent
     *  @param {number} [decay] - How quickly the tail fades, higher is faster */
    setRoom(duration, decay=2)
    {
        ASSERT(isNumber(duration) && duration > 0, 'duration must be positive');
        ASSERT(isNumber(decay) && decay > 0, 'decay must be positive');
        this.node.buffer = this.createImpulse(duration, decay);
    }

    /** Build a stereo impulse response of decaying noise
     *  @param {number} duration - Seconds until silence
     *  @param {number} decay - How quickly it fades, higher is faster
     *  @return {AudioBuffer} */
    createImpulse(duration, decay)
    {
        const sampleRate = audioContext.sampleRate;
        const length = max(1, sampleRate * duration | 0);
        const buffer = audioContext.createBuffer(2, length, sampleRate);
        for (let channel = 2; channel--;)
        {
            const samples = buffer.getChannelData(channel);
            for (let i = length; i--;)
                samples[i] = rand(-1, 1) * (1 - i/length) ** decay;
        }
        return buffer;
    }
}

///////////////////////////////////////////////////////////////////////////////
/**
 * Delay effect, echoes that repeat and fade
 * @extends AudioEffect
 * @memberof AudioEffects
 * @example
 * const canyon = new AudioDelay(.4, .5);
 * shout.output = canyon;
 */
class AudioDelay extends AudioEffect
{
    /** Create a delay effect
     *  @param {number} [time] - Seconds between echoes, up to 5
     *  @param {number} [feedback] - How much of each echo repeats, 0 to .95
     *  @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */
    constructor(time=.3, feedback=.4, mix=.5)
    {
        super(mix);

        /** @property {DelayNode} - The delay node */
        this.node = audioContext.createDelay(5);
        /** @property {GainNode} - How much of the delayed signal feeds back in */
        this.feedbackGain = audioContext.createGain();
        this.node.connect(this.feedbackGain).connect(this.node);
        this.connectEffect(this.node);
        this.setTime(time);
        this.setFeedback(feedback);
    }

    /** Set the time between echoes
     *  - Browsers hold a delay in a feedback loop to at least one render quantum, so 0 is not a bypass
     *  @param {number} time - Seconds, up to 5
     *  @param {number} [fadeTime] - Seconds to ramp over, pitch bends while it moves */
    setTime(time, fadeTime=0)
    {
        ASSERT(isNumber(time) && time >= 0 && time <= 5, 'time must be between 0 and 5');
        this.rampParam(this.node.delayTime, time, fadeTime);
    }

    /** Set how much of each echo repeats, clamped below 1 so it always dies out
     *  @param {number} feedback - 0 to .95
     *  @param {number} [fadeTime] - Seconds to ramp over */
    setFeedback(feedback, fadeTime=0)
    {
        ASSERT(isNumber(feedback), 'feedback must be a number');
        this.rampParam(this.feedbackGain.gain, clamp(feedback, 0, .95), fadeTime);
    }
}

///////////////////////////////////////////////////////////////////////////////
/**
 * Distortion effect, overdrive for radios, damaged robots, and engines
 * @extends AudioEffect
 * @memberof AudioEffects
 * @example
 * const radio = new AudioDistortion(.8);
 * voice.output = radio;
 */
class AudioDistortion extends AudioEffect
{
    /** Create a distortion effect
     *  @param {number} [amount] - How hard to drive the signal, 0 is clean and 1 is crushed
     *  @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */
    constructor(amount=.5, mix=1)
    {
        super(mix);

        /** @property {WaveShaperNode} - The wave shaper node */
        this.node = audioContext.createWaveShaper();
        this.node.oversample = '2x';
        /** @property {number} - How hard the signal is driven, 0 is clean and 1 is crushed */
        this.amount = amount;
        this.setAmount(amount);
        this.connectEffect(this.node);
    }

    /** Set how hard to drive the signal, rebuilds the shaping curve
     *  @param {number} amount - 0 is clean and 1 is crushed */
    setAmount(amount)
    {
        ASSERT(isNumber(amount), 'amount must be a number');
        this.amount = amount = clamp(amount);

        // soft clip curve, drive grows with the square of amount so low values stay subtle
        // enough points that quiet signals are still shaped at high drive, where the curve is steep near 0
        const drive = 100 * amount * amount;
        const samples = 1024;
        const curve = new Float32Array(samples);
        for (let i = samples; i--;)
        {
            const x = i * 2 / (samples - 1) - 1;
            curve[i] = (1 + drive) * x / (1 + drive * abs(x));
        }
        this.node.curve = curve;
    }
}

///////////////////////////////////////////////////////////////////////////////
/**
 * Compressor effect, evens out loud and quiet so many sounds at once don't clip
 * - Meant for the master bus, it is not on by default
 * @extends AudioEffect
 * @memberof AudioEffects
 * @example
 * const compressor = new AudioCompressor;
 * setAudioMasterEffect(compressor);
 */
class AudioCompressor extends AudioEffect
{
    /** Create a compressor effect
     *  @param {number} [threshold] - Level in dB above which the signal is reduced
     *  @param {number} [ratio] - How much to reduce it, 12 means 12 dB in becomes 1 dB out
     *  @param {number} [mix] - Wet/dry balance, 0 is fully dry and 1 is fully wet */
    constructor(threshold=-24, ratio=12, mix=1)
    {
        super(mix);
        ASSERT(isNumber(threshold), 'threshold must be a number');
        ASSERT(isNumber(ratio) && ratio >= 1, 'ratio must be 1 or more');

        /** @property {DynamicsCompressorNode} - The compressor node */
        this.node = audioContext.createDynamicsCompressor();
        this.node.threshold.value = threshold;
        this.node.ratio.value = ratio;
        this.connectEffect(this.node);
    }

    /** Set the level above which the signal is reduced
     *  @param {number} threshold - Level in dB
     *  @param {number} [fadeTime] - Seconds to ramp over */
    setThreshold(threshold, fadeTime=0)
    {
        ASSERT(isNumber(threshold), 'threshold must be a number');
        this.rampParam(this.node.threshold, threshold, fadeTime);
    }

    /** Set how much the signal is reduced above the threshold
     *  @param {number} ratio - 1 is no reduction, 20 is a hard limit
     *  @param {number} [fadeTime] - Seconds to ramp over */
    setRatio(ratio, fadeTime=0)
    {
        ASSERT(isNumber(ratio) && ratio >= 1, 'ratio must be 1 or more');
        this.rampParam(this.node.ratio, ratio, fadeTime);
    }
}