KilledByAPixel / ZzFX

A Tiny JavaScript Sound FX System
https://zzfx.3d2k.com
MIT License
585 stars 36 forks source link

LP/BP/HP effects #7

Closed dy closed 5 months ago

dy commented 3 years ago

audionodes provides adsr for filtering effects: image Enabling simple filters would make a big deal for recreating realistic sounds with zzfx.

wafxr has that too: https://andyhall.github.io/wafxr/

KilledByAPixel commented 3 years ago

Hi, thank you for the suggestion but that would add too much to the size of zzfx. Part of the goal with this lib is to keep it as small as possible. Not only would it increase the size but also require 6 more parameters.

However you can use something like BiquadFilterNode, ConvolverNode, or use other code to process the result of zzfx.

dy commented 3 years ago

I actually had in mind just simple biquad formula, that's around 5-10 loc. y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2; too much wires with WAA.

KilledByAPixel commented 3 years ago

I'm not sure I understand, why not use this?

https://developer.mozilla.org/en-US/docs/Web/API/BiquadFilterNode

dy commented 3 years ago

just following the spirit of the package - least Web Audio API. Compiled WAA can end up in bigger size than the actual biquad math

KilledByAPixel commented 3 years ago

I don't a good understanding of how to implement the filters here. Would be interesting in seeing what you envision the code looking like. If it was possible to do that small I'd like to include it somehow!

dy commented 3 years ago

In a simple bpf case it's roughly this (adapted from audio-biquad):

