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