matcha-tts-pl / effects.js
mcPear's picture
Matcha-TTS-PL v2 browser playground
735f5c2 verified
Raw History Blame Contribute Delete
28.4 kB
// effects.js - dependency-free browser port of scripts/effects.py (numpy + scipy + librosa DSP chain).
//
// applyChain(wav /* Float32Array mono */, sr, { pitch: 2, robot: 40, ... }) -> Float32Array
//
// Processing order == order of EFFECTS (time -> identity -> tone -> character -> space -> output),
// identical to the Python module. Filters, gain, gate and delay effects are numerically matched to
// scipy (float64 internally, float32 between stages like the Python code). STFT-based effects follow
// the same algorithms (librosa 1.0 phase vocoder, scipy stft/istft framing); micro-pitch and room use
// a seeded JS PRNG instead of numpy's PCG64 stream, so they are perceptually (not sample-) equivalent.
export const EFFECTS = [
// key, label, group, min, max, step, default, unit, desc
{ key: "speed", label: "Tempo", group: "Time", min: 0.7, max: 1.4, step: 0.05, default: 1.0, unit: "×", desc: "time stretch without changing pitch (granular OLA)" },
{ key: "pitch", label: "Pitch", group: "Identity", min: -6, max: 6, step: 0.5, default: 0, unit: "st", desc: "lower ↔ higher, duration unchanged" },
{ key: "formant", label: "Vocal tract", group: "Identity", min: -6, max: 6, step: 0.5, default: 0, unit: "st", desc: "spectral envelope shift (larger ↔ smaller “body”), harmonics unchanged" },
{ key: "warmth", label: "Warmth", group: "Tone", min: -8, max: 8, step: 0.5, default: 0, unit: "dB", desc: "low shelf (250 Hz) – body and weight" },
{ key: "presence", label: "Presence", group: "Tone", min: -8, max: 8, step: 0.5, default: 0, unit: "dB", desc: "high shelf (3.5 kHz) – clarity and edge" },
{ key: "dynamic", label: "Dynamic EQ", group: "Tone", min: 0, max: 100, step: 1, default: 0, unit: "%", desc: "level-dependent harshness reduction at 1.2–4 kHz" },
{ key: "deess", label: "De-esser", group: "Tone", min: 0, max: 100, step: 1, default: 0, unit: "%", desc: "dynamic taming of strong sibilants (>5 kHz)" },
{ key: "drive", label: "Drive", group: "Character", min: 0, max: 50, step: 1, default: 0, unit: "%", desc: "soft tanh saturation – texture and grit" },
{ key: "saturation", label: "Warm saturation", group: "Character", min: 0, max: 100, step: 1, default: 0, unit: "%", desc: "asymmetric soft clipping – harmonic coloring" },
{ key: "robot", label: "Robot", group: "Character", min: 0, max: 100, step: 1, default: 0, unit: "%", desc: "42 Hz ring modulation – metallic color" },
{ key: "micro", label: "Micro-pitch", group: "Character", min: 0, max: 100, step: 1, default: 0, unit: "%", desc: "layer of grains detuned by up to ±85 cents" },
{ key: "halo", label: "Granular halo", group: "Character", min: 0, max: 80, step: 1, default: 0, unit: "%", desc: "35 ms smoothed grains added to the signal" },
{ key: "chorus", label: "Chorus", group: "Space", min: 0, max: 100, step: 1, default: 0, unit: "%", desc: "18 ms delay modulated at 0.27 Hz" },
{ key: "echo", label: "Echo", group: "Space", min: 0, max: 40, step: 1, default: 0, unit: "%", desc: "short 115 ms delay – sense of space" },
{ key: "room", label: "Small room", group: "Space", min: 0, max: 100, step: 1, default: 0, unit: "%", desc: "synthetic convolution reverb, 220 ms tail" },
{ key: "gate", label: "Pause gate", group: "Output", min: 0, max: 60, step: 1, default: 0, unit: "dB", desc: "downward expander: parts quieter than the threshold (dB below peak) are attenuated a further 3 dB/dB – removes electronic “breaths” in pauses; 0 = off" },
{ key: "lowpass", label: "Low-pass filter", group: "Output", min: 4000, max: 11000, step: 250, default: 11000, unit: "Hz", desc: "cuts the band above the cutoff (11000 = off); 10 kHz removes the HiFi-GAN whistle near Nyquist, speech unaffected" },
{ key: "volume", label: "Output gain", group: "Output", min: -12, max: 12, step: 0.5, default: 0, unit: "dB", desc: "output gain with limiter" },
];
export const DEFAULTS = Object.fromEntries(EFFECTS.map((e) => [e.key, e.default]));
// default "robot" preset in the UI (chosen by ear 2026-09-24): slight warmth + presence, a bit of chorus and echo; rest neutral
export const PRESET = { warmth: 8.0, presence: 8.0 };
export const UI_DEFAULTS = { ...DEFAULTS, ...PRESET };
// ---------------------------------------------------------------- small numeric helpers
const clip = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
// Python round(): round half to even (used by librosa for the stretched length)
function roundHalfEven(x) {
const f = Math.floor(x), d = x - f;
if (d < 0.5) return f;
if (d > 0.5) return f + 1;
return f % 2 === 0 ? f : f + 1;
}
// np.interp(xq, arange(n), y) for a single query (clamps like numpy; `right` = value past the last sample)
function interp1(y, q, right) {
const n = y.length;
if (q <= 0) return y[0];
if (q >= n - 1) return right === undefined ? y[n - 1] : q === n - 1 ? y[n - 1] : right;
const i = Math.floor(q), t = q - i;
return y[i] + (y[i + 1] - y[i]) * t;
}
function hannPeriodic(n) { // scipy.signal.get_window('hann', n) / librosa 'hann' (fftbins=True)
const w = new Float64Array(n);
for (let i = 0; i < n; i++) w[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / n);
return w;
}
function hanningSym(n) { // np.hanning(n)
const w = new Float64Array(n);
if (n === 1) { w[0] = 1; return w; }
for (let i = 0; i < n; i++) w[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (n - 1));
return w;
}
// deterministic PRNG (mulberry32) + Box-Muller; replaces np.random.default_rng(seed)
function makeRng(seed) {
let a = (seed + 0x9e3779b9) >>> 0;
const next = () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
uniform: (lo, hi) => lo + (hi - lo) * next(),
normal: () => {
let u = 0;
while (u === 0) u = next();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * next());
},
};
}
// ---------------------------------------------------------------- FFT (iterative radix-2, complex in-place)
const _plans = new Map();
function fftPlan(n) {
let p = _plans.get(n);
if (p) return p;
const levels = Math.round(Math.log2(n));
if (1 << levels !== n) throw new Error("FFT size must be a power of 2, got " + n);
const rev = new Uint32Array(n);
for (let i = 0; i < n; i++) {
let r = 0, x = i;
for (let j = 0; j < levels; j++) { r = (r << 1) | (x & 1); x >>= 1; }
rev[i] = r;
}
const cos = new Float64Array(n >> 1), sin = new Float64Array(n >> 1);
for (let i = 0; i < n >> 1; i++) { cos[i] = Math.cos((2 * Math.PI * i) / n); sin[i] = Math.sin((2 * Math.PI * i) / n); }
p = { rev, cos, sin };
_plans.set(n, p);
return p;
}
// forward: e^{-j}, inverse: e^{+j} (unscaled)
function fft(re, im, inverse) {
const n = re.length;
const { rev, cos, sin } = fftPlan(n);
for (let i = 0; i < n; i++) {
const j = rev[i];
if (j > i) { let t = re[i]; re[i] = re[j]; re[j] = t; t = im[i]; im[i] = im[j]; im[j] = t; }
}
const sgn = inverse ? 1 : -1;
for (let size = 2; size <= n; size <<= 1) {
const half = size >> 1, step = n / size;
for (let start = 0; start < n; start += size) {
for (let k = 0, t = 0; k < half; k++, t += step) {
const wr = cos[t], wi = sgn * sin[t];
const a = start + k, b = a + half;
const xr = re[b] * wr - im[b] * wi, xi = re[b] * wi + im[b] * wr;
re[b] = re[a] - xr; im[b] = im[a] - xi;
re[a] += xr; im[a] += xi;
}
}
}
}
function nextPow2(n) { let p = 1; while (p < n) p <<= 1; return p; }
// ---------------------------------------------------------------- STFT helpers
// generic framed rfft: frames start at `f*hop - offset` in x (zeros outside), returns half spectra
function stftFrames(x, nFft, hop, offset, nFrames, win) {
const nb = (nFft >> 1) + 1, n = x.length;
const re = new Float64Array(nb * nFrames), im = new Float64Array(nb * nFrames);
const fr = new Float64Array(nFft), fi = new Float64Array(nFft);
for (let f = 0; f < nFrames; f++) {
const start = f * hop - offset;
for (let i = 0; i < nFft; i++) {
const idx = start + i;
fr[i] = idx >= 0 && idx < n ? x[idx] * win[i] : 0;
fi[i] = 0;
}
fft(fr, fi, false);
re.set(fr.subarray(0, nb), f * nb);
im.set(fi.subarray(0, nb), f * nb);
}
return { re, im, nb, nFrames, nFft, hop };
}
// inverse rfft of one frame (columns of S) into out (length nFft), scaled by 1/nFft
function irfftFrame(S, f, fr, fi) {
const { nb, nFft } = S, off = f * nb;
for (let k = 0; k < nb; k++) { fr[k] = S.re[off + k]; fi[k] = S.im[off + k]; }
for (let k = nb; k < nFft; k++) { fr[k] = S.re[off + nFft - k]; fi[k] = -S.im[off + nFft - k]; }
fft(fr, fi, true);
const s = 1 / nFft;
for (let i = 0; i < nFft; i++) fr[i] *= s;
}
// weighted overlap-add of windowed inverse frames; norm = OLA of win^2 (returns both buffers)
function overlapAdd(S, win) {
const { nFft, hop, nFrames } = S;
const L = nFft + hop * (nFrames - 1);
const y = new Float64Array(L), norm = new Float64Array(L);
const fr = new Float64Array(nFft), fi = new Float64Array(nFft);
for (let f = 0; f < nFrames; f++) {
irfftFrame(S, f, fr, fi);
const o = f * hop;
for (let i = 0; i < nFft; i++) { y[o + i] += fr[i] * win[i]; norm[o + i] += win[i] * win[i]; }
}
return { y, norm };
}
// ---------------------------------------------------------------- librosa-style time stretch / pitch shift
// librosa.stft(center=True, pad_mode='constant', hann)
function stftLibrosa(x, nFft, hop) {
const nFrames = 1 + Math.floor(x.length / hop); // padded length n + nFft -> frames = 1 + (n)/hop
return stftFrames(x, nFft, hop, nFft >> 1, nFrames, hannPeriodic(nFft));
}
// librosa.istft(center=True, length=length)
function istftLibrosa(S, length) {
const { nFft, hop } = S;
const nFrames = Math.min(S.nFrames, Math.ceil((length + 2 * (nFft >> 1)) / hop));
const S2 = { ...S, nFrames };
const { y, norm } = overlapAdd(S2, hannPeriodic(nFft));
const out = new Float32Array(length), half = nFft >> 1, tiny = 1.1754944e-38;
for (let i = 0; i < length; i++) {
const j = i + half;
if (j < y.length) out[i] = norm[j] > tiny ? y[j] / norm[j] : y[j];
}
return out;
}
// librosa 1.0 phase_vocoder: cumulative raw phase differences + linear magnitude interpolation
function phaseVocoder(S, rate) {
const { re, im, nb, nFrames } = S;
const nOut = Math.ceil(nFrames / rate); // np.arange(0, nFrames, rate)
const oRe = new Float64Array(nb * nOut), oIm = new Float64Array(nb * nOut);
const ph = new Float64Array(nb * nFrames), mg = new Float64Array(nb * nFrames);
for (let i = 0; i < ph.length; i++) { ph[i] = Math.atan2(im[i], re[i]); mg[i] = Math.hypot(re[i], im[i]); }
const phase = new Float64Array(nb);
const first = Math.floor(0 * rate);
for (let k = 0; k < nb; k++) phase[k] = ph[first * nb + k];
for (let t = 0; t < nOut; t++) {
const tt = t * rate;
const i0 = Math.floor(tt), i1 = Math.min(i0 + 1, nFrames - 1);
if (t > 0) { // phase[t] = phase[t-1] + diff[t-1]
const pt = (t - 1) * rate, p0 = Math.floor(pt), p1 = Math.min(p0 + 1, nFrames - 1);
for (let k = 0; k < nb; k++) phase[k] += ph[p1 * nb + k] - ph[p0 * nb + k];
}
// magnitude: linear interpolation (extrapolate from the last segment past nFrames-1)
const a = tt - i0;
let m0 = i0, m1 = i1, alpha = a;
if (i1 === i0) { if (nFrames > 1) { m0 = nFrames - 2; m1 = nFrames - 1; alpha = tt - m0; } else { alpha = 0; } }
for (let k = 0; k < nb; k++) {
const mag = mg[m0 * nb + k] + (mg[m1 * nb + k] - mg[m0 * nb + k]) * alpha;
const p = phase[k];
oRe[t * nb + k] = mag * Math.cos(p);
oIm[t * nb + k] = mag * Math.sin(p);
}
}
return { re: oRe, im: oIm, nb, nFrames: nOut, nFft: S.nFft, hop: S.hop };
}
// librosa.effects.time_stretch (n_fft 2048, hop 512)
function timeStretch(x, rate) {
const S = stftLibrosa(x, 2048, 512);
const S2 = phaseVocoder(S, rate);
return istftLibrosa(S2, roundHalfEven(x.length / rate));
}
// bandlimited windowed-sinc resampler (stands in for soxr_hq): output sample i taken at input position i/ratio
function resample(x, ratio, nOut) {
const fc = Math.min(1, ratio); // cutoff relative to input Nyquist (anti-alias when downsampling)
const zc = 24; // zero crossings per side at fc = 1
const W = zc / fc, R = 64, beta = 8.6;
const i0 = (z) => { let s = 1, t = 1; const q = (z * z) / 4; for (let k = 1; k < 60; k++) { t *= q / (k * k); s += t; if (t < 1e-14 * s) break; } return s; };
const tabN = Math.ceil(W * R) + 2, tab = new Float64Array(tabN), ib = 1 / i0(beta);
for (let i = 0; i < tabN; i++) {
const t = i / R;
if (t >= W) { tab[i] = 0; continue; }
const s = t === 0 ? 1 : Math.sin(Math.PI * fc * t) / (Math.PI * fc * t);
const r = t / W;
tab[i] = fc * s * i0(beta * Math.sqrt(1 - r * r)) * ib;
}
const out = new Float32Array(nOut), n = x.length;
for (let i = 0; i < nOut; i++) {
const t0 = i / ratio;
const k0 = Math.max(0, Math.ceil(t0 - W)), k1 = Math.min(n - 1, Math.floor(t0 + W));
let acc = 0;
for (let k = k0; k <= k1; k++) {
const u = Math.abs(k - t0) * R, j = Math.floor(u), fr = u - j;
acc += x[k] * (tab[j] + (tab[j + 1] - tab[j]) * fr);
}
out[i] = acc;
}
return out;
}
// librosa.effects.pitch_shift: stretch by 2^(-st/12), resample back, fix length
function pitchShift(x, sr, nSteps) {
const rate = Math.pow(2, -nSteps / 12);
const ys = timeStretch(x, rate);
const r = resample(ys, rate, Math.ceil(ys.length * rate));
const out = new Float32Array(x.length);
out.set(r.subarray(0, Math.min(r.length, x.length)));
return out;
}
// ---------------------------------------------------------------- formant (scipy.signal.stft / istft, n_fft 1024, hop 256)
function formant(x, sr, shiftSt) {
if (Math.abs(shiftSt) < 1e-6) return x;
const nFft = 1024, hop = 256, half = 512, n = x.length;
const L1 = n + 2 * half; // boundary='zeros'
const nadd = (((-(L1 - nFft)) % hop) + hop) % hop; // padded=True
const L = L1 + nadd;
const nFrames = (L - nFft) / hop + 1;
const win = hannPeriodic(nFft);
const S = stftFrames(x, nFft, hop, half, nFrames, win);
const nb = S.nb, scale = 1 / 512; // scipy 'spectrum' scaling = 1/sum(win)
const radius = Math.max(3, Math.round((175 * nFft) / sr));
const kw = 1 / (2 * radius + 1);
const ratio = Math.pow(2, shiftSt / 12);
const gateF = new Float64Array(nb);
for (let k = 0; k < nb; k++) {
const hz = (k * sr) / nFft;
gateF[k] = clip(hz / 180, 0, 1) * clip((7500 - hz) / 2000, 0, 1);
}
const logm = new Float64Array(nb), env = new Float64Array(nb);
for (let f = 0; f < nFrames; f++) {
const off = f * nb;
for (let k = 0; k < nb; k++) logm[k] = Math.log(Math.hypot(S.re[off + k], S.im[off + k]) * scale + 1e-7);
// np.convolve(mode='same') with a box kernel, zero padded at the edges
for (let k = 0; k < nb; k++) {
let s = 0;
const a = Math.max(0, k - radius), b = Math.min(nb - 1, k + radius);
for (let j = a; j <= b; j++) s += logm[j];
env[k] = s * kw;
}
for (let k = 0; k < nb; k++) {
const src = clip(k / ratio, 0, nb - 1);
const warped = interp1(env, src);
const g = Math.exp(clip((warped - env[k]) * gateF[k], -1.38, 1.38));
S.re[off + k] *= g; S.im[off + k] *= g;
}
}
const { y, norm } = overlapAdd(S, win);
const out = new Float32Array(n);
for (let i = 0; i < n; i++) {
const j = i + half;
out[i] = norm[j] > 1e-10 ? y[j] / norm[j] : y[j];
}
return out;
}
// ---------------------------------------------------------------- IIR filters
// scipy.signal.lfilter(b, a, x) with 2nd-order sections, direct form II transposed, zero initial state
function biquad(b0, b1, b2, a1, a2, x) {
const n = x.length, y = new Float32Array(n);
let z1 = 0, z2 = 0;
for (let i = 0; i < n; i++) {
const xi = x[i], yi = b0 * xi + z1;
z1 = b1 * xi - a1 * yi + z2;
z2 = b2 * xi - a2 * yi;
y[i] = yi;
}
return y;
}
// RBJ shelving biquad, same formulas as effects._shelf
function shelf(x, sr, f0, gainDb, high) {
if (Math.abs(gainDb) < 1e-6) return x;
const A = Math.pow(10, gainDb / 40);
const w0 = (2 * Math.PI * f0) / sr;
const cos = Math.cos(w0), sin = Math.sin(w0);
const alpha = (sin / 2) * Math.sqrt((A + 1 / A) * (1 / 0.9 - 1) + 2);
const s = 2 * Math.sqrt(A) * alpha;
let b, a;
if (high) {
b = [A * ((A + 1) + (A - 1) * cos + s), -2 * A * ((A - 1) + (A + 1) * cos), A * ((A + 1) + (A - 1) * cos - s)];
a = [(A + 1) - (A - 1) * cos + s, 2 * ((A - 1) - (A + 1) * cos), (A + 1) - (A - 1) * cos - s];
} else {
b = [A * ((A + 1) - (A - 1) * cos + s), 2 * A * ((A - 1) - (A + 1) * cos), A * ((A + 1) - (A - 1) * cos - s)];
a = [(A + 1) + (A - 1) * cos + s, -2 * ((A - 1) + (A + 1) * cos), (A + 1) + (A - 1) * cos - s];
}
return biquad(b[0] / a[0], b[1] / a[0], b[2] / a[0], a[1] / a[0], a[2] / a[0], x);
}
// sos = array of [b0,b1,b2,a1,a2] (a0 = 1); zi (optional) = per-section [z1,z2]; returns Float64Array
function sosfilt(sos, x, zi) {
const n = x.length, y = new Float64Array(n);
y.set(x);
for (let s = 0; s < sos.length; s++) {
const [b0, b1, b2, a1, a2] = sos[s];
let z1 = zi ? zi[s][0] : 0, z2 = zi ? zi[s][1] : 0;
for (let i = 0; i < n; i++) {
const xi = y[i], yi = b0 * xi + z1;
z1 = b1 * xi - a1 * yi + z2;
z2 = b2 * xi - a2 * yi;
y[i] = yi;
}
}
return y;
}
// scipy.signal.sosfilt_zi: steady state for a unit step, scaled by the DC gain of the preceding sections
function sosfiltZi(sos) {
const zi = [];
let scale = 1;
for (const [b0, b1, b2, a1, a2] of sos) {
const H1 = (b0 + b1 + b2) / (1 + a1 + a2);
const z2 = b2 - a2 * H1, z1 = b1 - a1 * H1 + z2;
zi.push([z1 * scale, z2 * scale]);
scale *= H1;
}
return zi;
}
// scipy.signal.sosfiltfilt(sos, x) with default odd padding, padlen = 3*(2*nsec+1) (all coefficients nonzero)
function sosfiltfilt(sos, x) {
const n = x.length;
let padlen = 3 * (2 * sos.length + 1);
if (padlen >= n) padlen = Math.max(0, n - 1);
const L = n + 2 * padlen, ext = new Float64Array(L);
for (let i = 0; i < padlen; i++) ext[i] = 2 * x[0] - x[padlen - i];
for (let i = 0; i < n; i++) ext[padlen + i] = x[i];
for (let i = 0; i < padlen; i++) ext[padlen + n + i] = 2 * x[n - 1] - x[n - 2 - i];
const zi = sosfiltZi(sos);
const scaled = (v) => zi.map(([a, b]) => [a * v, b * v]);
let y = sosfilt(sos, ext, scaled(ext[0]));
y.reverse();
y = sosfilt(sos, y, scaled(y[0]));
y.reverse();
const out = new Float32Array(n);
for (let i = 0; i < n; i++) out[i] = y[padlen + i];
return out;
}
// --- Butterworth design (analog prototype -> prewarp -> bilinear with fs=2, like scipy.signal.butter)
function butterPoles(order) { // buttap: -exp(j*pi*m/(2N)), m = -N+1, -N+3, ..., N-1
const p = [];
for (let m = -order + 1; m <= order - 1; m += 2) p.push([-Math.cos((Math.PI * m) / (2 * order)), -Math.sin((Math.PI * m) / (2 * order))]);
return p;
}
const cmul = (a, b) => [a[0] * b[0] - a[1] * b[1], a[0] * b[1] + a[1] * b[0]];
const cdiv = (a, b) => { const d = b[0] * b[0] + b[1] * b[1]; return [(a[0] * b[0] + a[1] * b[1]) / d, (a[1] * b[0] - a[0] * b[1]) / d]; };
const csqrt = (z) => { const r = Math.hypot(z[0], z[1]); const re = Math.sqrt((r + z[0]) / 2); const im = Math.sqrt(Math.max(0, (r - z[0]) / 2)); return [re, z[1] < 0 ? -im : im]; };
const bilinear = (p) => cdiv([4 + p[0], p[1]], [4 - p[0], -p[1]]); // z = (2fs + s)/(2fs - s), fs = 2
// 8th (or any even) order low-pass as unity-DC-gain biquads
function butterLowpassSos(order, fc, sr) {
const warped = 4 * Math.tan((Math.PI * (fc / (sr / 2))) / 2);
const poles = butterPoles(order).map((p) => bilinear([p[0] * warped, p[1] * warped]));
const sos = [];
for (const z of poles) {
if (z[1] <= 0) continue; // one per conjugate pair (Butterworth has no real poles for even order)
const a1 = -2 * z[0], a2 = z[0] * z[0] + z[1] * z[1];
const g = (1 + a1 + a2) / 4;
sos.push([g, 2 * g, g, a1, a2]);
}
return sos;
}
// 2nd-order (prototype) band-pass = 2 biquads, matches scipy.signal.butter(2, [lo, hi], 'band', output='sos')
function butterBandpassSos2(lo, hi, sr) {
const wl = 4 * Math.tan((Math.PI * (lo / (sr / 2))) / 2), wh = 4 * Math.tan((Math.PI * (hi / (sr / 2))) / 2);
const bw = wh - wl, wo = Math.sqrt(wl * wh);
const p = butterPoles(2)[1]; // upper-half-plane prototype pole
const plp = [(p[0] * bw) / 2, (p[1] * bw) / 2];
const disc = csqrt([plp[0] * plp[0] - plp[1] * plp[1] - wo * wo, 2 * plp[0] * plp[1]]);
const q = [[plp[0] + disc[0], plp[1] + disc[1]], [plp[0] - disc[0], plp[1] - disc[1]]];
// gain: k = bw^2 (lp2bp) * 16 / prod(4 - p_bp) over all 4 poles (= |4 - q1|^2 |4 - q2|^2 with conjugates)
let denom = 1;
for (const qq of q) denom *= (4 - qq[0]) * (4 - qq[0]) + qq[1] * qq[1];
const k = (bw * bw * 16) / denom;
const sos = [];
q.forEach((qq, i) => {
const z = bilinear(qq);
const a1 = -2 * z[0], a2 = z[0] * z[0] + z[1] * z[1];
const g = i === 0 ? k : 1;
sos.push([g, 0, -g, a1, a2]); // zeros at z = +1 and z = -1 in each section
});
return sos;
}
// ---------------------------------------------------------------- dynamic band processing
function envelope(x, sr, attack = 0.002, release = 0.07) {
const ea = Math.exp(-1 / (sr * attack)), er = Math.exp(-1 / (sr * release));
const env = new Float32Array(x.length);
let e = 0;
for (let i = 0; i < x.length; i++) {
const ax = Math.abs(x[i]);
const c = ax > e ? ea : er;
e = c * e + (1 - c) * ax;
env[i] = e;
}
return env;
}
function bandDynamic(x, sr, lo, hi, amount, thresh = 0.02, maxRed = 0.8) {
if (amount <= 0) return x;
const sos = butterBandpassSos2(lo, Math.min(hi, sr / 2 - 100), sr);
const band = Float32Array.from(sosfilt(sos, x));
const env = envelope(band, sr);
const out = new Float32Array(x.length), m = amount / 100;
for (let i = 0; i < x.length; i++) {
const red = m * clip((env[i] - thresh) / Math.max(env[i], 1e-4), 0, maxRed);
out[i] = x[i] - band[i] * red;
}
return out;
}
// ---------------------------------------------------------------- granular layers, room, gate
function grains(x, sr, sizeS, mix, detuneCents = 0, seed = 0) {
if (mix <= 0) return x;
const rng = makeRng(seed);
const size = Math.floor(sr * sizeS), hop = size >> 1;
const win = hanningSym(size);
const n = x.length, out = new Float64Array(n + size);
const g = new Float64Array(size);
for (let start = 0; start < n - size; start += hop) {
for (let i = 0; i < size; i++) g[i] = x[start + i] * win[i];
if (detuneCents) {
const r = Math.pow(2, rng.uniform(-detuneCents, detuneCents) / 1200);
const g2 = new Float32Array(size);
for (let i = 0; i < size; i++) g2[i] = interp1(g, i * r, 0) * win[i];
for (let i = 0; i < size; i++) out[start + i] += g2[i];
} else {
for (let i = 0; i < size; i++) out[start + i] += g[i];
}
}
const y = new Float32Array(n);
for (let i = 0; i < n; i++) y[i] = (1 - mix) * x[i] + mix * out[i];
return y;
}
function fftConvolve(x, h, nOut) {
const N = nextPow2(x.length + h.length - 1);
const xr = new Float64Array(N), xi = new Float64Array(N), hr = new Float64Array(N), hi = new Float64Array(N);
xr.set(x); hr.set(h);
fft(xr, xi, false); fft(hr, hi, false);
for (let i = 0; i < N; i++) {
const r = xr[i] * hr[i] - xi[i] * hi[i], im = xr[i] * hi[i] + xi[i] * hr[i];
xr[i] = r; xi[i] = im;
}
fft(xr, xi, true);
const out = new Float64Array(nOut);
for (let i = 0; i < nOut; i++) out[i] = xr[i] / N;
return out;
}
function room(x, sr, mix, tailS = 0.22, seed = 1) {
if (mix <= 0) return x;
const rng = makeRng(seed);
const n = Math.floor(sr * tailS), ir = new Float64Array(n);
const tau = (sr * tailS) / 4;
for (let i = 0; i < n; i++) ir[i] = rng.normal() * Math.exp(-i / tau);
ir[0] = 1;
let e = 0;
for (let i = 0; i < n; i++) e += ir[i] * ir[i];
const s = 1 / Math.sqrt(e);
for (let i = 0; i < n; i++) ir[i] *= s;
const wet = fftConvolve(x, ir, x.length);
const y = new Float32Array(x.length);
for (let i = 0; i < x.length; i++) y[i] = (1 - 0.5 * mix) * x[i] + mix * 0.6 * Math.fround(wet[i]);
return y;
}
// downward expander (pause gate), see effects._gate
function gate(x, sr, depthDb, ratio = 4.0, floorDb = -40.0, attack = 0.005, release = 0.06) {
const hop = Math.floor(0.005 * sr), n = x.length;
if (n < 4 * hop) return x;
const nFrames = Math.ceil((n + 1) / hop); // sliding windows of 2*hop over pad(x, hop) taken every hop
const env = new Float64Array(nFrames);
let peak = 0;
for (let f = 0; f < nFrames; f++) {
let s = 0;
const start = f * hop - hop;
for (let i = 0; i < 2 * hop; i++) { const j = start + i; if (j >= 0 && j < n) s += x[j] * x[j]; }
env[f] = Math.sqrt(s / (2 * hop) + 1e-12);
if (env[f] > peak) peak = env[f];
}
if (peak <= 0) return x;
const thr = -Math.abs(depthDb);
const gainDb = new Float64Array(nFrames);
for (let f = 0; f < nFrames; f++) {
const envDb = 20 * Math.log10(env[f] / peak);
gainDb[f] = Math.max(Math.min(0, (envDb - thr) * (ratio - 1)), floorDb);
}
const aAtt = Math.exp(-hop / (attack * sr)), aRel = Math.exp(-hop / (release * sr));
const gain = new Float64Array(nFrames);
let g = gainDb[0];
for (let f = 0; f < nFrames; f++) {
const v = gainDb[f], c = v > g ? aAtt : aRel;
g = c * g + (1 - c) * v;
gain[f] = Math.pow(10, g / 20);
}
const y = new Float32Array(n);
for (let i = 0; i < n; i++) y[i] = x[i] * interp1(gain, i / hop);
return y;
}
// ---------------------------------------------------------------- the chain
export function applyChain(wav, sr, params) {
const p = { ...DEFAULTS };
for (const k in params || {}) if (k in DEFAULTS) p[k] = Number(params[k]);
let x = wav instanceof Float32Array ? wav : Float32Array.from(wav);
if (Math.abs(p.speed - 1) > 1e-3) x = timeStretch(x, p.speed);
if (Math.abs(p.pitch) > 1e-6) x = pitchShift(x, sr, p.pitch);
x = formant(x, sr, p.formant);
x = shelf(x, sr, 250, p.warmth, false);
x = shelf(x, sr, 3500, p.presence, true);
x = bandDynamic(x, sr, 1200, 4000, p.dynamic);
x = bandDynamic(x, sr, 5000, Math.min(9000, sr / 2 - 200), p.deess, 0.01);
if (p.drive > 0) {
const k = 1 + (p.drive / 100) * 7, tk = Math.tanh(k);
x = x.map((v) => Math.tanh(k * v) / tk);
}
if (p.saturation > 0) {
const k = 1 + (p.saturation / 100) * 7, tk = Math.tanh(k), t15 = Math.tanh(0.15);
x = x.map((v) => (Math.tanh(k * v + 0.15) - t15) / tk);
}
if (p.robot > 0) {
const m = p.robot / 100, y = new Float32Array(x.length);
for (let i = 0; i < x.length; i++) {
const ring = x[i] * Math.fround(Math.sin((2 * Math.PI * 42 * i) / sr));
y[i] = (1 - m) * x[i] + m * ring * 1.6;
}
x = y;
}
x = grains(x, sr, 0.05, (p.micro / 100) * 0.8, 85);
x = grains(x, sr, 0.035, p.halo / 100);
if (p.chorus > 0) {
const d = Math.floor(0.018 * sr), n = x.length, m = (p.chorus / 100) * 0.5, y = new Float32Array(n);
for (let t = 0; t < n; t++) {
const mod = d + 0.003 * sr * Math.sin((2 * Math.PI * 0.27 * t) / sr);
const wet = Math.fround(interp1(x, clip(t - mod, 0, n - 1)));
y[t] = (1 - m) * x[t] + m * wet;
}
x = y;
}
if (p.echo > 0) {
const d = Math.floor(0.115 * sr), n = x.length, fb = p.echo / 100;
const y = Float32Array.from(x);
for (let i = 1; i < 4; i++) {
const g = Math.pow(fb, i);
if (g < 0.01) break;
for (let j = i * d; j < n; j++) y[j] += g * x[j - i * d];
}
x = y;
}
x = room(x, sr, p.room / 100);
if (p.gate > 0) x = gate(x, sr, p.gate);
if (p.lowpass < 10900 && p.lowpass < sr / 2) x = sosfiltfilt(butterLowpassSos(8, p.lowpass, sr), x);
if (Math.abs(p.volume) > 1e-6) {
const g = Math.pow(10, p.volume / 20);
x = x.map((v) => v * g);
}
let peak = 0;
for (let i = 0; i < x.length; i++) { const a = Math.abs(x[i]); if (a > peak) peak = a; }
if (peak > 0.98) x = x.map((v) => (v / peak) * 0.98);
return x instanceof Float32Array ? x : Float32Array.from(x);
}
export default { EFFECTS, DEFAULTS, PRESET, UI_DEFAULTS, applyChain };