/** * Web Worker: renders the phase-rotated sound B from the source A, and the * curves and images shown in the app. One message in, one message out. */ import { applyFilter, applyTimeVarying, bandCenters, effectiveFilter, groupDelay, sigmaF, spectrogram, type Spectrogram } from './bank' import { nextPow2 } from './fft' import { fieldAt, fieldRow, isTimeVarying, maxGroupDelaySec, phaseMap, randomField, sampleRow, wrapAngle } from './phasemap' import { peak, rms } from './sources' import type { Curve, ImageData2D, RenderRequest, RenderResult } from './types' /** Sample an array defined on FFT bins onto `points` evenly spaced frequencies in [0, fmax]. */ function sampleCurve(values: ArrayLike, binHz: number, fmax: number, points: number): Curve { const f = new Float32Array(points) const y = new Float32Array(points) for (let i = 0; i < points; i++) { const fi = (fmax * i) / (points - 1) const b = Math.min(values.length - 1, Math.round(fi / binHz)) f[i] = fi y[i] = values[b] } return { f, y } } function toDb(s: Spectrogram, ref: number): ImageData2D { const db = new Float32Array(s.data.length) for (let i = 0; i < db.length; i++) db[i] = 20 * Math.log10(Math.max(s.data[i], 1e-12) / ref) return { data: db, rows: s.rows, cols: s.cols, durationSec: s.durationSec, fmax: s.fmax } } const FIELD_ROWS = 160 const FIELD_COLS = 320 function render(req: RenderRequest): RenderResult { const t0 = performance.now() const { samples: x, fs, fmax } = req const n = x.length const sigma = req.sigmaMs / 1000 const centers = bandCenters(fs, sigma) const df = sigmaF(sigma) const kMax = Math.min(centers.length - 1, Math.floor(fmax / df)) const timeVarying = isTimeVarying(req.map) let y: Float32Array let phiRef: Float64Array // the map used for the response plots let field: ImageData2D let L: number if (timeVarying && req.map.kind === 'random') { // group delay of a smoothed random map stays below the window width, so the padding need not depend on it const padSec = 10 * sigma + 0.05 const pad = Math.ceil(padSec * fs) L = nextPow2(n + 2 * pad) const rf = randomField(centers.length, L / fs, req.map.smoothBands, req.map.timeScaleMs! / 1000, req.map.seed, req.map.strength) const rows = new Map() const rowFor = (k: number) => { let r = rows.get(k) if (!r) { r = fieldRow(rf, k) rows.set(k, r) } return r } y = applyTimeVarying(x, fs, sigma, centers, padSec, (k, times) => sampleRow(rowFor(k), rf.dT, times, new Float64Array(times.length)), rf.maxRateHz) const tMid = (pad + n / 2) / fs phiRef = new Float64Array(centers.length) for (let k = 0; k < centers.length; k++) phiRef[k] = fieldAt(rf, k, tMid) // the field over the clip, for display const fr = Math.min(FIELD_ROWS, kMax + 1) const data = new Float32Array(fr * FIELD_COLS) const times = new Float64Array(FIELD_COLS) for (let c = 0; c < FIELD_COLS; c++) times[c] = (pad + ((c + 0.5) * n) / FIELD_COLS) / fs const tmp = new Float64Array(FIELD_COLS) for (let r = 0; r < fr; r++) { const k = Math.round((r * kMax) / Math.max(1, fr - 1)) sampleRow(rowFor(k), rf.dT, times, tmp) for (let c = 0; c < FIELD_COLS; c++) data[r * FIELD_COLS + c] = wrapAngle(tmp[c]) } field = { data, rows: fr, cols: FIELD_COLS, durationSec: n / fs, fmax: kMax * df } } else { const phi = phaseMap(req.map, centers, fs) const padSec = 8 * sigma + maxGroupDelaySec(phi, df) + 0.02 const out = applyFilter(x, fs, sigma, phi, centers, padSec) y = out.y phiRef = phi L = Math.round(fs / out.g.binHz) const fr = Math.min(FIELD_ROWS, kMax + 1) const data = new Float32Array(fr) for (let r = 0; r < fr; r++) data[r] = wrapAngle(phi[Math.round((r * kMax) / Math.max(1, fr - 1))]) field = { data, rows: fr, cols: 1, durationSec: n / fs, fmax: kMax * df } } const g = effectiveFilter(centers, phiRef, sigma, L, fs) // shared gain: target RMS from A, but never let either sound clip const pk = Math.max(peak(x), peak(y)) const r = rms(x) const gain = pk > 0 && r > 0 ? Math.min(0.9 / pk, 0.2 / r) : 0 let d = 0 let e = 0 for (let i = 0; i < n; i++) { d += (y[i] - x[i]) ** 2 e += x[i] * x[i] } const relDiff = e > 0 ? Math.sqrt(d / e) : 0 const nb = g.re.length const magDb = new Float64Array(nb) for (let i = 0; i < nb; i++) magDb[i] = 20 * Math.log10(Math.max(Math.hypot(g.re[i], g.im[i]), 1e-9)) const tau = groupDelay(g) const tauMs = new Float64Array(nb) for (let i = 0; i < nb; i++) tauMs[i] = tau[i] * 1000 let maxGd = 0 let maxMagDev = 0 const lastBin = Math.min(nb - 1, Math.round(fmax / g.binHz)) for (let i = 0; i <= lastBin; i++) { maxGd = Math.max(maxGd, Math.abs(tauMs[i])) maxMagDev = Math.max(maxMagDev, Math.abs(magDb[i])) } const sA = spectrogram(x, fs, sigma, fmax, req.maxCols, req.maxRows) const sB = spectrogram(y, fs, sigma, fmax, req.maxCols, req.maxRows) let ref = 1e-12 for (let i = 0; i < sA.data.length; i++) if (sA.data[i] > ref) ref = sA.data[i] for (let i = 0; i < sB.data.length; i++) if (sB.data[i] > ref) ref = sB.data[i] return { id: req.id, y, gain, relDiff, bands: centers.length, sigmaF: df, timeVarying, phaseField: field, magDb: sampleCurve(magDb, g.binHz, fmax, req.curvePoints), groupDelayMs: sampleCurve(tauMs, g.binHz, fmax, req.curvePoints), maxGroupDelayMs: maxGd, maxMagDeviationDb: maxMagDev, specA: toDb(sA, ref), specB: toDb(sB, ref), elapsedMs: performance.now() - t0, } } self.onmessage = (e: MessageEvent) => { const res = render(e.data) const transfer = [res.y.buffer, res.specA.data.buffer, res.specB.data.buffer, res.phaseField.data.buffer] ;(self as unknown as Worker).postMessage(res, transfer) }