Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 226 additions & 57 deletions synth-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,221 @@
this.index = 0;
}
}
class ZitaRev {
constructor(sampleRate) {
this.fs = sampleRate;
this.active = false; // CPU saver: don't process if decay is 0

// --- PARAMETERS ---
this.params = {
preDel: 20.0,
lfFc: 200.0,
lowRt60: 2.0,
midRt60: 2.0,
hfDamp: 6000.0
};

// --- CONSTANTS & STATE ---
const SR = this.fs;
this.maxDelay = 32768; // Power of 2
this.mask = this.maxDelay - 1;

this.fConst0 = Math.min(192000.0, Math.max(1.0, SR));
this.fConst1 = 6.283185307179586 / this.fConst0;
this.fConst2 = Math.floor(0.125 * this.fConst0 + 0.5);
this.fConst4 = 3.141592653589793 / this.fConst0;
this.fConst7 = 0.001 * this.fConst0;

// Delay line setup
this.delayConsts = [
{ main: Math.floor(0.219991 * this.fConst0 + 0.5), apf: Math.floor(0.019123 * this.fConst0 + 0.5) },
{ main: Math.floor(0.192303 * this.fConst0 + 0.5), apf: Math.floor(0.029291 * this.fConst0 + 0.5) },
{ main: Math.floor(0.174713 * this.fConst0 + 0.5), apf: Math.floor(0.022904 * this.fConst0 + 0.5) },
{ main: Math.floor(0.256891 * this.fConst0 + 0.5), apf: Math.floor(0.027333 * this.fConst0 + 0.5) },
{ main: Math.floor(0.127837 * this.fConst0 + 0.5), apf: Math.floor(0.031604 * this.fConst0 + 0.5) },
{ main: Math.floor(0.210389 * this.fConst0 + 0.5), apf: Math.floor(0.024421 * this.fConst0 + 0.5) },
{ main: Math.floor(0.153129 * this.fConst0 + 0.5), apf: Math.floor(0.020346 * this.fConst0 + 0.5) },
{ main: this.fConst2, apf: Math.floor(0.013458 * this.fConst0 + 0.5) } // Filter 7 special case
];

// Buffers
this.delays = new Array(8).fill(0).map(() => ({
main: new Float32Array(this.maxDelay),
apf: new Float32Array(4096)
}));

this.states = new Array(8).fill(0).map(() => ({
damp: 0, lp: 0, comb: 0, rec: [0,0] // History for feedback
}));

// Misc
this.inputL = new Float32Array(this.maxDelay);
this.inputR = new Float32Array(this.maxDelay);
this.iota = 0;
this.lfFilter = 0;
this.dirty = true; // Force calc on first run
this.coeffs = [];
this.lfCoeff = {};
this.preDelaySamps = 0;

// Helper constants
this.dwConst = 0.353553385; // 1/sqrt(8)
this.apConst = 0.6;
}

setParam(key, val) {
if (this.params[key] !== val) {
this.params[key] = val;
this.dirty = true;
}
}

update() {
if (!this.dirty) return;
const p = this.params;

// Pre-calc coefficients (Run only when knobs move)
const fSlow0 = Math.cos(this.fConst1 * p.hfDamp);
const lfTan = 1.0 / Math.tan(this.fConst4 * p.lfFc);

this.lfCoeff = {
scale: 1.0 / (lfTan + 1.0),
fb: (1.0 - lfTan) / (lfTan + 1.0)
};

this.preDelaySamps = Math.floor(this.fConst7 * p.preDel);

this.coeffs = this.delayConsts.map(dc => {
const decayConst = (0.0 - (6.907755 * dc.main)) / this.fConst0;
const fSlow2 = Math.exp(decayConst / p.midRt60);
const fSlow3 = fSlow2 * fSlow2;
const fSlow4 = 1.0 - (fSlow0 * fSlow3);
const fSlow5 = 1.0 - fSlow3;
const fSlow6 = fSlow4 / fSlow5;
const fSlow7 = Math.sqrt(Math.max(0, (fSlow4*fSlow4)/(fSlow5*fSlow5) - 1.0));
const b0 = fSlow6 - fSlow7;

return {
b0: b0,
a1: fSlow2 * (fSlow7 + (1.0 - fSlow6)),
lowMult: (Math.exp(decayConst / p.lowRt60) / fSlow2) - 1.0
};
});

this.dirty = false;
}

processSample(inL, inR) {
// Optimization: If reverb is barely active, just return silence
// (Can add a threshold here if needed)

this.update(); // Check dirty flag

const idx = this.iota & this.mask;
this.inputL[idx] = inL;
this.inputR[idx] = inR;

// Read Pre-delay
const rIdx = (this.iota - this.preDelaySamps) & this.mask;
const dL = this.inputL[rIdx];
const dR = this.inputR[rIdx];

const earlyL = dL * 0.3;
const earlyR = dR * 0.3;

const combOuts = new Float32Array(8);

// Run 8 Comb Filters
for(let i=0; i<8; i++) {
const s = this.states[i];
const c = this.coeffs[i];
const d = this.delays[i];
const dc = this.delayConsts[i];

// Low Shelf (Rt60 control)
const recSum = s.rec[0] + s.rec[1];
s.lp = (this.lfCoeff.scale * recSum) + (this.lfCoeff.fb * s.lp);

// High Damping
s.damp = (c.b0 * s.damp) + (c.a1 * (s.rec[0] + (c.lowMult * s.lp)));

// Main Delay write
const mainOut = (this.dwConst * s.damp); // + denormal fix if needed
const mainReadIdx = (this.iota - (dc.main - dc.apf)) & this.mask; // Approximate logic for delay taps

// Just using main buffer as ring buffer
// Note: The original code uses multiple tap points.
// Simplified here: Write to head.

// The original Zita delay logic is complex with specific read pointers.
// To keep this snippet small, we assume standard FDN structure:
// Write Head -> Read Head (Delay Time)

const writePos = idx;
const readPos = (this.iota - dc.main) & this.mask;

d.main[writePos] = mainOut;
const mainRead = d.main[readPos];

// Embedded Allpass
const apIn = mainRead - (this.apConst * s.comb); // + inject

// Special Injection logic from original code:
// Filters 0,3,5,7 get earlyL, others get earlyR
let inject = 0;
if (i===0 || i===5 || i===7) inject = -earlyL;
else if (i===3) inject = earlyL;
else if (i===1 || i===4 || i===6) inject = -earlyR;
else inject = earlyR;

const apInFinal = apIn + inject;

const apWrite = this.iota & 4095;
const apRead = (this.iota - dc.apf) & 4095;

d.apf[apWrite] = apInFinal;
const apOut = d.apf[apRead];

s.comb = apOut + (this.apConst * apInFinal);
combOuts[i] = s.comb;
}

// Hadamard Matrix Mixing (The "Magic" Sauce)
// This mixes the 8 comb outputs to create the feedback for the next frame

// Using a fast in-place Hadamard-like shuffle
// 0+1, 2+3, 4+5, 6+7
const t0 = combOuts[0]+combOuts[1]; const t1 = combOuts[0]-combOuts[1];
const t2 = combOuts[2]+combOuts[3]; const t3 = combOuts[2]-combOuts[3];
const t4 = combOuts[4]+combOuts[5]; const t5 = combOuts[4]-combOuts[5];
const t6 = combOuts[6]+combOuts[7]; const t7 = combOuts[6]-combOuts[7];

// Stage 2
const m0 = t0+t2; const m1 = t0-t2;
const m2 = t1+t3; const m3 = t1-t3;
const m4 = t4+t6; const m5 = t4-t6;
const m6 = t5+t7; const m7 = t5-t7;

// Feedback inputs for next cycle
this.states[0].rec[1] = this.states[0].rec[0]; this.states[0].rec[0] = m0 + m4;
this.states[1].rec[1] = this.states[1].rec[0]; this.states[1].rec[0] = m1 + m5; // Check Zita permutes
// (Zita actually does a specific permutation here, but a full Hadamard is usually fine for reverb)
this.states[2].rec[1] = this.states[2].rec[0]; this.states[2].rec[0] = m2 + m6;
this.states[3].rec[1] = this.states[3].rec[0]; this.states[3].rec[0] = m3 + m7;
this.states[4].rec[1] = this.states[4].rec[0]; this.states[4].rec[0] = m0 - m4;
this.states[5].rec[1] = this.states[5].rec[0]; this.states[5].rec[0] = m1 - m5;
this.states[6].rec[1] = this.states[6].rec[0]; this.states[6].rec[0] = m2 - m6;
this.states[7].rec[1] = this.states[7].rec[0]; this.states[7].rec[0] = m3 - m7;

// Output Mix
const outL = (this.states[1].rec[0] + this.states[2].rec[0]) * 0.37;
const outR = (this.states[1].rec[0] - this.states[2].rec[0]) * 0.37;

this.iota++;

return [outL, outR];
}
}
class JroomzProcessor extends AudioWorkletProcessor {
constructor() {
super();
Expand All @@ -90,15 +304,7 @@
this.delayTimeSmoother = new SmoothValue(0.3);
this.pitchSmoother = new SmoothValue(0.5);
this.velSmoother = new SmoothValue(0.5);

const tunings = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617];
const stereospread = 23;
this.combsL = tunings.map(t => new LowpassCombFilter(t));
this.combsR = tunings.map(t => new LowpassCombFilter(t + stereospread));

this.allpassesL = [new AllpassFilter(556), new AllpassFilter(441), new AllpassFilter(341), new AllpassFilter(225)];
this.allpassesR = [new AllpassFilter(556+stereospread), new AllpassFilter(441+stereospread), new AllpassFilter(341+stereospread), new AllpassFilter(225+stereospread)];
this.verbFb = 0.7;
this.zita = new ZitaRev(this.fs);

this.sequencer = {
currentStep: 0,
Expand Down Expand Up @@ -135,11 +341,14 @@

this.params[payload.id] = payload.value;

if (payload.id === 'reverbDecay') {
this.verbFb = 0.7 + (payload.value * 0.28);
this.combsL.forEach(c => c.setFeedback(this.verbFb));
this.combsR.forEach(c => c.setFeedback(this.verbFb));
}
if (payload.id === 'reverbDecay') {
// Map 0.0-1.0 to 0.5s-8.0s decay time
const len = 0.5 + (payload.value * 7.5);
this.zita.setParam('midRt60', len);
this.zita.setParam('lowRt60', len * 0.85);
// We fix High Freq Damping to 6kHz for a natural shimmer
this.zita.setParam('hfDamp', 6000);
}
if (payload.id === 'delayRate') {
this.delayTimeSmoother.set(payload.value);
}
Expand Down Expand Up @@ -334,46 +543,6 @@
return [inL + (outL - inL) * mix, inR + (outR - inR) * mix];
}

processReverb(inL, inR) {
const mix = (inL + inR) * 0.5 * 0.015;
let oL = 0, oR = 0;

for (let i = 0; i < 8; i++) {
const c = this.combsL[i];
const out = c.buffer[c.index];
c.store = (out * 0.8) + (c.store * 0.2);
c.buffer[c.index] = mix + (c.store * this.verbFb);
c.index = (c.index + 1) % c.bufferSize;
oL += out;
}
for (let i = 0; i < 8; i++) {
const c = this.combsR[i];
const out = c.buffer[c.index];
c.store = (out * 0.8) + (c.store * 0.2);
c.buffer[c.index] = mix + (c.store * this.verbFb);
c.index = (c.index + 1) % c.bufferSize;
oR += out;
}

for (let i = 0; i < 4; i++) {
const ap = this.allpassesL[i];
const bufOut = ap.buffer[ap.index];
const out = -oL + bufOut;
ap.buffer[ap.index] = oL + (bufOut * 0.5);
ap.index = (ap.index + 1) % ap.bufferSize;
oL = out;
}
for (let i = 0; i < 4; i++) {
const ap = this.allpassesR[i];
const bufOut = ap.buffer[ap.index];
const out = -oR + bufOut;
ap.buffer[ap.index] = oR + (bufOut * 0.5);
ap.index = (ap.index + 1) % ap.bufferSize;
oR = out;
}
return [oL, oR];
}

process(inputs, outputs) {
const output = outputs[0];
const channelL = output[0];
Expand Down Expand Up @@ -465,11 +634,11 @@

const [bentL, bentR] = this.processDataBender(postVca, postVca);
const [delL, delR] = this.processDelay(bentL, bentR);
const [revL, revR] = this.processReverb(delL, delR);
const [revL, revR] = this.zita.processSample(delL, delR);
const rWet = this.params.reverbMix;

let mixL = delL + (revL * rWet * 1.5);
let mixR = delR + (revR * rWet * 1.5);
let mixL = delL + (revL * rWet);
let mixR = delR + (revR * rWet);

let subL = this.mfL_hp_sub.process(mixL, hpFreq, this.fs);
let subR = this.mfR_hp_sub.process(mixR, hpFreq, this.fs);
Expand Down