Simplified clarinet

Using DWG synthesis. Note how this synthesis technique is able to capture variations of timbre due to nuance, even though the model is very simple.

Log in to post a comment.

// A very simplified "clarinet" physical model,
// consisting in:
// delay line, lowpass filter, 
// first-order allpass interpolation as proposed here
// https://ccrma.stanford.edu/~jos/pasp/First_Order_Allpass_Interpolation.html#sec:apinterp
// and finally a nonlinear "logistic map" function from which the resonance emerges.

// Similar to what is proposed in
// https://ccrma.stanford.edu/~jos/swgt/Wind_Instruments.html
// but even simpler.

input.nuance = 0.25; // min=0, max=1, step=0.01

const clarinet = synth.def( class {
    constructor(options) {
        let freq = midi_to_hz(options.note);
        let dly = 1 / (2*freq*ditty.dt); // duration of half a period in samples
        let cutoff = options.cutoff;
        this.a0 = clamp01(2*Math.PI*cutoff*ditty.dt);
        let dly_lp = (1 - this.a0) / this.a0; // Delay induced by the lowpass filter
        let dly_ap = (dly - dly_lp - 0.1) % 1 + 0.1; // Delay of the allpass filter
        let dly_M  = Math.round(dly - dly_lp - dly_ap); // Samples in the delay line
        this.dl_s = new Float32Array(dly_M);
        this.dl_pos = 0;
        this.dl_M = dly_M;
        this.eta = (1 - dly_ap) / (1 + dly_ap); // Coefficient of the allpass filter
        // Initialize values to the steady state to avoid ugly transients / clicks
        //  See https://en.wikipedia.org/wiki/Logistic_map
        let gamma = 2; // Initial value
        let v0 = (gamma-1)/gamma;
        for(let i=0; i<this.dl_M; i++) { this.dl_s[i] = v0; }
        this.ap_ynm1 = v0;
        this.ap_xnm1 = v0;
        this.lp_znm1 = v0;
        // Compensate the gain of the lowpass filter depending on the note played, so that nuance is homogeneous
        let lp_gain = 1 / Math.sqrt(1 + (0.5*freq/cutoff)**2);
        this.lp_gain_compensation = 1/lp_gain;
    }
    process(note, env, tick, options) {
        let gamma = 2 + (1 + 0.449*options.nuance*(1+options.vibAmt*Math.sin(options.vibHz*6.28*tick))) * env.value;
        let xn = this.dl_s[this.dl_pos];
        // Apply allpass filter (fractional delay)
        // https://ccrma.stanford.edu/~jos/pasp/First_Order_Allpass_Interpolation.html#sec:apinterp
        let yn = this.eta * (xn - this.ap_ynm1) + this.ap_xnm1;
        this.ap_xnm1 = xn; this.ap_ynm1 = yn;
        let noise = 0.001 * (Math.random() - 0.5) * env.value;
        // Apply lowpass filter
        let zn = this.lp_znm1 + this.a0 * (yn - this.lp_znm1 + noise);
        let hp = yn - zn; // Also gather hipass signal
        //let zn = this.lp_znm1 + this.a0 * (yn - this.lp_znm1); // DEBUG bypassing allpass
        this.lp_znm1 = zn;
        
        // Apply nonlinearity
        let wn = gamma * zn * (1-zn); // logistic map
        wn *= this.lp_gain_compensation;
        wn = Math.max(wn, 0);
        // Feed back into delay line
        this.dl_s[this.dl_pos] = wn;
        this.dl_pos++;
        if(this.dl_pos >= this.dl_M) {
            this.dl_pos -= this.dl_M;
        }
        // Return the high-passed signal
        return hp * env.value;
    }
}, {env: adsr, attack:0.06, decay: 0.1, sustain:0.98, release: 0.2, duration: 1, cutoff:1700, nuance:() => input.nuance,
    vibAmt: 0.1, vibHz: 5
});

ditty.bpm = 60;

/*
loop( (i) => {
    clarinet.play(d3, {env:adsr, duration: 5, cutoff:1500, pan:0.3, vibHz: 3});
    clarinet.play(a3, {env:adsr, duration: 5, cutoff:1500, pan:-0.6, vibHz: 3.62});
    clarinet.play(fs4, {env:adsr, duration: 5, pan:0.6, vibHz: 4.2});
    clarinet.play(d5, {env:adsr, duration: 5, pan:-0.3, vibHz: 4.7});
    sleep(6);
}, {name: "ensemble", amp:1.5});

loop( (i) => {
    const nn = [d4, fs4, a4, b4, fs5, a5, d5, cs5, b4, a4, fs4, e4];
    let nuance = input.nuance;// + 0.07*Math.random();
    clarinet.play(nn.ring(i), {release:0.5, vibAmt:(tick) => 0.3*clamp01(2*tick-0.3), vibHz: 3+Math.random()*2,
                  nuance:nuance, cutoff:2000});
    sleep(1);
}, {name:"solo clarinet", amp:5});
*/

// Nuance test
/*
loop( (i) => {
    const nn = [d4, fs4, a4, b4, fs5, a5, d5, cs5, b4, a4, fs4, e4];
    clarinet.play(nn.ring(i), {env:one, duration:10, nuance:(tick) => tick/10});
    sleep(10);
});
*/


loop( (i) => {
    // Adapted from Brahms - Clarinet Sonata No. 2 in Eb Major Op. 120
    const nn =  [eb5, d5, f5, eb5, g4, c5, ab4, d4,   0, c4, eb4, bb3, bb4,   0,  d4, f4, c4, c5, 0,    f4, ab5, g5, e5, f5, g4, ab4, eb5, d5, b4, c5, e4, f4, eb4, d4, eb4,0];
    const bps = 1.8; // beats per second - I don't like that changing ditty.bpm changes the envelope durations
    const dur = [1.5,0.5,0.5,0.5,0.5,0.5, 2.0, 1.3,0.2,0.5, 1.5, 0.5, 1.2, 0.3, 0.5,1.5,0.5,1.3, 0.2, 0.5, 1.5,0.5,0.5,0.5,0.5,0.5,1.5, 0.5,0.5,0.5,0.5,0.5,2.0,2.0,2.0,2.0];
    const nu  = [.01,.15,0.1, .15,.12,.13,  .1, .2,  .01,.15,.05,0.15, .25, 0.05, .2,.1, .3, .4, 0.4, 0.4,0.08,.2,0.1,.12, .1, .2,  .1, .25, .1, .2, .15, .25, .15,.07,.05,.12]
    let dur_i = dur.ring(i) / bps;
    if(nn.ring(i)) {
        clarinet.play(nn.ring(i), {duration:dur_i, attack:0.02, sustain:1, release:0.8, 
                        nuance: (tick) => lerp(nu.ring(i), nu.ring(i+1), tick/dur_i)});
    }
    sleep(dur_i);
}, {amp:5});