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.

// 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});