...
const zzfx =         // play sound
(
    // parameters
    volume = 1, randomness = .05, frequency = 220, attack = 0, sustain = 0, release = .1, shape = 0, shapeCurve = 1, slide = 0, deltaSlide = 0, pitchJump = 0, pitchJumpTime = 0, repeatTime = 0, noise = 0, modulation = 0, bitCrush = 0, delay = 0, sustainVolume = 1, decay = 0, tremolo = 0,
+ filterType=0, filterFreq = 220, filterQ = 1
)=>
{
    // init parameters
    let PI2 = Math.PI*2,
    sign = v => v>0?1:-1,
    startSlide = slide *= 500 * PI2 / zzfxR / zzfxR,
    startFrequency = frequency *= (1 + randomness*2*Math.random() - randomness) 
        * PI2 / zzfxR,
    b=[], t=0, tm=0, i=0, j=1, r=0, c=0, s=0, f, length, buffer, source,
+ x = 0, y = 0, x2 =0, x1 = 0, y2 = 0, y1 = 0;
+ const [b0,b1,b2,a1,a2] = filters[filterType](filterFreq * 2 / zzfxR, filterQ)  // filterType === 3 is BPF type (example)

    // scale by sample rate
    attack = attack * zzfxR + 9; // minimum attack to prevent pop
    decay *= zzfxR;
    sustain *= zzfxR;
    release *= zzfxR;
    delay *= zzfxR;
    deltaSlide *= 500 * PI2 / zzfxR**3;
    modulation *= PI2 / zzfxR;
    pitchJump *= PI2 / zzfxR;
    pitchJumpTime *= zzfxR;
    repeatTime = repeatTime * zzfxR | 0;

    // generate waveform
    for(length = attack + decay + sustain + release + delay | 0;
        i < length; b[i++] = s)
    {
        if (!(++c%(bitCrush*100|0)))                      // bit crush
        {
            s = shape? shape>1? shape>2? shape>3?         // wave shape
                Math.sin((t%PI2)**3) :                    // 4 noise
                Math.max(Math.min(Math.tan(t),1),-1):     // 3 tan
                1-(2*t/PI2%2+2)%2:                        // 2 saw
                1-4*Math.abs(Math.round(t/PI2)-t/PI2):    // 1 triangle
                Math.sin(t);                              // 0 sin

            s = (repeatTime ?
                    1 - tremolo + tremolo*Math.sin(PI2*i/repeatTime) // tremolo
                    : 1) *
                sign(s)*(Math.abs(s)**shapeCurve) *       // curve 0=square, 2=pointy
                volume * zzfxV * (                        // envelope
                i < attack ? i/attack :                   // attack
                i < attack + decay ?                      // decay
                1-((i-attack)/decay)*(1-sustainVolume) :  // decay falloff
                i < attack  + decay + sustain ?           // sustain
                sustainVolume :                           // sustain volume
                i < length - delay ?                      // release
                (length - i - delay)/release *            // release falloff
                sustainVolume :                           // release volume
                0);                                       // post release

            s = delay ? s/2 + (delay > i ? 0 :            // delay
                (i<length-delay? 1 : (length-i)/delay) *  // release delay 
                b[i-delay|0]/2) : s;                      // sample delay

+ // biquad filter
+ if (filterType) x = s, y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2, s = y, x2 = x1, x1 = x, y2 = y1, y1 = y;
        }

        f = (frequency += slide += deltaSlide) *          // frequency
            Math.cos(modulation*tm++);                    // modulation
        t += f - f*noise*(1 - (Math.sin(i)+1)*1e9%2);     // noise

        if (j && ++j > pitchJumpTime)       // pitch jump
        {
            frequency += pitchJump;         // apply pitch jump
            startFrequency += pitchJump;    // also apply to start
            j = 0;                          // reset pitch jump time
        }

        if (repeatTime && !(++r % repeatTime)) // repeat
        {
            frequency = startFrequency;     // reset frequency
            slide = startSlide;             // reset slide
            j = j || 1;                     // reset pitch jump time
        }
    }
 ...

+ // get biquad filter params [b0, b1, b2, a1, a2]
+ const filters = [,
+ () => [], // lpf
+ () => [], // hpf
+ (f, Q, w0 = Math.PI * f, α=Math.sin(w0) / (2 * Q), k = Math.cos(w0)) => f > 0 && f < 1 ? Q > 0 ? nc(α, 0, -α, 1 + α, -2*k, 1-α) : nc(1, 0, 0, 1, 0, 0) : nc(0, 0, 0, 1, 0, 0) // bpf
+ ]

+ // normalize coeffs
+ const nc = (b0, b1, b2, a0, a1, a2, a0i=1/a0) => [b0 * a0i, b1 * a0i, b2 * a0i, a1 * a0i, a2 * a0i]

(sorry got no chance to debug yet, tested only expanded version)

KilledByAPixel commented 3 years ago

That is pretty cool. Doesn't seem like too much code (assuming it works), though considering how small zzfx is, this would probably add 10-20%. Also need to figure out lpf and hpf.

I wonder if it would be simpler/smaller to just implement lpf and hpf. Then maybe it just requires a single extra parameter. If 0 there is no filter. If a positive number it does a high pass using that value. If negative it does a low pass using the absolute value as frequency.

dy commented 3 years ago

Cool! I'd inverse though - for positive it does LP (cutoff frequency), for negative - HP (intuition like array.slice(-5)) - slices from the end. Maybe there's even shorter formulas for just LP/HP, not generic biquad.

KilledByAPixel commented 3 years ago

Yeah that's what I am thinking. I will look into it. We will leave it open for now.

dy commented 3 years ago

Will leave it here Audio EQ Cookbook, biquad-coefs

dy commented 5 months ago

@KilledByAPixel I wonder if I should've reversed negative filter values - it doesn't exactly follows array logic, at 0 it has discontinuity so that when values go negative mb it would make more sense to cut off frequency from the end of spectrum

KilledByAPixel commented 5 months ago

@dy yes i was also thinking of how to fix the continuity issue. it's not too late to change, i am still working on the big update.

what would be the end of the spectrum in this case, the Nyquist frequency?

KilledByAPixel commented 5 months ago

it is all checked in now. From my testing it feels good the way you have it set. Because for low cutoff you typically want a value of around 2000hz or less, but the upper theoretical cutoff would be 10x that.