/ proof-of-concept / phase-perception-2
Sign in
proof-of-concept / phase-perception-2
phase-perception-2 / src / worker.ts
148 lines · 5.8 KBBlameHistoryRaw
1/**
2 * Web Worker: renders the phase-rotated sound B from the source A, and the
3 * curves and images shown in the app. One message in, one message out.
4 */
5import { applyFilter, applyTimeVarying, bandCenters, effectiveFilter, groupDelay, sigmaF, spectrogram, type Spectrogram } from './bank'
6import { nextPow2 } from './fft'
7import { fieldAt, fieldRow, isTimeVarying, maxGroupDelaySec, phaseMap, randomField, sampleRow, wrapAngle } from './phasemap'
8import { peak, rms } from './sources'
9import type { Curve, ImageData2D, RenderRequest, RenderResult } from './types'
11/** Sample an array defined on FFT bins onto `points` evenly spaced frequencies in [0, fmax]. */
12function sampleCurve(values: ArrayLike<number>, binHz: number, fmax: number, points: number): Curve {
13 const f = new Float32Array(points)
14 const y = new Float32Array(points)
15 for (let i = 0; i < points; i++) {
16 const fi = (fmax * i) / (points - 1)
17 const b = Math.min(values.length - 1, Math.round(fi / binHz))
18 f[i] = fi
19 y[i] = values[b]
20 }
21 return { f, y }
24function toDb(s: Spectrogram, ref: number): ImageData2D {
25 const db = new Float32Array(s.data.length)
26 for (let i = 0; i < db.length; i++) db[i] = 20 * Math.log10(Math.max(s.data[i], 1e-12) / ref)
27 return { data: db, rows: s.rows, cols: s.cols, durationSec: s.durationSec, fmax: s.fmax }
30const FIELD_ROWS = 160
31const FIELD_COLS = 320
33function render(req: RenderRequest): RenderResult {
34 const t0 = performance.now()
35 const { samples: x, fs, fmax } = req
36 const n = x.length
37 const sigma = req.sigmaMs / 1000
38 const centers = bandCenters(fs, sigma)
39 const df = sigmaF(sigma)
40 const kMax = Math.min(centers.length - 1, Math.floor(fmax / df))
41 const timeVarying = isTimeVarying(req.map)
43 let y: Float32Array
44 let phiRef: Float64Array // the map used for the response plots
45 let field: ImageData2D
46 let L: number
47 if (timeVarying && req.map.kind === 'random') {
48 // group delay of a smoothed random map stays below the window width, so the padding need not depend on it
49 const padSec = 10 * sigma + 0.05
50 const pad = Math.ceil(padSec * fs)
51 L = nextPow2(n + 2 * pad)
52 const rf = randomField(centers.length, L / fs, req.map.smoothBands, req.map.timeScaleMs! / 1000, req.map.seed, req.map.strength)
53 const rows = new Map<number, Float64Array>()
54 const rowFor = (k: number) => {
55 let r = rows.get(k)
56 if (!r) {
57 r = fieldRow(rf, k)
58 rows.set(k, r)
59 }
60 return r
61 }
62 y = applyTimeVarying(x, fs, sigma, centers, padSec, (k, times) => sampleRow(rowFor(k), rf.dT, times, new Float64Array(times.length)), rf.maxRateHz)
63 const tMid = (pad + n / 2) / fs
64 phiRef = new Float64Array(centers.length)
65 for (let k = 0; k < centers.length; k++) phiRef[k] = fieldAt(rf, k, tMid)
66 // the field over the clip, for display
67 const fr = Math.min(FIELD_ROWS, kMax + 1)
68 const data = new Float32Array(fr * FIELD_COLS)
69 const times = new Float64Array(FIELD_COLS)
70 for (let c = 0; c < FIELD_COLS; c++) times[c] = (pad + ((c + 0.5) * n) / FIELD_COLS) / fs
71 const tmp = new Float64Array(FIELD_COLS)
72 for (let r = 0; r < fr; r++) {
73 const k = Math.round((r * kMax) / Math.max(1, fr - 1))
74 sampleRow(rowFor(k), rf.dT, times, tmp)
75 for (let c = 0; c < FIELD_COLS; c++) data[r * FIELD_COLS + c] = wrapAngle(tmp[c])
76 }
77 field = { data, rows: fr, cols: FIELD_COLS, durationSec: n / fs, fmax: kMax * df }
78 } else {
79 const phi = phaseMap(req.map, centers, fs)
80 const padSec = 8 * sigma + maxGroupDelaySec(phi, df) + 0.02
81 const out = applyFilter(x, fs, sigma, phi, centers, padSec)
82 y = out.y
83 phiRef = phi
84 L = Math.round(fs / out.g.binHz)
85 const fr = Math.min(FIELD_ROWS, kMax + 1)
86 const data = new Float32Array(fr)
87 for (let r = 0; r < fr; r++) data[r] = wrapAngle(phi[Math.round((r * kMax) / Math.max(1, fr - 1))])
88 field = { data, rows: fr, cols: 1, durationSec: n / fs, fmax: kMax * df }
89 }
90 const g = effectiveFilter(centers, phiRef, sigma, L, fs)
92 // shared gain: target RMS from A, but never let either sound clip
93 const pk = Math.max(peak(x), peak(y))
94 const r = rms(x)
95 const gain = pk > 0 && r > 0 ? Math.min(0.9 / pk, 0.2 / r) : 0
97 let d = 0
98 let e = 0
99 for (let i = 0; i < n; i++) {
100 d += (y[i] - x[i]) ** 2
101 e += x[i] * x[i]
102 }
103 const relDiff = e > 0 ? Math.sqrt(d / e) : 0
105 const nb = g.re.length
106 const magDb = new Float64Array(nb)
107 for (let i = 0; i < nb; i++) magDb[i] = 20 * Math.log10(Math.max(Math.hypot(g.re[i], g.im[i]), 1e-9))
108 const tau = groupDelay(g)
109 const tauMs = new Float64Array(nb)
110 for (let i = 0; i < nb; i++) tauMs[i] = tau[i] * 1000
111 let maxGd = 0
112 let maxMagDev = 0
113 const lastBin = Math.min(nb - 1, Math.round(fmax / g.binHz))
114 for (let i = 0; i <= lastBin; i++) {
115 maxGd = Math.max(maxGd, Math.abs(tauMs[i]))
116 maxMagDev = Math.max(maxMagDev, Math.abs(magDb[i]))
117 }
119 const sA = spectrogram(x, fs, sigma, fmax, req.maxCols, req.maxRows)
120 const sB = spectrogram(y, fs, sigma, fmax, req.maxCols, req.maxRows)
121 let ref = 1e-12
122 for (let i = 0; i < sA.data.length; i++) if (sA.data[i] > ref) ref = sA.data[i]
123 for (let i = 0; i < sB.data.length; i++) if (sB.data[i] > ref) ref = sB.data[i]
125 return {
126 id: req.id,
127 y,
128 gain,
129 relDiff,
130 bands: centers.length,
131 sigmaF: df,
132 timeVarying,
133 phaseField: field,
134 magDb: sampleCurve(magDb, g.binHz, fmax, req.curvePoints),
135 groupDelayMs: sampleCurve(tauMs, g.binHz, fmax, req.curvePoints),
136 maxGroupDelayMs: maxGd,
137 maxMagDeviationDb: maxMagDev,
138 specA: toDb(sA, ref),
139 specB: toDb(sB, ref),
140 elapsedMs: performance.now() - t0,
141 }
144self.onmessage = (e: MessageEvent<RenderRequest>) => {
145 const res = render(e.data)
146 const transfer = [res.y.buffer, res.specA.data.buffer, res.specB.data.buffer, res.phaseField.data.buffer]
147 ;(self as unknown as Worker).postMessage(res, transfer)
↑↓ move↵openescclose