Spaces:
Running
Running
Download effects.js from machinekind/matcha-tts-pl: direct link, hf CLI and curl.
- Browser
- Download file 28.4 kB
-
https://huggingface.co/spaces/machinekind/matcha-tts-pl/resolve/main/effects.js
- Command line
-
hf download hf://spaces/machinekind/matcha-tts-pl/effects.js
-
curl -L -o effects.js https://huggingface.co/spaces/machinekind/matcha-tts-pl/resolve/main/effects.js
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 }; | |