Phase perception 2: subband phase rotation of arbitrary sounds with filter bank, phase maps, plots, and playback
32 changed files+5609−0
.gitignoreadded+24−0View file
@@ -0,0 +1,24 @@
1+# Logs
2+logs
3+*.log
4+npm-debug.log*
5+yarn-debug.log*
6+yarn-error.log*
7+pnpm-debug.log*
8+lerna-debug.log*
9+
10+node_modules
11+dist
12+dist-ssr
13+*.local
14+
15+# Editor directories and files
16+.vscode/*
17+!.vscode/extensions.json
18+.idea
19+.DS_Store
20+*.suo
21+*.ntvs*
22+*.njsproj
23+*.sln
24+*.sw?
README.mdadded+28−0View file
@@ -0,0 +1,28 @@
1+# Phase Perception 2
2+
3+A web app for testing whether a sound still sounds the same when the phases of its frequency components are changed while their envelopes are kept. It extends [phase-perception](../phase-perception/), which asked the same question of steady tones with a fixed set of harmonics, to arbitrary sounds: clicks, noise bursts, plucked strings, loaded audio files, and microphone recordings.
4+
5+## What it does
6+
7+The source sound A is split into overlapping Gaussian frequency bands, the short-time Fourier transform viewed as a filter bank with a hop of one sample. Band k has a Gaussian response of width sigma_f = 1 / (2 pi sigma) centered at f_k = k sigma_f, where sigma is the window width in time, and the responses are normalized to sum to one so that adding the bands back together reproduces the signal exactly. Sound B is
8+
9+ B(t) = Re sum_k exp(i phi_k) z_k(t)
10+
11+where z_k is the complex subband signal and phi_k is the phase map evaluated at the band center. Every subband envelope |z_k(t)| is unchanged, so A and B have the same spectrogram at the analysis resolution, but their waveforms differ. Because phi_k does not depend on time, the operation is a linear time-invariant filter with response G(f) = sum_k exp(i phi_k) H_k(f), and the app computes it that way, with one FFT pair, in a Web Worker.
12+
13+The controls are the window width sigma (0.3 ms to 50 ms), the phase map (smoothed random with adjustable smoothing and strength, a pure delay as a control, or a chirp whose group delay grows linearly with frequency), and the source. The app plots the map, the magnitude response and group delay of the equivalent filter, the waveforms of A and B with a zoom, and the spectrograms of A and B on a shared scale. A and B can be played singly or in sequence with the same gain.
14+
15+The design and the reasoning behind it are described in [docs/spectrogram-phase-proposal.md](../phase-perception/docs/spectrogram-phase-proposal.md) in the first app's repository.
16+
17+## Two constraints worth knowing
18+
19+The phase map must change slowly from one band to the next. Neighbouring bands overlap, and where their phases disagree their contributions partly cancel, which puts notches in the magnitude response. The app reports the largest magnitude deviation and warns above 1 dB. For a pure delay of tau the magnitude is exp(-tau^2 / (2 sigma^2)), so delays are limited to a fraction of the window width. In general the group delay that any map can impose without changing magnitudes is bounded by roughly sigma, which is why the window width is the parameter that controls how much transients are smeared.
20+
21+Group delay peaks beyond roughly 1 ms have been reported to be audible on impulsive sounds over headphones (Blauert and Laws, 1978), and steady sounds are far less sensitive. The expectation is therefore that phase maps are inaudible on sustained sounds for any window and on transients for short windows, and audible as a softening of transients for windows of several milliseconds and beyond.
22+
23+## Running
24+
25+ npm install
26+ npm run dev
27+
28+Then open the printed URL. `npm run build` produces a static site in `dist/` with a relative base path. Microphone recording needs a secure context (https or localhost).
eslint.config.jsadded+23−0View file
@@ -0,0 +1,23 @@
1+import js from '@eslint/js'
2+import globals from 'globals'
3+import reactHooks from 'eslint-plugin-react-hooks'
4+import reactRefresh from 'eslint-plugin-react-refresh'
5+import tseslint from 'typescript-eslint'
6+import { defineConfig, globalIgnores } from 'eslint/config'
7+
8+export default defineConfig([
9+ globalIgnores(['dist']),
10+ {
11+ files: ['**/*.{ts,tsx}'],
12+ extends: [
13+ js.configs.recommended,
14+ tseslint.configs.recommended,
15+ reactHooks.configs['recommended-latest'],
16+ reactRefresh.configs.vite,
17+ ],
18+ languageOptions: {
19+ ecmaVersion: 2020,
20+ globals: globals.browser,
21+ },
22+ },
23+])
index.htmladded+14−0View file
@@ -0,0 +1,14 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+ <meta name="description" content="A perceptual test: keep the subband envelopes of a sound, rotate its subband phases, and listen for a difference." />
8+ <title>Phase Perception 2</title>
9+ </head>
10+ <body>
11+ <div id="root"></div>
12+ <script type="module" src="/src/main.tsx"></script>
13+ </body>
14+</html>
package-lock.jsonadded+3327−0View file
This diff is 3,332 lines long and is not shown.
package.jsonadded+31−0View file
@@ -0,0 +1,31 @@
1+{
2+ "name": "phase-perception-2",
3+ "private": true,
4+ "version": "0.1.0",
5+ "type": "module",
6+ "description": "Can you hear the phase? Rotate the subband phases of any sound while keeping its subband envelopes, and listen",
7+ "scripts": {
8+ "dev": "vite",
9+ "build": "tsc -b && vite build",
10+ "lint": "eslint .",
11+ "preview": "vite preview"
12+ },
13+ "dependencies": {
14+ "react": "^19.1.1",
15+ "react-dom": "^19.1.1"
16+ },
17+ "devDependencies": {
18+ "@eslint/js": "^9.36.0",
19+ "@types/node": "^24.6.0",
20+ "@types/react": "^19.1.16",
21+ "@types/react-dom": "^19.1.9",
22+ "@vitejs/plugin-react": "^5.0.4",
23+ "eslint": "^9.36.0",
24+ "eslint-plugin-react-hooks": "^5.2.0",
25+ "eslint-plugin-react-refresh": "^0.4.22",
26+ "globals": "^16.4.0",
27+ "typescript": "~5.9.3",
28+ "typescript-eslint": "^8.45.0",
29+ "vite": "^7.1.7"
30+ }
31+}
public/favicon.svgadded+1−0View file
@@ -0,0 +1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#1f5fa8"/><path d="M3 16c3-10 6-10 9 0s6 10 9 0 6-10 8 0" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/></svg>
src/App.tsxadded+101−0View file
@@ -0,0 +1,101 @@
1+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2+import { getSampleRate, playSequence, stopAll } from './audio'
3+import { AnalysisPanel } from './components/AnalysisPanel'
4+import { PlaybackPanel } from './components/PlaybackPanel'
5+import { SourcePanel } from './components/SourcePanel'
6+import { defaultPhaseMap, type PhaseMapSpec } from './phasemap'
7+import { Renderer } from './renderer'
8+import { makePreset, presets } from './sources'
9+import type { RenderResult, Source } from './types'
10+
11+const RENDER_DEBOUNCE_MS = 120
12+const MAX_COLS = 600
13+const MAX_ROWS = 256
14+const CURVE_POINTS = 1024
15+
16+export default function App() {
17+ const fs = useMemo(() => getSampleRate(), [])
18+ const fmax = Math.min(10000, fs / 2)
19+ const [source, setSource] = useState<Source | null>(() => makePreset(presets[4], fs))
20+ const [sigmaMs, setSigmaMs] = useState(5)
21+ const [map, setMap] = useState<PhaseMapSpec>(defaultPhaseMap)
22+ const [result, setResult] = useState<RenderResult | null>(null)
23+ const [rendering, setRendering] = useState(false)
24+ const [error, setError] = useState<string | null>(null)
25+ const [playing, setPlaying] = useState<string | null>(null)
26+ const renderer = useMemo(() => new Renderer(), [])
27+ const resultSource = useRef<Source | null>(null)
28+
29+ useEffect(() => () => renderer.terminate(), [renderer])
30+
31+ // Re-render B whenever the source, the window, or the map changes (debounced for slider drags).
32+ useEffect(() => {
33+ if (!source) return
34+ setRendering(true)
35+ const timer = setTimeout(() => {
36+ renderer
37+ .request({ samples: source.samples, fs: source.fs, sigmaMs, map, fmax, maxCols: MAX_COLS, maxRows: MAX_ROWS, curvePoints: CURVE_POINTS })
38+ .then((r) => {
39+ if (!r) return // superseded
40+ resultSource.current = source
41+ setResult(r)
42+ setError(null)
43+ setRendering(false)
44+ })
45+ .catch((err: unknown) => {
46+ setError(err instanceof Error ? err.message : String(err))
47+ setRendering(false)
48+ })
49+ }, RENDER_DEBOUNCE_MS)
50+ return () => clearTimeout(timer)
51+ }, [source, sigmaMs, map, fmax, renderer])
52+
53+ const play = useCallback(
54+ async (label: string, which: ('A' | 'B')[]) => {
55+ const src = resultSource.current
56+ if (!src || !result) return
57+ const g = result.gain
58+ const scaled = (x: Float32Array) => x.map((v) => v * g)
59+ const buffers = which.map((w) => scaled(w === 'A' ? src.samples : result.y))
60+ setPlaying(label)
61+ try {
62+ await playSequence(buffers)
63+ } finally {
64+ setPlaying(null)
65+ }
66+ },
67+ [result],
68+ )
69+
70+ // Show plots for the source the current result belongs to, even while a new render is pending.
71+ const shownSource = resultSource.current
72+
73+ return (
74+ <div className="app">
75+ <header className="app-header">
76+ <h1>Phase Perception 2</h1>
77+ <p>
78+ The first app asked whether a steady tone still sounds the same when its harmonic phases change. Here the same question is asked of
79+ any sound. The sound is split into overlapping frequency bands, each band's phase is rotated by an amount that depends on frequency,
80+ and the bands are added back together. Every band keeps its envelope, so the spectrogram at that resolution is unchanged, but the
81+ waveform is not. The width of the bands is a free parameter: narrow bands (a long window) allow large group delays and smear
82+ transients, wide bands (a short window) do not.
83+ </p>
84+ </header>
85+
86+ <SourcePanel fs={fs} source={source} setSource={setSource} busy={playing !== null} />
87+ <AnalysisPanel sigmaMs={sigmaMs} setSigmaMs={setSigmaMs} map={map} setMap={setMap} fmax={fmax} result={result} />
88+ {error && <div className="card error">Rendering failed: {error}</div>}
89+ {result && shownSource && (
90+ <PlaybackPanel source={shownSource} result={result} stale={rendering} playing={playing} play={(l, w) => void play(l, w)} stop={stopAll} />
91+ )}
92+ {!result && !error && <div className="card status">Rendering…</div>}
93+
94+ <footer className="app-footer">
95+ {result && `Last render took ${result.elapsedMs.toFixed(0)} ms. `}
96+ Playback uses one gain for A and B, chosen from A's RMS and limited so that neither sound clips. Clips are mono, at the audio
97+ device's sample rate of {fs} Hz.
98+ </footer>
99+ </div>
100+ )
101+}
src/audio.tsadded+69−0View file
@@ -0,0 +1,69 @@
1+/** Thin wrapper over the Web Audio API for playing pre-rendered sample buffers. */
2+
3+let ctx: AudioContext | null = null
4+const active = new Set<AudioBufferSourceNode>()
5+let currentFinish: (() => void) | null = null
6+
7+export function getAudioContext(): AudioContext {
8+ if (!ctx) ctx = new AudioContext()
9+ if (ctx.state === 'suspended') void ctx.resume()
10+ return ctx
11+}
12+
13+export function getSampleRate(): number {
14+ return getAudioContext().sampleRate
15+}
16+
17+/** Stop anything currently playing and resolve its promise. */
18+export function stopAll(): void {
19+ for (const s of active) {
20+ try {
21+ s.stop()
22+ } catch {
23+ // already stopped
24+ }
25+ }
26+ active.clear()
27+ const f = currentFinish
28+ currentFinish = null
29+ f?.()
30+}
31+
32+/**
33+ * Play the buffers one after another with a silent gap between them.
34+ * Resolves when the last buffer ends (or when stopAll() is called).
35+ */
36+export function playSequence(buffers: Float32Array[], gapSec = 0.4): Promise<void> {
37+ const ac = getAudioContext()
38+ stopAll()
39+ let t = ac.currentTime + 0.05
40+ const sources: AudioBufferSourceNode[] = []
41+ for (const samples of buffers) {
42+ const buf = ac.createBuffer(1, samples.length, ac.sampleRate)
43+ buf.getChannelData(0).set(samples)
44+ const src = ac.createBufferSource()
45+ src.buffer = buf
46+ src.connect(ac.destination)
47+ src.start(t)
48+ active.add(src)
49+ sources.push(src)
50+ t += buf.duration + gapSec
51+ }
52+ return new Promise<void>((resolve) => {
53+ const last = sources[sources.length - 1]
54+ if (!last) {
55+ resolve()
56+ return
57+ }
58+ let done = false
59+ const finish = () => {
60+ if (done) return
61+ done = true
62+ for (const s of sources) active.delete(s)
63+ if (currentFinish === finish) currentFinish = null
64+ resolve()
65+ }
66+ currentFinish = finish
67+ last.onended = finish
68+ })
69+}
src/bank.tsadded+168−0View file
@@ -0,0 +1,168 @@
1+/**
2+ * Gaussian filter bank: the short-time Fourier transform with a hop of one
3+ * sample, viewed as a bank of complex bandpass filters.
4+ *
5+ * Band k has center f_k = k * df and frequency response
6+ *
7+ * H_k(f) = W(f - f_k) / S(f), W(f) = exp(-f^2 / (2 sigma_f^2)), S(f) = sum_j W(f - f_j)
8+ *
9+ * where sigma_f = 1 / (2 pi sigma) is the spectral width of a Gaussian window
10+ * of time width sigma. The responses sum to one on f >= 0, so the subbands
11+ * z_k = x * h_k add back to the signal: x = Re sum_k z_k.
12+ *
13+ * Rotating every subband by a constant angle phi_k and adding them back is a
14+ * linear time-invariant filter with response G(f) = sum_k exp(i phi_k) H_k(f).
15+ * We therefore compute G on the FFT grid and apply it with one FFT pair,
16+ * which is exact and much cheaper than synthesizing the subbands one by one.
17+ */
18+import { fftPlan, nextPow2 } from './fft'
19+
20+export const sigmaF = (sigmaSec: number): number => 1 / (2 * Math.PI * sigmaSec)
21+
22+/** Band centers 0, df, 2 df, ... up to the Nyquist frequency, with df = sigma_f. */
23+export function bandCenters(fs: number, sigmaSec: number): Float64Array {
24+ const df = sigmaF(sigmaSec)
25+ const k = Math.ceil(fs / 2 / df)
26+ const c = new Float64Array(k + 1)
27+ for (let i = 0; i <= k; i++) c[i] = i * df
28+ return c
29+}
30+
31+export type Response = { re: Float64Array; im: Float64Array; binHz: number }
32+
33+/** G(f) on the bins 0..L/2 of an FFT of length L at sample rate fs. */
34+export function effectiveFilter(centers: Float64Array, phi: ArrayLike<number>, sigmaSec: number, L: number, fs: number): Response {
35+ const sf = sigmaF(sigmaSec)
36+ const binHz = fs / L
37+ const nb = L / 2 + 1
38+ const S = new Float64Array(nb)
39+ const re = new Float64Array(nb)
40+ const im = new Float64Array(nb)
41+ const reach = 5 * sf
42+ const inv2s2 = 1 / (2 * sf * sf)
43+ for (let k = 0; k < centers.length; k++) {
44+ const fc = centers[k]
45+ const c = Math.cos(phi[k])
46+ const s = Math.sin(phi[k])
47+ const i0 = Math.max(0, Math.ceil((fc - reach) / binHz))
48+ const i1 = Math.min(nb - 1, Math.floor((fc + reach) / binHz))
49+ for (let i = i0; i <= i1; i++) {
50+ const d = i * binHz - fc
51+ const w = Math.exp(-d * d * inv2s2)
52+ S[i] += w
53+ re[i] += c * w
54+ im[i] += s * w
55+ }
56+ }
57+ for (let i = 0; i < nb; i++) {
58+ if (S[i] > 0) {
59+ re[i] /= S[i]
60+ im[i] /= S[i]
61+ }
62+ }
63+ return { re, im, binHz }
64+}
65+
66+/** Group delay in seconds, -(d/d omega) arg G, by finite differences between neighbouring bins. */
67+export function groupDelay(g: Response): Float64Array {
68+ const n = g.re.length
69+ const tau = new Float64Array(n)
70+ for (let i = 0; i + 1 < n; i++) {
71+ // angle of G[i+1] * conj(G[i]), already wrapped to (-pi, pi]
72+ const re = g.re[i + 1] * g.re[i] + g.im[i + 1] * g.im[i]
73+ const im = g.im[i + 1] * g.re[i] - g.re[i + 1] * g.im[i]
74+ tau[i] = -Math.atan2(im, re) / (2 * Math.PI * g.binHz)
75+ }
76+ tau[n - 1] = tau[n - 2]
77+ return tau
78+}
79+
80+/**
81+ * Apply the filter G to x. The signal is placed in the middle of a zero-padded
82+ * buffer of length L so that the circular convolution does not wrap; padSec
83+ * must cover the window tails plus the largest group delay.
84+ */
85+export function applyFilter(x: Float32Array, fs: number, sigmaSec: number, phi: ArrayLike<number>, centers: Float64Array, padSec: number): { y: Float32Array; g: Response } {
86+ const n = x.length
87+ const pad = Math.ceil(padSec * fs)
88+ const L = nextPow2(n + 2 * pad)
89+ const g = effectiveFilter(centers, phi, sigmaSec, L, fs)
90+ const re = new Float64Array(L)
91+ const im = new Float64Array(L)
92+ re.set(x, pad)
93+ const plan = fftPlan(L)
94+ plan.transform(re, im)
95+ const half = L / 2
96+ // Y = X G on the positive half; the negative half is its conjugate mirror so y is real.
97+ for (let i = 1; i < half; i++) {
98+ const xr = re[i]
99+ const xi = im[i]
100+ const yr = xr * g.re[i] - xi * g.im[i]
101+ const yi = xr * g.im[i] + xi * g.re[i]
102+ re[i] = yr
103+ im[i] = yi
104+ re[L - i] = yr
105+ im[L - i] = -yi
106+ }
107+ re[0] *= g.re[0]
108+ im[0] = 0
109+ re[half] *= g.re[half]
110+ im[half] = 0
111+ plan.transform(re, im, true)
112+ const y = new Float32Array(n)
113+ for (let i = 0; i < n; i++) y[i] = re[pad + i]
114+ return { y, g }
115+}
116+
117+export type Spectrogram = {
118+ /** rows x cols, row-major, row 0 at 0 Hz; linear magnitude */
119+ data: Float32Array
120+ rows: number
121+ cols: number
122+ durationSec: number
123+ fmax: number
124+}
125+
126+/**
127+ * Magnitude spectrogram with a Gaussian window of the given sigma, sampled on
128+ * a grid of at most maxCols columns and maxRows rows (bins are max-pooled into
129+ * rows). This is |z_k(t)| of the bank above, up to the normalization by S.
130+ */
131+export function spectrogram(x: Float32Array, fs: number, sigmaSec: number, fmax: number, maxCols: number, maxRows: number): Spectrogram {
132+ const n = x.length
133+ const ss = sigmaSec * fs
134+ const half = Math.ceil(4 * ss)
135+ const wlen = 2 * half + 1
136+ const N = nextPow2(Math.max(wlen, 2048))
137+ const hop = Math.max(Math.ceil(n / maxCols), Math.ceil(ss / 2), 1)
138+ const cols = Math.ceil(n / hop)
139+ const w = new Float64Array(wlen)
140+ for (let j = 0; j < wlen; j++) w[j] = Math.exp(-0.5 * ((j - half) / ss) ** 2)
141+ const binHz = fs / N
142+ const nb = Math.min(N / 2, Math.floor(fmax / binHz))
143+ const rows = Math.min(maxRows, nb + 1)
144+ const data = new Float32Array(rows * cols)
145+ const plan = fftPlan(N)
146+ const re = new Float64Array(N)
147+ const im = new Float64Array(N)
148+ const mag = new Float64Array(nb + 1)
149+ for (let c = 0; c < cols; c++) {
150+ const center = c * hop
151+ re.fill(0)
152+ im.fill(0)
153+ for (let j = 0; j < wlen; j++) {
154+ const t = center - half + j
155+ if (t >= 0 && t < n) re[j] = x[t] * w[j]
156+ }
157+ plan.transform(re, im)
158+ for (let i = 0; i <= nb; i++) mag[i] = Math.hypot(re[i], im[i])
159+ for (let r = 0; r < rows; r++) {
160+ const b0 = Math.floor((r * (nb + 1)) / rows)
161+ const b1 = Math.max(b0 + 1, Math.floor(((r + 1) * (nb + 1)) / rows))
162+ let m = 0
163+ for (let i = b0; i < b1 && i <= nb; i++) if (mag[i] > m) m = mag[i]
164+ data[r * cols + c] = m
165+ }
166+ }
167+ return { data, rows, cols, durationSec: n / fs, fmax: rows * ((nb + 1) / rows) * binHz }
168+}
src/capture.tsadded+71−0View file
@@ -0,0 +1,71 @@
1+/** Clips from audio files and from the microphone. */
2+import { getAudioContext } from './audio'
3+import { toMono } from './sources'
4+import recorderUrl from './recorder-worklet.ts?worker&url'
5+
6+export type Clip = { name: string; fs: number; samples: Float32Array }
7+
8+/** Decode a file to mono at the audio context's sample rate. */
9+export async function decodeFile(file: File): Promise<Clip> {
10+ const ctx = getAudioContext()
11+ const buf = await ctx.decodeAudioData(await file.arrayBuffer())
12+ return { name: file.name, fs: buf.sampleRate, samples: toMono(buf) }
13+}
14+
15+let workletReady: Promise<void> | null = null
16+
17+/**
18+ * Record raw PCM from the microphone for up to `seconds`. Processing such as
19+ * echo cancellation and automatic gain is turned off where the browser allows.
20+ * Resolves early if `stop` is triggered.
21+ */
22+export async function recordMicrophone(seconds: number, stop: { signal: AbortSignal }, onLevel?: (secondsSoFar: number) => void): Promise<Clip> {
23+ const ctx = getAudioContext()
24+ await ctx.resume()
25+ if (!workletReady) {
26+ workletReady = ctx.audioWorklet.addModule(recorderUrl).catch((err: unknown) => {
27+ workletReady = null
28+ throw err
29+ })
30+ }
31+ await workletReady
32+ const stream = await navigator.mediaDevices.getUserMedia({
33+ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: 1 },
34+ })
35+ const src = ctx.createMediaStreamSource(stream)
36+ const node = new AudioWorkletNode(ctx, 'pcm-recorder', { numberOfInputs: 1, numberOfOutputs: 0 })
37+ const chunks: Float32Array[] = []
38+ let total = 0
39+ const limit = Math.round(seconds * ctx.sampleRate)
40+ return new Promise<Clip>((resolve, reject) => {
41+ let done = false
42+ const finish = () => {
43+ if (done) return
44+ done = true
45+ src.disconnect()
46+ node.port.onmessage = null
47+ for (const t of stream.getTracks()) t.stop()
48+ const out = new Float32Array(Math.min(total, limit))
49+ let at = 0
50+ for (const c of chunks) {
51+ const n = Math.min(c.length, out.length - at)
52+ if (n <= 0) break
53+ out.set(c.subarray(0, n), at)
54+ at += n
55+ }
56+ resolve({ name: 'Microphone recording', fs: ctx.sampleRate, samples: out })
57+ }
58+ node.port.onmessage = (e: MessageEvent<Float32Array>) => {
59+ chunks.push(e.data)
60+ total += e.data.length
61+ onLevel?.(total / ctx.sampleRate)
62+ if (total >= limit) finish()
63+ }
64+ stop.signal.addEventListener('abort', finish, { once: true })
65+ try {
66+ src.connect(node)
67+ } catch (err) {
68+ reject(err instanceof Error ? err : new Error(String(err)))
69+ }
70+ })
71+}
src/components/AnalysisPanel.tsxadded+158−0View file
@@ -0,0 +1,158 @@
1+import type { PhaseMapSpec } from '../phasemap'
2+import type { RenderResult } from '../types'
3+import { LinePlot } from './LinePlot'
4+
5+type Props = {
6+ sigmaMs: number
7+ setSigmaMs: (v: number) => void
8+ map: PhaseMapSpec
9+ setMap: (m: PhaseMapSpec) => void
10+ fmax: number
11+ result: RenderResult | null
12+}
13+
14+const SIGMA_LOG_MIN = Math.log10(0.3)
15+const SIGMA_LOG_MAX = Math.log10(50)
16+const sig3 = (x: number) => Number(x.toPrecision(3))
17+
18+const MAP_COLOR = '#d9641e'
19+const THRESHOLD_MS = 1
20+
21+/** Window width, phase map choice and parameters, and the plots that describe the resulting filter. */
22+export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }: Props) {
23+ const sigmaF = 1000 / (2 * Math.PI * sigmaMs)
24+ const setKind = (kind: PhaseMapSpec['kind']) => {
25+ if (kind === map.kind) return
26+ if (kind === 'random') setMap({ kind, smoothBands: 8, seed: 1, strength: 1 })
27+ else if (kind === 'delay') setMap({ kind, delayMs: sig3(sigmaMs / 4) })
28+ else setMap({ kind, delayAtNyquistMs: sig3(sigmaMs / 4) })
29+ }
30+
31+ return (
32+ <section className="card">
33+ <h2>Filter bank and phase map</h2>
34+ <p className="hint">
35+ The sound is split into overlapping Gaussian bands of width sigma in time (and 1 / (2 pi sigma) in frequency). Sound B is made by
36+ rotating the phase of each band by phi(f) and adding the bands back together, which leaves every band's envelope unchanged. For a
37+ fixed map this is an all-pass filter; its group delay, minus the slope of the phase, is what smears transients. Peaks of group delay
38+ beyond about {THRESHOLD_MS} ms are reported to be audible on impulsive sounds. Because the map is sampled at the band centers, it
39+ must change by less than about a radian from one band to the next; the magnitude plot shows when it does not. For a pure delay this
40+ means delays shorter than sigma, so the achievable group delay grows with the window width.
41+ </p>
42+ <div className="global-controls">
43+ <label>
44+ <span>Window sigma</span>
45+ <input
46+ type="range"
47+ className="wide-range"
48+ min={SIGMA_LOG_MIN}
49+ max={SIGMA_LOG_MAX}
50+ step={0.01}
51+ value={Math.min(SIGMA_LOG_MAX, Math.max(SIGMA_LOG_MIN, Math.log10(sigmaMs)))}
52+ onChange={(e) => setSigmaMs(sig3(10 ** Number(e.target.value)))}
53+ />
54+ <input
55+ type="number"
56+ className="slider-value"
57+ min={0.1}
58+ max={200}
59+ step="any"
60+ value={sigmaMs}
61+ onChange={(e) => {
62+ const v = Number(e.target.value)
63+ if (Number.isFinite(v) && v > 0) setSigmaMs(Math.min(200, Math.max(0.1, v)))
64+ }}
65+ />
66+ <span className="slider-unit">ms</span>
67+ <span className="info">
68+ band width {sig3(sigmaF)} Hz{result ? `, ${result.bands} bands` : ''}
69+ </span>
70+ </label>
71+ </div>
72+ <div className="global-controls">
73+ <label>
74+ <span>Phase map</span>
75+ <select value={map.kind} onChange={(e) => setKind(e.target.value as PhaseMapSpec['kind'])}>
76+ <option value="random">Smoothed random</option>
77+ <option value="delay">Pure delay (control)</option>
78+ <option value="chirp">Chirp (group delay linear in frequency)</option>
79+ </select>
80+ </label>
81+ {map.kind === 'random' && (
82+ <>
83+ <label>
84+ <span>Smoothing</span>
85+ <input type="range" min={1} max={40} step={1} value={map.smoothBands} onChange={(e) => setMap({ ...map, smoothBands: Number(e.target.value) })} />
86+ <span className="slider-unit">
87+ {map.smoothBands} bands ({sig3(map.smoothBands * sigmaF)} Hz)
88+ </span>
89+ </label>
90+ <label>
91+ <span>Strength</span>
92+ <input type="range" min={0} max={2} step={0.05} value={map.strength} onChange={(e) => setMap({ ...map, strength: Number(e.target.value) })} />
93+ <span className="slider-unit">{map.strength.toFixed(2)}</span>
94+ </label>
95+ <button onClick={() => setMap({ ...map, seed: (map.seed % 100000) + 1 })}>New random map</button>
96+ <span className="info">seed {map.seed}</span>
97+ </>
98+ )}
99+ {map.kind === 'delay' && (
100+ <label>
101+ <span>Delay</span>
102+ <input type="range" min={-20} max={20} step={0.05} value={map.delayMs} onChange={(e) => setMap({ ...map, delayMs: Number(e.target.value) })} />
103+ <input type="number" className="slider-value" step="any" value={map.delayMs} onChange={(e) => setMap({ ...map, delayMs: Number(e.target.value) || 0 })} />
104+ <span className="slider-unit">ms</span>
105+ </label>
106+ )}
107+ {map.kind === 'chirp' && (
108+ <label>
109+ <span>Group delay at Nyquist</span>
110+ <input type="range" min={-20} max={20} step={0.05} value={map.delayAtNyquistMs} onChange={(e) => setMap({ ...map, delayAtNyquistMs: Number(e.target.value) })} />
111+ <input type="number" className="slider-value" step="any" value={map.delayAtNyquistMs} onChange={(e) => setMap({ ...map, delayAtNyquistMs: Number(e.target.value) || 0 })} />
112+ <span className="slider-unit">ms</span>
113+ </label>
114+ )}
115+ </div>
116+
117+ {result && result.maxMagDeviationDb > 1 && (
118+ <div className="warning">
119+ The magnitude response deviates by up to {result.maxMagDeviationDb.toFixed(1)} dB, so B is not just a phase change of A. The map
120+ changes too quickly across neighbouring bands for this window: increase sigma, increase the smoothing, or reduce the delay.
121+ </div>
122+ )}
123+ {result && (
124+ <div className="response-plots">
125+ <div>
126+ <div className="plot-label">Phase map phi(f), wrapped to (-180°, 180°]</div>
127+ <LinePlot
128+ series={[{ x: result.map.f, y: Array.from(result.map.y, (v) => (v * 180) / Math.PI), color: MAP_COLOR, dots: result.map.f.length <= 400, width: 1 }]}
129+ xRange={[0, fmax]}
130+ yRange={[-190, 190]}
131+ xLabel="frequency (Hz)"
132+ yLabel="degrees"
133+ zeroLine
134+ />
135+ </div>
136+ <div>
137+ <div className="plot-label">Magnitude response of the equivalent filter (should stay near 0 dB)</div>
138+ <LinePlot series={[{ x: result.magDb.f, y: result.magDb.y, color: '#1f5fa8' }]} xRange={[0, fmax]} yRange={[-30, 3]} xLabel="frequency (Hz)" yLabel="dB" zeroLine />
139+ </div>
140+ <div>
141+ <div className="plot-label">
142+ Group delay of the equivalent filter (largest {result.maxGroupDelayMs.toFixed(2)} ms)
143+ </div>
144+ <LinePlot
145+ series={[{ x: result.groupDelayMs.f, y: result.groupDelayMs.y, color: '#1f5fa8' }]}
146+ xRange={[0, fmax]}
147+ symmetric
148+ xLabel="frequency (Hz)"
149+ yLabel="ms"
150+ band={{ y0: -THRESHOLD_MS, y1: THRESHOLD_MS, color: 'rgba(46, 160, 67, 0.15)', label: 'approximate audibility threshold' }}
151+ zeroLine
152+ />
153+ </div>
154+ </div>
155+ )}
156+ </section>
157+ )
158+}
src/components/LinePlot.tsxadded+170−0View file
@@ -0,0 +1,170 @@
1+import { useCallback } from 'react'
2+import { useCanvas } from '../hooks/useCanvas'
3+import { AXIS, FONT, FRAME, GRID, formatTick, niceTicks } from '../plot'
4+
5+export type Series = {
6+ x: ArrayLike<number>
7+ y: ArrayLike<number>
8+ color: string
9+ width?: number
10+ /** draw points instead of a line */
11+ dots?: boolean
12+}
13+
14+type Props = {
15+ series: Series[]
16+ xRange: [number, number]
17+ /** fixed y range; otherwise fitted to the data, symmetric if `symmetric` */
18+ yRange?: [number, number]
19+ symmetric?: boolean
20+ xLabel?: string
21+ yLabel?: string
22+ /** a shaded horizontal band, e.g. a threshold */
23+ band?: { y0: number; y1: number; color: string; label?: string }
24+ zeroLine?: boolean
25+ className?: string
26+}
27+
28+const M = { l: 48, r: 12, t: 8, b: 30 }
29+
30+/** A plain x-y plot with ticks, an optional shaded band, and a zero line. */
31+export function LinePlot({ series, xRange, yRange, symmetric, xLabel, yLabel, band, zeroLine, className }: Props) {
32+ const draw = useCallback(
33+ (ctx: CanvasRenderingContext2D, w: number, h: number) => {
34+ const pw = w - M.l - M.r
35+ const ph = h - M.t - M.b
36+ let [y0, y1] = yRange ?? [Infinity, -Infinity]
37+ if (!yRange) {
38+ for (const s of series) for (let i = 0; i < s.y.length; i++) {
39+ const v = s.y[i]
40+ if (!Number.isFinite(v)) continue
41+ if (v < y0) y0 = v
42+ if (v > y1) y1 = v
43+ }
44+ if (band) {
45+ y0 = Math.min(y0, band.y0)
46+ y1 = Math.max(y1, band.y1)
47+ }
48+ if (!(y1 > y0)) {
49+ y0 = Number.isFinite(y0) ? y0 - 1 : -1
50+ y1 = Number.isFinite(y1) ? y1 + 1 : 1
51+ }
52+ if (symmetric) {
53+ const m = Math.max(Math.abs(y0), Math.abs(y1))
54+ y0 = -m
55+ y1 = m
56+ }
57+ const padY = (y1 - y0) * 0.06
58+ y0 -= padY
59+ y1 += padY
60+ }
61+ const [x0, x1] = xRange
62+ const toX = (v: number) => M.l + ((v - x0) / (x1 - x0)) * pw
63+ const toY = (v: number) => M.t + ph - ((v - y0) / (y1 - y0)) * ph
64+
65+ ctx.font = FONT
66+ ctx.lineWidth = 1
67+
68+ if (band) {
69+ ctx.fillStyle = band.color
70+ const by0 = Math.max(M.t, Math.min(M.t + ph, toY(band.y1)))
71+ const by1 = Math.max(M.t, Math.min(M.t + ph, toY(band.y0)))
72+ ctx.fillRect(M.l, by0, pw, by1 - by0)
73+ }
74+
75+ ctx.fillStyle = AXIS
76+ ctx.strokeStyle = GRID
77+ ctx.textAlign = 'right'
78+ ctx.textBaseline = 'middle'
79+ for (const v of niceTicks(y0, y1, 4)) {
80+ const y = Math.round(toY(v)) + 0.5
81+ if (y < M.t || y > M.t + ph) continue
82+ ctx.beginPath()
83+ ctx.moveTo(M.l, y)
84+ ctx.lineTo(M.l + pw, y)
85+ ctx.stroke()
86+ ctx.fillText(formatTick(v), M.l - 5, y)
87+ }
88+ ctx.textAlign = 'center'
89+ ctx.textBaseline = 'top'
90+ for (const v of niceTicks(x0, x1, 6)) {
91+ const x = Math.round(toX(v)) + 0.5
92+ if (x < M.l || x > M.l + pw) continue
93+ ctx.beginPath()
94+ ctx.moveTo(x, M.t)
95+ ctx.lineTo(x, M.t + ph)
96+ ctx.stroke()
97+ ctx.fillText(formatTick(v), x, M.t + ph + 4)
98+ }
99+ if (zeroLine && y0 < 0 && y1 > 0) {
100+ ctx.strokeStyle = '#b8bcc2'
101+ const y = Math.round(toY(0)) + 0.5
102+ ctx.beginPath()
103+ ctx.moveTo(M.l, y)
104+ ctx.lineTo(M.l + pw, y)
105+ ctx.stroke()
106+ }
107+ ctx.strokeStyle = FRAME
108+ ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph)
109+
110+ if (xLabel) {
111+ ctx.textAlign = 'center'
112+ ctx.textBaseline = 'bottom'
113+ ctx.fillText(xLabel, M.l + pw / 2, h - 2)
114+ }
115+ if (yLabel) {
116+ ctx.save()
117+ ctx.translate(11, M.t + ph / 2)
118+ ctx.rotate(-Math.PI / 2)
119+ ctx.textAlign = 'center'
120+ ctx.textBaseline = 'middle'
121+ ctx.fillText(yLabel, 0, 0)
122+ ctx.restore()
123+ }
124+ if (band?.label) {
125+ ctx.fillStyle = '#2f7d32'
126+ ctx.textAlign = 'right'
127+ ctx.textBaseline = 'bottom'
128+ ctx.fillText(band.label, M.l + pw - 4, Math.max(M.t + 12, toY(band.y1)) - 2)
129+ }
130+
131+ ctx.save()
132+ ctx.beginPath()
133+ ctx.rect(M.l, M.t, pw, ph)
134+ ctx.clip()
135+ for (const s of series) {
136+ ctx.strokeStyle = s.color
137+ ctx.fillStyle = s.color
138+ ctx.lineWidth = s.width ?? 1.5
139+ ctx.lineJoin = 'round'
140+ if (s.dots) {
141+ for (let i = 0; i < s.x.length; i++) {
142+ ctx.beginPath()
143+ ctx.arc(toX(s.x[i]), toY(s.y[i]), 1.8, 0, 2 * Math.PI)
144+ ctx.fill()
145+ }
146+ } else {
147+ ctx.beginPath()
148+ let pen = false
149+ for (let i = 0; i < s.x.length; i++) {
150+ const v = s.y[i]
151+ if (!Number.isFinite(v)) {
152+ pen = false
153+ continue
154+ }
155+ const px = toX(s.x[i])
156+ const py = toY(v)
157+ if (!pen) ctx.moveTo(px, py)
158+ else ctx.lineTo(px, py)
159+ pen = true
160+ }
161+ ctx.stroke()
162+ }
163+ }
164+ ctx.restore()
165+ },
166+ [series, xRange, yRange, symmetric, xLabel, yLabel, band, zeroLine],
167+ )
168+ const ref = useCanvas(draw)
169+ return <canvas ref={ref} className={`plot ${className ?? 'line-plot'}`} />
170+}
src/components/PlaybackPanel.tsxadded+97−0View file
@@ -0,0 +1,97 @@
1+import { useState } from 'react'
2+import { formatSeconds } from '../plot'
3+import type { RenderResult, Source } from '../types'
4+import { SpectrogramPlot } from './SpectrogramPlot'
5+import { WaveformPlot } from './WaveformPlot'
6+
7+type Props = {
8+ source: Source
9+ result: RenderResult
10+ stale: boolean
11+ playing: string | null
12+ play: (label: string, which: ('A' | 'B')[]) => void
13+ stop: () => void
14+}
15+
16+const COLOR_A = '#1f5fa8'
17+const COLOR_B = '#d9641e'
18+const SPANS: { label: string; sec: number | null }[] = [
19+ { label: 'full clip', sec: null },
20+ { label: '500 ms', sec: 0.5 },
21+ { label: '100 ms', sec: 0.1 },
22+ { label: '20 ms', sec: 0.02 },
23+ { label: '5 ms', sec: 0.005 },
24+]
25+const DB_RANGE = 70
26+
27+/** Play A and B, and compare their waveforms and spectrograms. */
28+export function PlaybackPanel({ source, result, stale, playing, play, stop }: Props) {
29+ const [spanIdx, setSpanIdx] = useState(0)
30+ const [center, setCenter] = useState(0.5)
31+ const fs = source.fs
32+ const duration = source.samples.length / fs
33+ const span = SPANS[spanIdx].sec ?? duration
34+ const start = SPANS[spanIdx].sec ? Math.min(Math.max(0, center * duration - span / 2), Math.max(0, duration - span)) : 0
35+ let yMax = 0
36+ for (let i = 0; i < source.samples.length; i++) yMax = Math.max(yMax, Math.abs(source.samples[i]))
37+ for (let i = 0; i < result.y.length; i++) yMax = Math.max(yMax, Math.abs(result.y[i]))
38+ yMax = Number(yMax.toPrecision(2)) || 1
39+ const busy = playing !== null
40+
41+ return (
42+ <section className="card">
43+ <h2>Listen and compare</h2>
44+ <p className="hint">
45+ A is the source; B has the phase map applied. Both are played with the same gain{stale ? ' (B is being re-rendered)' : ''}. The
46+ waveforms differ by {Math.round(100 * result.relDiff)}% of A's RMS, while the spectrograms below, computed at the analysis
47+ resolution, should look the same.
48+ </p>
49+ <div className="button-row">
50+ <button className="primary" onClick={() => play('A', ['A'])} disabled={busy || stale}>
51+ Play A
52+ </button>
53+ <button className="primary" onClick={() => play('B', ['B'])} disabled={busy || stale}>
54+ Play B
55+ </button>
56+ <button className="primary" onClick={() => play('A then B', ['A', 'B'])} disabled={busy || stale}>
57+ Play A then B
58+ </button>
59+ <button className="primary" onClick={() => play('B then A', ['B', 'A'])} disabled={busy || stale}>
60+ Play B then A
61+ </button>
62+ <button onClick={stop} disabled={!busy}>
63+ Stop
64+ </button>
65+ <span className="playing-indicator">{playing ? `Playing ${playing}…` : ''}</span>
66+ </div>
67+
68+ <div className="zoom-controls">
69+ <label>
70+ <span>Waveform window</span>
71+ <select value={spanIdx} onChange={(e) => setSpanIdx(Number(e.target.value))}>
72+ {SPANS.map((s, i) => (
73+ <option key={s.label} value={i}>
74+ {s.label}
75+ </option>
76+ ))}
77+ </select>
78+ </label>
79+ {SPANS[spanIdx].sec !== null && (
80+ <label className="grow">
81+ <span>Position</span>
82+ <input type="range" min={0} max={1} step={0.0005} value={center} onChange={(e) => setCenter(Number(e.target.value))} />
83+ <span className="slider-unit">{formatSeconds(start)}</span>
84+ </label>
85+ )}
86+ </div>
87+ <WaveformPlot samples={source.samples} fs={fs} startSec={start} spanSec={span} yMax={yMax} label="A" color={COLOR_A} />
88+ <WaveformPlot samples={result.y} fs={fs} startSec={start} spanSec={span} yMax={yMax} label="B" color={COLOR_B} />
89+
90+ <div className="plot-label">Spectrograms at the analysis resolution, {DB_RANGE} dB range, shared scale</div>
91+ <div className="spectrograms">
92+ <SpectrogramPlot spec={result.specA} dbRange={DB_RANGE} label="A" color="#fff" />
93+ <SpectrogramPlot spec={result.specB} dbRange={DB_RANGE} label="B" color="#fff" />
94+ </div>
95+ </section>
96+ )
97+}
src/components/SourcePanel.tsxadded+116−0View file
@@ -0,0 +1,116 @@
1+import { useEffect, useRef, useState } from 'react'
2+import { decodeFile, recordMicrophone, type Clip } from '../capture'
3+import { MAX_SECONDS, excerpt, makePreset, presets } from '../sources'
4+import type { Source } from '../types'
5+
6+type Props = {
7+ fs: number
8+ source: Source | null
9+ setSource: (s: Source) => void
10+ busy: boolean
11+}
12+
13+/** Choose the source sound: a synthesized preset, a file, or a microphone recording. */
14+export function SourcePanel({ fs, source, setSource, busy }: Props) {
15+ const [clip, setClip] = useState<Clip | null>(null)
16+ const [startSec, setStartSec] = useState(0)
17+ const [recording, setRecording] = useState<number | null>(null)
18+ const [error, setError] = useState<string | null>(null)
19+ const abortRef = useRef<AbortController | null>(null)
20+ const fileRef = useRef<HTMLInputElement>(null)
21+
22+ const clipSec = clip ? clip.samples.length / clip.fs : 0
23+ const canScrub = clip !== null && clipSec > MAX_SECONDS
24+
25+ // A loaded clip becomes the source through its excerpt.
26+ useEffect(() => {
27+ if (!clip) return
28+ setSource({ name: clip.name, fs: clip.fs, samples: excerpt(clip.samples, clip.fs, startSec) })
29+ // eslint-disable-next-line react-hooks/exhaustive-deps
30+ }, [clip, startSec])
31+
32+ const choosePreset = (i: number) => {
33+ setClip(null)
34+ setError(null)
35+ setSource(makePreset(presets[i], fs))
36+ }
37+
38+ const onFile = async (file: File | undefined) => {
39+ if (!file) return
40+ setError(null)
41+ try {
42+ const c = await decodeFile(file)
43+ setStartSec(0)
44+ setClip(c)
45+ } catch (err) {
46+ setError(`Could not decode ${file.name}: ${err instanceof Error ? err.message : String(err)}`)
47+ }
48+ }
49+
50+ const record = async () => {
51+ setError(null)
52+ const ac = new AbortController()
53+ abortRef.current = ac
54+ setRecording(0)
55+ try {
56+ const c = await recordMicrophone(MAX_SECONDS, ac, (s) => setRecording(s))
57+ setStartSec(0)
58+ setClip(c)
59+ } catch (err) {
60+ setError(`Recording failed: ${err instanceof Error ? err.message : String(err)}`)
61+ } finally {
62+ setRecording(null)
63+ abortRef.current = null
64+ }
65+ }
66+
67+ return (
68+ <section className="card">
69+ <h2>Source sound</h2>
70+ <p className="hint">
71+ Pick a synthesized test sound, load an audio file, or record from the microphone. Clips are mono and limited to {MAX_SECONDS} s.
72+ Transient sounds (clicks, bursts, plucks) are where phase changes are expected to be audible; steady sounds are where they are not.
73+ </p>
74+ <div className="button-row">
75+ {presets.map((p, i) => (
76+ <button key={p.name} className={source?.name === p.name ? 'selected' : ''} onClick={() => choosePreset(i)} disabled={busy || recording !== null}>
77+ {p.name}
78+ </button>
79+ ))}
80+ <span className="divider" />
81+ <button onClick={() => fileRef.current?.click()} disabled={busy || recording !== null}>
82+ Load file…
83+ </button>
84+ <input ref={fileRef} type="file" accept="audio/*" hidden onChange={(e) => void onFile(e.target.files?.[0])} />
85+ {recording === null ? (
86+ <button onClick={() => void record()} disabled={busy}>
87+ Record {MAX_SECONDS} s from microphone
88+ </button>
89+ ) : (
90+ <button className="primary" onClick={() => abortRef.current?.abort()}>
91+ Stop recording ({recording.toFixed(1)} s)
92+ </button>
93+ )}
94+ </div>
95+ {canScrub && clip && (
96+ <label className="scrub">
97+ <span>Excerpt start</span>
98+ <input type="range" min={0} max={Math.max(0, clipSec - MAX_SECONDS)} step={0.01} value={startSec} onChange={(e) => setStartSec(Number(e.target.value))} />
99+ <span className="slider-unit">
100+ {startSec.toFixed(2)} s of {clipSec.toFixed(1)} s
101+ </span>
102+ </label>
103+ )}
104+ <div className="source-info">
105+ {source ? (
106+ <>
107+ <strong>{source.name}</strong>: {(source.samples.length / source.fs).toFixed(2)} s at {source.fs} Hz
108+ </>
109+ ) : (
110+ 'No source selected.'
111+ )}
112+ </div>
113+ {error && <div className="error">{error}</div>}
114+ </section>
115+ )
116+}
src/components/SpectrogramPlot.tsxadded+91−0View file
@@ -0,0 +1,91 @@
1+import { useCallback, useMemo } from 'react'
2+import { useCanvas } from '../hooks/useCanvas'
3+import { AXIS, FONT, FRAME, formatTick, magma, niceTicks } from '../plot'
4+import type { SpectrogramData } from '../types'
5+
6+type Props = {
7+ spec: SpectrogramData
8+ /** dB below the shared maximum shown as black */
9+ dbRange: number
10+ label: string
11+ color: string
12+}
13+
14+const M = { l: 48, r: 12, t: 8, b: 26 }
15+
16+/** Draws a dB spectrogram as an image with time and frequency axes. */
17+export function SpectrogramPlot({ spec, dbRange, label, color }: Props) {
18+ // Rasterize once per spectrogram, then scale to the canvas on every draw.
19+ const image = useMemo(() => {
20+ const { rows, cols, db } = spec
21+ const off = document.createElement('canvas')
22+ off.width = cols
23+ off.height = rows
24+ const ictx = off.getContext('2d')
25+ if (!ictx) return null
26+ const img = ictx.createImageData(cols, rows)
27+ const d = img.data
28+ for (let r = 0; r < rows; r++) {
29+ const yImg = rows - 1 - r // row 0 (0 Hz) at the bottom
30+ for (let c = 0; c < cols; c++) {
31+ const v = db[r * cols + c]
32+ const [R, G, B] = magma(1 + v / dbRange)
33+ const o = 4 * (yImg * cols + c)
34+ d[o] = R
35+ d[o + 1] = G
36+ d[o + 2] = B
37+ d[o + 3] = 255
38+ }
39+ }
40+ ictx.putImageData(img, 0, 0)
41+ return off
42+ }, [spec, dbRange])
43+
44+ const draw = useCallback(
45+ (ctx: CanvasRenderingContext2D, w: number, h: number) => {
46+ const pw = w - M.l - M.r
47+ const ph = h - M.t - M.b
48+ ctx.font = FONT
49+ if (image) {
50+ ctx.imageSmoothingEnabled = false
51+ ctx.drawImage(image, M.l, M.t, pw, ph)
52+ }
53+ ctx.strokeStyle = FRAME
54+ ctx.lineWidth = 1
55+ ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph)
56+
57+ ctx.fillStyle = AXIS
58+ ctx.textAlign = 'right'
59+ ctx.textBaseline = 'middle'
60+ for (const v of niceTicks(0, spec.fmax, 4)) {
61+ if (v > spec.fmax) continue
62+ const y = M.t + ph - (v / spec.fmax) * ph
63+ ctx.fillText(formatTick(v), M.l - 5, y)
64+ }
65+ ctx.textAlign = 'center'
66+ ctx.textBaseline = 'top'
67+ for (const v of niceTicks(0, spec.durationSec, 6)) {
68+ if (v > spec.durationSec) continue
69+ const x = M.l + (v / spec.durationSec) * pw
70+ ctx.fillText(formatTick(v), x, M.t + ph + 4)
71+ }
72+ ctx.textBaseline = 'bottom'
73+ ctx.fillText('time (s)', M.l + pw / 2, h - 1)
74+ ctx.save()
75+ ctx.translate(11, M.t + ph / 2)
76+ ctx.rotate(-Math.PI / 2)
77+ ctx.textBaseline = 'middle'
78+ ctx.fillText('Hz', 0, 0)
79+ ctx.restore()
80+
81+ ctx.fillStyle = color
82+ ctx.font = 'bold 12px system-ui, sans-serif'
83+ ctx.textAlign = 'left'
84+ ctx.textBaseline = 'top'
85+ ctx.fillText(label, M.l + 6, M.t + 5)
86+ },
87+ [image, spec, label, color],
88+ )
89+ const ref = useCanvas(draw)
90+ return <canvas ref={ref} className="plot spectrogram-plot" />
91+}
src/components/WaveformPlot.tsxadded+120−0View file
@@ -0,0 +1,120 @@
1+import { useCallback } from 'react'
2+import { useCanvas } from '../hooks/useCanvas'
3+import { AXIS, FONT, FRAME, GRID, formatTick, niceTicks } from '../plot'
4+
5+type Props = {
6+ samples: Float32Array
7+ fs: number
8+ /** start of the visible window in seconds */
9+ startSec: number
10+ /** length of the visible window in seconds */
11+ spanSec: number
12+ yMax: number
13+ label: string
14+ color: string
15+ /** highlight this region (the zoom window) when drawing the full clip */
16+ highlight?: [number, number]
17+}
18+
19+const M = { l: 48, r: 12, t: 6, b: 22 }
20+
21+/** One waveform. Draws a min/max envelope when there are many samples per pixel, a line otherwise. */
22+export function WaveformPlot({ samples, fs, startSec, spanSec, yMax, label, color, highlight }: Props) {
23+ const draw = useCallback(
24+ (ctx: CanvasRenderingContext2D, w: number, h: number) => {
25+ const pw = w - M.l - M.r
26+ const ph = h - M.t - M.b
27+ const ymax = yMax > 0 ? yMax : 1
28+ const y0 = M.t + ph / 2
29+ const toY = (v: number) => y0 - (v / ymax) * (ph / 2)
30+ const toX = (t: number) => M.l + ((t - startSec) / spanSec) * pw
31+
32+ ctx.font = FONT
33+ ctx.lineWidth = 1
34+ if (highlight) {
35+ ctx.fillStyle = 'rgba(31, 95, 168, 0.10)'
36+ const a = Math.max(M.l, toX(highlight[0]))
37+ const b = Math.min(M.l + pw, toX(highlight[1]))
38+ if (b > a) ctx.fillRect(a, M.t, b - a, ph)
39+ }
40+ ctx.strokeStyle = GRID
41+ ctx.fillStyle = AXIS
42+ ctx.textAlign = 'center'
43+ ctx.textBaseline = 'top'
44+ for (const v of niceTicks(startSec, startSec + spanSec, 6)) {
45+ const x = Math.round(toX(v)) + 0.5
46+ if (x < M.l || x > M.l + pw) continue
47+ ctx.beginPath()
48+ ctx.moveTo(x, M.t)
49+ ctx.lineTo(x, M.t + ph)
50+ ctx.stroke()
51+ ctx.fillText(spanSec < 0.5 ? `${formatTick(v * 1000)}` : formatTick(v), x, M.t + ph + 4)
52+ }
53+ ctx.textAlign = 'right'
54+ ctx.textBaseline = 'middle'
55+ ctx.fillText(formatTick(ymax), M.l - 5, toY(ymax))
56+ ctx.fillText('0', M.l - 5, y0)
57+ ctx.fillText(formatTick(-ymax), M.l - 5, toY(-ymax))
58+ ctx.strokeStyle = '#b8bcc2'
59+ ctx.beginPath()
60+ ctx.moveTo(M.l, Math.round(y0) + 0.5)
61+ ctx.lineTo(M.l + pw, Math.round(y0) + 0.5)
62+ ctx.stroke()
63+ ctx.strokeStyle = FRAME
64+ ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph)
65+ ctx.fillStyle = AXIS
66+ ctx.textAlign = 'right'
67+ ctx.textBaseline = 'bottom'
68+ ctx.fillText(spanSec < 0.5 ? 'ms' : 's', M.l + pw, h - 1)
69+
70+ const i0 = Math.max(0, Math.floor(startSec * fs))
71+ const i1 = Math.min(samples.length, Math.ceil((startSec + spanSec) * fs))
72+ const perPx = (i1 - i0) / pw
73+ ctx.save()
74+ ctx.beginPath()
75+ ctx.rect(M.l, M.t, pw, ph)
76+ ctx.clip()
77+ ctx.strokeStyle = color
78+ ctx.fillStyle = color
79+ ctx.lineWidth = 1.2
80+ ctx.lineJoin = 'round'
81+ if (perPx > 2) {
82+ // min/max envelope per pixel column
83+ for (let px = 0; px < pw; px++) {
84+ const a = i0 + Math.floor(px * perPx)
85+ const b = Math.min(i1, i0 + Math.floor((px + 1) * perPx))
86+ if (b <= a) continue
87+ let lo = Infinity
88+ let hi = -Infinity
89+ for (let i = a; i < b; i++) {
90+ const v = samples[i]
91+ if (v < lo) lo = v
92+ if (v > hi) hi = v
93+ }
94+ const yTop = toY(hi)
95+ const yBot = toY(lo)
96+ ctx.fillRect(M.l + px, yTop, 1, Math.max(1, yBot - yTop))
97+ }
98+ } else {
99+ ctx.beginPath()
100+ for (let i = i0; i < i1; i++) {
101+ const px = toX(i / fs)
102+ const py = toY(samples[i])
103+ if (i === i0) ctx.moveTo(px, py)
104+ else ctx.lineTo(px, py)
105+ }
106+ ctx.stroke()
107+ }
108+ ctx.restore()
109+
110+ ctx.fillStyle = color
111+ ctx.font = 'bold 12px system-ui, sans-serif'
112+ ctx.textAlign = 'left'
113+ ctx.textBaseline = 'top'
114+ ctx.fillText(label, M.l + 6, M.t + 4)
115+ },
116+ [samples, fs, startSec, spanSec, yMax, label, color, highlight],
117+ )
118+ const ref = useCanvas(draw)
119+ return <canvas ref={ref} className="plot waveform-plot" />
120+}
src/fft.tsadded+92−0View file
@@ -0,0 +1,92 @@
1+/**
2+ * In-place iterative radix-2 complex FFT on separate real and imaginary
3+ * arrays. Sizes must be powers of two. The inverse transform is scaled by 1/n.
4+ */
5+export class FFT {
6+ readonly n: number
7+ private rev: Uint32Array
8+ private cos: Float64Array
9+ private sin: Float64Array
10+
11+ constructor(n: number) {
12+ if (n < 2 || (n & (n - 1)) !== 0) throw new Error(`FFT size must be a power of two, got ${n}`)
13+ this.n = n
14+ const bits = Math.round(Math.log2(n))
15+ this.rev = new Uint32Array(n)
16+ for (let i = 0; i < n; i++) {
17+ let r = 0
18+ let x = i
19+ for (let b = 0; b < bits; b++) {
20+ r = (r << 1) | (x & 1)
21+ x >>= 1
22+ }
23+ this.rev[i] = r >>> 0
24+ }
25+ this.cos = new Float64Array(n / 2)
26+ this.sin = new Float64Array(n / 2)
27+ for (let i = 0; i < n / 2; i++) {
28+ const a = (-2 * Math.PI * i) / n
29+ this.cos[i] = Math.cos(a)
30+ this.sin[i] = Math.sin(a)
31+ }
32+ }
33+
34+ transform(re: Float64Array, im: Float64Array, inverse = false): void {
35+ const n = this.n
36+ const rev = this.rev
37+ for (let i = 0; i < n; i++) {
38+ const j = rev[i]
39+ if (j > i) {
40+ const tr = re[i]
41+ re[i] = re[j]
42+ re[j] = tr
43+ const ti = im[i]
44+ im[i] = im[j]
45+ im[j] = ti
46+ }
47+ }
48+ const sgn = inverse ? -1 : 1
49+ for (let size = 2; size <= n; size <<= 1) {
50+ const half = size >> 1
51+ const step = n / size
52+ for (let start = 0; start < n; start += size) {
53+ for (let k = 0, tw = 0; k < half; k++, tw += step) {
54+ const wr = this.cos[tw]
55+ const wi = sgn * this.sin[tw]
56+ const i = start + k
57+ const j = i + half
58+ const tr = re[j] * wr - im[j] * wi
59+ const ti = re[j] * wi + im[j] * wr
60+ re[j] = re[i] - tr
61+ im[j] = im[i] - ti
62+ re[i] += tr
63+ im[i] += ti
64+ }
65+ }
66+ }
67+ if (inverse) {
68+ for (let i = 0; i < n; i++) {
69+ re[i] /= n
70+ im[i] /= n
71+ }
72+ }
73+ }
74+}
75+
76+export function nextPow2(n: number): number {
77+ let p = 1
78+ while (p < n) p <<= 1
79+ return p
80+}
81+
82+const cache = new Map<number, FFT>()
83+
84+/** Shared FFT plans, one per size. */
85+export function fftPlan(n: number): FFT {
86+ let p = cache.get(n)
87+ if (!p) {
88+ p = new FFT(n)
89+ cache.set(n, p)
90+ }
91+ return p
92+}
src/hooks/useCanvas.tsadded+34−0View file
@@ -0,0 +1,34 @@
1+import { useEffect, useRef } from 'react'
2+
3+export type DrawFn = (ctx: CanvasRenderingContext2D, width: number, height: number) => void
4+
5+/**
6+ * Returns a ref for a <canvas>. The draw callback runs whenever it changes
7+ * identity (wrap it in useCallback) and whenever the canvas is resized.
8+ * Handles devicePixelRatio so lines stay crisp on high-DPI screens.
9+ */
10+export function useCanvas(draw: DrawFn) {
11+ const ref = useRef<HTMLCanvasElement>(null)
12+ useEffect(() => {
13+ const canvas = ref.current
14+ if (!canvas) return
15+ const render = () => {
16+ const dpr = window.devicePixelRatio || 1
17+ const w = canvas.clientWidth
18+ const h = canvas.clientHeight
19+ if (w === 0 || h === 0) return
20+ canvas.width = Math.round(w * dpr)
21+ canvas.height = Math.round(h * dpr)
22+ const ctx = canvas.getContext('2d')
23+ if (!ctx) return
24+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
25+ ctx.clearRect(0, 0, w, h)
26+ draw(ctx, w, h)
27+ }
28+ render()
29+ const ro = new ResizeObserver(render)
30+ ro.observe(canvas)
31+ return () => ro.disconnect()
32+ }, [draw])
33+ return ref
34+}
src/index.cssadded+257−0View file
@@ -0,0 +1,257 @@
1+:root {
2+ --bg: #f4f5f7;
3+ --card: #ffffff;
4+ --text: #1c1e21;
5+ --muted: #5f646b;
6+ --border: #dfe2e6;
7+ --accent: #1f5fa8;
8+ --ok: #1a7f37;
9+ --bad: #b42318;
10+ --warn-bg: #fff4e0;
11+ --warn: #8a5a00;
12+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
13+ color: var(--text);
14+ background: var(--bg);
15+ line-height: 1.45;
16+}
17+
18+* {
19+ box-sizing: border-box;
20+}
21+
22+body {
23+ margin: 0;
24+}
25+
26+.app {
27+ max-width: 1100px;
28+ margin: 0 auto;
29+ padding: 16px 16px 48px;
30+}
31+
32+.app-header h1 {
33+ margin: 8px 0 4px;
34+ font-size: 1.7rem;
35+}
36+
37+.app-header p,
38+.hint {
39+ color: var(--muted);
40+ margin: 0 0 14px;
41+ max-width: 75em;
42+}
43+
44+.app-footer {
45+ color: var(--muted);
46+ font-size: 0.85rem;
47+ margin-top: 16px;
48+}
49+
50+.card {
51+ background: var(--card);
52+ border: 1px solid var(--border);
53+ border-radius: 8px;
54+ padding: 14px 16px 16px;
55+ margin-bottom: 16px;
56+}
57+
58+.card h2 {
59+ margin: 0 0 10px;
60+ font-size: 1.15rem;
61+}
62+
63+.card.status {
64+ color: var(--muted);
65+}
66+
67+.plot {
68+ display: block;
69+ width: 100%;
70+ background: #fff;
71+}
72+
73+.line-plot {
74+ height: 170px;
75+}
76+
77+.waveform-plot {
78+ height: 130px;
79+ margin-top: 6px;
80+}
81+
82+.spectrogram-plot {
83+ height: 240px;
84+}
85+
86+.spectrograms {
87+ display: grid;
88+ grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
89+ gap: 8px;
90+}
91+
92+.response-plots {
93+ display: grid;
94+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
95+ gap: 8px 16px;
96+ margin-top: 8px;
97+}
98+
99+.plot-label {
100+ font-size: 0.8rem;
101+ color: var(--muted);
102+ margin: 10px 0 2px;
103+}
104+
105+.button-row {
106+ display: flex;
107+ align-items: center;
108+ gap: 8px;
109+ flex-wrap: wrap;
110+}
111+
112+.divider {
113+ width: 1px;
114+ height: 22px;
115+ background: var(--border);
116+ margin: 0 4px;
117+}
118+
119+button {
120+ font: inherit;
121+ font-size: 0.9rem;
122+ padding: 6px 12px;
123+ border-radius: 6px;
124+ border: 1px solid var(--border);
125+ background: #fff;
126+ color: var(--text);
127+ cursor: pointer;
128+}
129+
130+button:hover:not(:disabled) {
131+ background: #f0f2f5;
132+}
133+
134+button:disabled {
135+ opacity: 0.5;
136+ cursor: default;
137+}
138+
139+button.primary {
140+ background: var(--accent);
141+ border-color: var(--accent);
142+ color: #fff;
143+}
144+
145+button.primary:hover:not(:disabled) {
146+ background: #184c87;
147+}
148+
149+button.selected {
150+ border-color: var(--accent);
151+ box-shadow: inset 0 0 0 1px var(--accent);
152+ font-weight: 600;
153+}
154+
155+.playing-indicator {
156+ color: var(--muted);
157+ font-size: 0.9rem;
158+ min-width: 8em;
159+}
160+
161+.global-controls,
162+.zoom-controls {
163+ display: flex;
164+ flex-wrap: wrap;
165+ gap: 10px 24px;
166+ align-items: center;
167+ margin: 6px 0;
168+}
169+
170+.global-controls label,
171+.zoom-controls label,
172+.scrub {
173+ display: flex;
174+ align-items: center;
175+ gap: 8px;
176+ font-size: 0.9rem;
177+}
178+
179+.global-controls label > span:first-child,
180+.zoom-controls label > span:first-child,
181+.scrub > span:first-child {
182+ white-space: nowrap;
183+}
184+
185+.zoom-controls .grow {
186+ flex: 1;
187+ min-width: 260px;
188+}
189+
190+.scrub {
191+ margin: 10px 0 4px;
192+}
193+
194+.scrub input[type='range'] {
195+ flex: 1;
196+ max-width: 480px;
197+}
198+
199+.wide-range {
200+ width: 220px;
201+}
202+
203+.info {
204+ color: var(--muted);
205+ font-size: 0.85rem;
206+ white-space: nowrap;
207+}
208+
209+select {
210+ font: inherit;
211+ font-size: 0.9rem;
212+ padding: 4px 6px;
213+ border-radius: 6px;
214+ border: 1px solid var(--border);
215+ background: #fff;
216+}
217+
218+input[type='range'] {
219+ min-width: 80px;
220+ accent-color: var(--accent);
221+}
222+
223+.slider-value {
224+ font: inherit;
225+ font-size: 0.85rem;
226+ width: 5.5em;
227+ padding: 2px 4px;
228+ border: 1px solid var(--border);
229+ border-radius: 4px;
230+}
231+
232+.slider-unit {
233+ color: var(--muted);
234+ font-size: 0.85rem;
235+ white-space: nowrap;
236+}
237+
238+.source-info {
239+ margin-top: 10px;
240+ font-size: 0.9rem;
241+ color: var(--muted);
242+}
243+
244+.error {
245+ color: var(--bad);
246+ margin-top: 8px;
247+ font-size: 0.9rem;
248+}
249+
250+.warning {
251+ background: var(--warn-bg);
252+ color: var(--warn);
253+ border-radius: 6px;
254+ padding: 8px 10px;
255+ margin: 8px 0;
256+ font-size: 0.9rem;
257+}
src/main.tsxadded+10−0View file
@@ -0,0 +1,10 @@
1+import { StrictMode } from 'react'
2+import { createRoot } from 'react-dom/client'
3+import './index.css'
4+import App from './App.tsx'
5+
6+createRoot(document.getElementById('root')!).render(
7+ <StrictMode>
8+ <App />
9+ </StrictMode>,
10+)
src/phasemap.tsadded+112−0View file
@@ -0,0 +1,112 @@
1+/**
2+ * Phase maps: the angle phi_k applied to band k, as a function of the band
3+ * center frequency. The maps must vary smoothly from one band to the next,
4+ * otherwise neighbouring bands partly cancel where they overlap and the
5+ * magnitude response acquires notches.
6+ */
7+
8+export type PhaseMapSpec =
9+ | {
10+ kind: 'random'
11+ /** Gaussian smoothing width across bands */
12+ smoothBands: number
13+ seed: number
14+ /** multiplies the map; 1 gives the spread of uniform random phases */
15+ strength: number
16+ }
17+ | {
18+ kind: 'delay'
19+ /** phi = -2 pi f tau: a pure delay, which must be inaudible */
20+ delayMs: number
21+ }
22+ | {
23+ kind: 'chirp'
24+ /** group delay grows linearly with frequency, reaching this value at the Nyquist frequency */
25+ delayAtNyquistMs: number
26+ }
27+
28+export const defaultPhaseMap: PhaseMapSpec = { kind: 'random', smoothBands: 8, seed: 1, strength: 1 }
29+
30+/** Small seeded generator (mulberry32) so a map can be reproduced from its seed. */
31+export function mulberry32(seed: number): () => number {
32+ let a = seed >>> 0
33+ return () => {
34+ a = (a + 0x6d2b79f5) >>> 0
35+ let t = a
36+ t = Math.imul(t ^ (t >>> 15), t | 1)
37+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
38+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
39+ }
40+}
41+
42+const UNIFORM_STD = Math.PI / Math.sqrt(3)
43+
44+function smoothedRandom(n: number, smoothBands: number, seed: number, strength: number): Float64Array {
45+ const rnd = mulberry32(seed)
46+ const raw = new Float64Array(n)
47+ for (let i = 0; i < n; i++) raw[i] = (rnd() * 2 - 1) * Math.PI
48+ if (smoothBands <= 0) return raw.map((v) => v * strength)
49+ const radius = Math.ceil(4 * smoothBands)
50+ const kern = new Float64Array(2 * radius + 1)
51+ for (let j = -radius; j <= radius; j++) kern[j + radius] = Math.exp(-0.5 * (j / smoothBands) ** 2)
52+ const out = new Float64Array(n)
53+ for (let i = 0; i < n; i++) {
54+ let s = 0
55+ let wsum = 0
56+ for (let j = -radius; j <= radius; j++) {
57+ const m = i + j
58+ if (m < 0 || m >= n) continue
59+ const w = kern[j + radius]
60+ s += w * raw[m]
61+ wsum += w
62+ }
63+ out[i] = s / wsum
64+ }
65+ // Smoothing shrinks the spread; rescale so the map keeps the std of uniform phases.
66+ let mean = 0
67+ for (let i = 0; i < n; i++) mean += out[i]
68+ mean /= n
69+ let v = 0
70+ for (let i = 0; i < n; i++) v += (out[i] - mean) ** 2
71+ const std = Math.sqrt(v / Math.max(1, n - 1))
72+ const scale = std > 0 ? (strength * UNIFORM_STD) / std : 0
73+ for (let i = 0; i < n; i++) out[i] = (out[i] - mean) * scale
74+ return out
75+}
76+
77+/** phi_k for every band center. */
78+export function phaseMap(spec: PhaseMapSpec, centers: Float64Array, fs: number): Float64Array {
79+ const n = centers.length
80+ switch (spec.kind) {
81+ case 'random':
82+ return smoothedRandom(n, spec.smoothBands, spec.seed, spec.strength)
83+ case 'delay': {
84+ const tau = spec.delayMs / 1000
85+ return centers.map((f) => -2 * Math.PI * f * tau)
86+ }
87+ case 'chirp': {
88+ // tau(f) = tauN f / fN => phi(f) = -2 pi int tau df = -pi tauN f^2 / fN
89+ const tauN = spec.delayAtNyquistMs / 1000
90+ const fN = fs / 2
91+ return centers.map((f) => (-Math.PI * tauN * f * f) / fN)
92+ }
93+ }
94+}
95+
96+/** Largest band-to-band slope of the map, as a group delay in seconds. Used to size the FFT padding. */
97+export function maxGroupDelaySec(phi: Float64Array, df: number): number {
98+ let m = 0
99+ for (let i = 1; i < phi.length; i++) {
100+ let d = Math.abs(phi[i] - phi[i - 1])
101+ d = Math.min(d, 2 * Math.PI) // a jump larger than 2 pi wraps to something smaller anyway
102+ if (d > m) m = d
103+ }
104+ return m / (2 * Math.PI * df)
105+}
106+
107+export const wrapAngle = (x: number): number => {
108+ let y = x % (2 * Math.PI)
109+ if (y > Math.PI) y -= 2 * Math.PI
110+ if (y <= -Math.PI) y += 2 * Math.PI
111+ return y
112+}
src/plot.tsadded+50−0View file
@@ -0,0 +1,50 @@
1+/** Small helpers shared by the canvas plots. */
2+
3+/** Round tick positions covering [min, max] with about `count` steps. */
4+export function niceTicks(min: number, max: number, count = 5): number[] {
5+ if (!(max > min)) return [min]
6+ const raw = (max - min) / count
7+ const mag = 10 ** Math.floor(Math.log10(raw))
8+ const norm = raw / mag
9+ const step = (norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10) * mag
10+ const out: number[] = []
11+ for (let v = Math.ceil(min / step) * step; v <= max + step * 1e-9; v += step) out.push(Number(v.toFixed(10)))
12+ return out
13+}
14+
15+export const formatTick = (v: number): string => {
16+ if (v === 0) return '0'
17+ const a = Math.abs(v)
18+ if (a >= 1000) return `${Number((v / 1000).toPrecision(3))}k`
19+ if (a >= 10) return String(Number(v.toPrecision(3)))
20+ return String(Number(v.toPrecision(2)))
21+}
22+
23+export const formatSeconds = (s: number): string => (s >= 1 ? `${Number(s.toPrecision(3))} s` : `${Number((s * 1000).toPrecision(3))} ms`)
24+
25+/** A magma-like colormap: t in [0, 1] to rgb. */
26+const STOPS: [number, number, number][] = [
27+ [0, 0, 4],
28+ [28, 16, 68],
29+ [79, 18, 123],
30+ [129, 37, 129],
31+ [181, 54, 122],
32+ [229, 80, 100],
33+ [251, 135, 97],
34+ [254, 194, 135],
35+ [252, 253, 191],
36+]
37+
38+export function magma(t: number): [number, number, number] {
39+ const u = Math.min(1, Math.max(0, t)) * (STOPS.length - 1)
40+ const i = Math.min(STOPS.length - 2, Math.floor(u))
41+ const a = u - i
42+ const c0 = STOPS[i]
43+ const c1 = STOPS[i + 1]
44+ return [c0[0] + a * (c1[0] - c0[0]), c0[1] + a * (c1[1] - c0[1]), c0[2] + a * (c1[2] - c0[2])]
45+}
46+
47+export const FONT = '11px system-ui, sans-serif'
48+export const AXIS = '#666'
49+export const GRID = '#e6e8eb'
50+export const FRAME = '#cfd3d8'
src/recorder-worklet.tsadded+19−0View file
@@ -0,0 +1,19 @@
1+/** AudioWorklet processor that forwards raw input samples to the main thread. */
2+
3+declare function registerProcessor(name: string, ctor: unknown): void
4+declare class AudioWorkletProcessor {
5+ readonly port: MessagePort
6+}
7+
8+class PcmRecorder extends AudioWorkletProcessor {
9+ process(inputs: Float32Array[][]): boolean {
10+ const ch = inputs[0]?.[0]
11+ if (ch && ch.length > 0) {
12+ const copy = new Float32Array(ch)
13+ this.port.postMessage(copy, [copy.buffer])
14+ }
15+ return true
16+ }
17+}
18+
19+registerProcessor('pcm-recorder', PcmRecorder)
src/renderer.tsadded+50−0View file
@@ -0,0 +1,50 @@
1+/**
2+ * Main-thread handle on the render worker. Requests are coalesced: while one
3+ * render is running, only the most recent further request is kept, and any
4+ * request it replaces resolves to null.
5+ */
6+import type { RenderRequest, RenderResult } from './types'
7+
8+type Job = { req: RenderRequest; resolve: (r: RenderResult | null) => void; reject: (e: Error) => void }
9+
10+export class Renderer {
11+ private worker: Worker
12+ private busy: Job | null = null
13+ private queued: Job | null = null
14+ private nextId = 1
15+
16+ constructor() {
17+ this.worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
18+ this.worker.onmessage = (e: MessageEvent<RenderResult>) => {
19+ const job = this.busy
20+ this.busy = null
21+ job?.resolve(e.data)
22+ this.pump()
23+ }
24+ this.worker.onerror = (e) => {
25+ const job = this.busy
26+ this.busy = null
27+ job?.reject(new Error(e.message || 'render failed'))
28+ this.pump()
29+ }
30+ }
31+
32+ request(req: Omit<RenderRequest, 'id'>): Promise<RenderResult | null> {
33+ return new Promise((resolve, reject) => {
34+ if (this.queued) this.queued.resolve(null)
35+ this.queued = { req: { ...req, id: this.nextId++ }, resolve, reject }
36+ this.pump()
37+ })
38+ }
39+
40+ private pump(): void {
41+ if (this.busy || !this.queued) return
42+ this.busy = this.queued
43+ this.queued = null
44+ this.worker.postMessage(this.busy.req)
45+ }
46+
47+ terminate(): void {
48+ this.worker.terminate()
49+ }
50+}
src/sources.tsadded+143−0View file
@@ -0,0 +1,143 @@
1+/** Synthesized test sounds, and helpers for clips loaded from files or the microphone. */
2+import { mulberry32 } from './phasemap'
3+import type { Source } from './types'
4+
5+export const MAX_SECONDS = 3
6+
7+const raisedCosineFades = (x: Float32Array, nFade: number): void => {
8+ const n = x.length
9+ const m = Math.min(nFade, Math.floor(n / 2))
10+ for (let i = 0; i < m; i++) {
11+ const e = 0.5 - 0.5 * Math.cos((Math.PI * i) / m)
12+ x[i] *= e
13+ x[n - 1 - i] *= e
14+ }
15+}
16+
17+/** Raised-cosine fades at both ends, in place. */
18+export function applyFades(x: Float32Array, fs: number, fadeSec: number): Float32Array {
19+ raisedCosineFades(x, Math.round(fadeSec * fs))
20+ return x
21+}
22+
23+const add = (x: Float32Array, at: number, seg: Float32Array): void => {
24+ for (let i = 0; i < seg.length && at + i < x.length; i++) if (at + i >= 0) x[at + i] += seg[i]
25+}
26+
27+function clickTrain(fs: number): Float32Array {
28+ const x = new Float32Array(Math.round(1.1 * fs))
29+ const w = Math.max(3, Math.round(0.0001 * fs)) // 0.1 ms pulse
30+ const pulse = new Float32Array(w)
31+ for (let i = 0; i < w; i++) pulse[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * (i + 1)) / (w + 1))
32+ for (const t of [0.1, 0.35, 0.6, 0.85]) add(x, Math.round(t * fs), pulse)
33+ return x
34+}
35+
36+function noiseBursts(fs: number): Float32Array {
37+ const x = new Float32Array(Math.round(1.2 * fs))
38+ const rnd = mulberry32(7)
39+ const len = Math.round(0.06 * fs)
40+ for (const t of [0.1, 0.5, 0.9]) {
41+ const seg = new Float32Array(len)
42+ for (let i = 0; i < len; i++) seg[i] = rnd() * 2 - 1
43+ raisedCosineFades(seg, Math.round(0.002 * fs))
44+ add(x, Math.round(t * fs), seg)
45+ }
46+ return x
47+}
48+
49+function pluck(fs: number, f0: number, seconds: number, seed: number): Float32Array {
50+ // Karplus-Strong: a noise burst circulating through a delay line with a two-point average
51+ const n = Math.round(seconds * fs)
52+ const period = Math.max(2, Math.round(fs / f0))
53+ const y = new Float32Array(n)
54+ const rnd = mulberry32(seed)
55+ for (let i = 0; i < period; i++) y[i] = rnd() * 2 - 1
56+ for (let i = period; i < n; i++) y[i] = 0.996 * 0.5 * (y[i - period] + y[i - period - 1 < 0 ? 0 : i - period - 1])
57+ raisedCosineFades(y, Math.round(0.002 * fs))
58+ return y
59+}
60+
61+function plucks(fs: number): Float32Array {
62+ const x = new Float32Array(Math.round(1.6 * fs))
63+ add(x, Math.round(0.05 * fs), pluck(fs, 220, 0.9, 11))
64+ add(x, Math.round(0.8 * fs), pluck(fs, 330, 0.8, 12))
65+ return x
66+}
67+
68+function harmonicTone(fs: number, f0: number, seconds: number, nHarm: number, amp: number): Float32Array {
69+ const n = Math.round(seconds * fs)
70+ const y = new Float32Array(n)
71+ for (let i = 0; i < n; i++) {
72+ let s = 0
73+ for (let k = 1; k <= nHarm; k++) s += Math.sin((2 * Math.PI * k * f0 * i) / fs) / k
74+ y[i] = amp * s
75+ }
76+ raisedCosineFades(y, Math.round(0.01 * fs))
77+ return y
78+}
79+
80+function tone(fs: number): Float32Array {
81+ const x = new Float32Array(Math.round(1.0 * fs))
82+ add(x, 0, harmonicTone(fs, 220, 1.0, 8, 0.5))
83+ return x
84+}
85+
86+function clickThenTone(fs: number): Float32Array {
87+ // the test signal used in the design document
88+ const x = new Float32Array(Math.round(0.8 * fs))
89+ const w = Math.max(3, Math.round(0.0002 * fs))
90+ const pulse = new Float32Array(w)
91+ for (let i = 0; i < w; i++) pulse[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * (i + 1)) / (w + 1))
92+ add(x, Math.round(0.15 * fs), pulse)
93+ add(x, Math.round(0.35 * fs), harmonicTone(fs, 220, 0.35, 8, 0.25))
94+ return x
95+}
96+
97+export type Preset = { name: string; make: (fs: number) => Float32Array }
98+
99+export const presets: Preset[] = [
100+ { name: 'Click train', make: clickTrain },
101+ { name: 'Noise bursts', make: noiseBursts },
102+ { name: 'Plucked string', make: plucks },
103+ { name: 'Harmonic tone', make: tone },
104+ { name: 'Click then tone', make: clickThenTone },
105+]
106+
107+export function makePreset(p: Preset, fs: number): Source {
108+ return { name: p.name, fs, samples: p.make(fs) }
109+}
110+
111+/** Average the channels of a decoded buffer into one. */
112+export function toMono(buffer: AudioBuffer): Float32Array {
113+ const n = buffer.length
114+ const out = new Float32Array(n)
115+ const nc = buffer.numberOfChannels
116+ for (let c = 0; c < nc; c++) {
117+ const d = buffer.getChannelData(c)
118+ for (let i = 0; i < n; i++) out[i] += d[i] / nc
119+ }
120+ return out
121+}
122+
123+/** A clip of at most MAX_SECONDS starting at startSec, with short fades. */
124+export function excerpt(samples: Float32Array, fs: number, startSec: number): Float32Array {
125+ const i0 = Math.max(0, Math.min(samples.length, Math.round(startSec * fs)))
126+ const i1 = Math.min(samples.length, i0 + Math.round(MAX_SECONDS * fs))
127+ return applyFades(samples.slice(i0, i1), fs, 0.005)
128+}
129+
130+export const rms = (x: Float32Array): number => {
131+ let s = 0
132+ for (let i = 0; i < x.length; i++) s += x[i] * x[i]
133+ return Math.sqrt(s / Math.max(1, x.length))
134+}
135+
136+export const peak = (x: Float32Array): number => {
137+ let m = 0
138+ for (let i = 0; i < x.length; i++) {
139+ const a = Math.abs(x[i])
140+ if (a > m) m = a
141+ }
142+ return m
143+}
src/types.tsadded+58−0View file
@@ -0,0 +1,58 @@
1+import type { PhaseMapSpec } from './phasemap'
2+
3+/** A mono clip at a given sample rate. */
4+export type Source = {
5+ name: string
6+ fs: number
7+ samples: Float32Array
8+}
9+
10+export type RenderRequest = {
11+ id: number
12+ samples: Float32Array
13+ fs: number
14+ sigmaMs: number
15+ map: PhaseMapSpec
16+ /** upper frequency for all plots */
17+ fmax: number
18+ maxCols: number
19+ maxRows: number
20+ /** number of points in the response and map curves */
21+ curvePoints: number
22+}
23+
24+export type Curve = { f: Float32Array; y: Float32Array }
25+
26+export type SpectrogramData = {
27+ /** rows x cols in dB relative to a shared maximum; row 0 is 0 Hz */
28+ db: Float32Array
29+ rows: number
30+ cols: number
31+ durationSec: number
32+ fmax: number
33+}
34+
35+export type RenderResult = {
36+ id: number
37+ /** the phase-rotated sound, unscaled */
38+ y: Float32Array
39+ /** shared playback gain for A and B */
40+ gain: number
41+ /** ||y - x|| / ||x|| */
42+ relDiff: number
43+ bands: number
44+ sigmaF: number
45+ /** the requested map at the band centers, wrapped to (-pi, pi] */
46+ map: Curve
47+ /** magnitude of the effective filter in dB */
48+ magDb: Curve
49+ /** group delay of the effective filter in ms */
50+ groupDelayMs: Curve
51+ /** largest |group delay| in ms over the plotted range */
52+ maxGroupDelayMs: number
53+ /** largest |magnitude response| in dB over the plotted range; large values mean the map is too rough for the window */
54+ maxMagDeviationDb: number
55+ specA: SpectrogramData
56+ specB: SpectrogramData
57+ elapsedMs: number
58+}
src/worker.tsadded+105−0View file
@@ -0,0 +1,105 @@
1+/**
2+ * Web Worker: renders the phase-rotated sound B from the source A, and the
3+ * curves and spectrograms shown in the app. One message in, one message out.
4+ */
5+import { applyFilter, bandCenters, groupDelay, sigmaF, spectrogram, type Spectrogram } from './bank'
6+import { maxGroupDelaySec, phaseMap, wrapAngle } from './phasemap'
7+import { peak, rms } from './sources'
8+import type { Curve, RenderRequest, RenderResult, SpectrogramData } from './types'
9+
10+/** Sample an array defined on FFT bins onto `points` evenly spaced frequencies in [0, fmax]. */
11+function sampleCurve(values: ArrayLike<number>, binHz: number, fmax: number, points: number): Curve {
12+ const f = new Float32Array(points)
13+ const y = new Float32Array(points)
14+ for (let i = 0; i < points; i++) {
15+ const fi = (fmax * i) / (points - 1)
16+ const b = Math.min(values.length - 1, Math.round(fi / binHz))
17+ f[i] = fi
18+ y[i] = values[b]
19+ }
20+ return { f, y }
21+}
22+
23+function toDb(s: Spectrogram, ref: number): SpectrogramData {
24+ const db = new Float32Array(s.data.length)
25+ for (let i = 0; i < db.length; i++) db[i] = 20 * Math.log10(Math.max(s.data[i], 1e-12) / ref)
26+ return { db, rows: s.rows, cols: s.cols, durationSec: s.durationSec, fmax: s.fmax }
27+}
28+
29+function render(req: RenderRequest): RenderResult {
30+ const t0 = performance.now()
31+ const { samples: x, fs, fmax } = req
32+ const sigma = req.sigmaMs / 1000
33+ const centers = bandCenters(fs, sigma)
34+ const df = sigmaF(sigma)
35+ const phi = phaseMap(req.map, centers, fs)
36+ const padSec = 8 * sigma + maxGroupDelaySec(phi, df) + 0.02
37+ const { y, g } = applyFilter(x, fs, sigma, phi, centers, padSec)
38+
39+ // shared gain: target RMS from A, but never let either sound clip
40+ const pk = Math.max(peak(x), peak(y))
41+ const r = rms(x)
42+ const gain = pk > 0 && r > 0 ? Math.min(0.9 / pk, 0.2 / r) : 0
43+
44+ let d = 0
45+ let e = 0
46+ for (let i = 0; i < x.length; i++) {
47+ d += (y[i] - x[i]) ** 2
48+ e += x[i] * x[i]
49+ }
50+ const relDiff = e > 0 ? Math.sqrt(d / e) : 0
51+
52+ const nb = g.re.length
53+ const magDb = new Float64Array(nb)
54+ for (let i = 0; i < nb; i++) magDb[i] = 20 * Math.log10(Math.max(Math.hypot(g.re[i], g.im[i]), 1e-9))
55+ const tau = groupDelay(g)
56+ const tauMs = new Float64Array(nb)
57+ for (let i = 0; i < nb; i++) tauMs[i] = tau[i] * 1000
58+ const gdCurve = sampleCurve(tauMs, g.binHz, fmax, req.curvePoints)
59+ let maxGd = 0
60+ let maxMagDev = 0
61+ const lastBin = Math.min(nb - 1, Math.round(fmax / g.binHz))
62+ for (let i = 0; i <= lastBin; i++) {
63+ maxGd = Math.max(maxGd, Math.abs(tauMs[i]))
64+ maxMagDev = Math.max(maxMagDev, Math.abs(magDb[i]))
65+ }
66+
67+ // the map itself, wrapped, at the band centers up to fmax (subsampled if there are many bands)
68+ const kMax = Math.min(centers.length - 1, Math.floor(fmax / df))
69+ const step = Math.max(1, Math.ceil((kMax + 1) / req.curvePoints))
70+ const mapF: number[] = []
71+ const mapY: number[] = []
72+ for (let k = 0; k <= kMax; k += step) {
73+ mapF.push(centers[k])
74+ mapY.push(wrapAngle(phi[k]))
75+ }
76+
77+ const sA = spectrogram(x, fs, sigma, fmax, req.maxCols, req.maxRows)
78+ const sB = spectrogram(y, fs, sigma, fmax, req.maxCols, req.maxRows)
79+ let ref = 1e-12
80+ for (let i = 0; i < sA.data.length; i++) if (sA.data[i] > ref) ref = sA.data[i]
81+ for (let i = 0; i < sB.data.length; i++) if (sB.data[i] > ref) ref = sB.data[i]
82+
83+ return {
84+ id: req.id,
85+ y,
86+ gain,
87+ relDiff,
88+ bands: centers.length,
89+ sigmaF: df,
90+ map: { f: Float32Array.from(mapF), y: Float32Array.from(mapY) },
91+ magDb: sampleCurve(magDb, g.binHz, fmax, req.curvePoints),
92+ groupDelayMs: gdCurve,
93+ maxGroupDelayMs: maxGd,
94+ maxMagDeviationDb: maxMagDev,
95+ specA: toDb(sA, ref),
96+ specB: toDb(sB, ref),
97+ elapsedMs: performance.now() - t0,
98+ }
99+}
100+
101+self.onmessage = (e: MessageEvent<RenderRequest>) => {
102+ const res = render(e.data)
103+ const transfer = [res.y.buffer, res.specA.db.buffer, res.specB.db.buffer]
104+ ;(self as unknown as Worker).postMessage(res, transfer)
105+}
tsconfig.app.jsonadded+28−0View file
@@ -0,0 +1,28 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4+ "target": "ES2022",
5+ "useDefineForClassFields": true,
6+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7+ "module": "ESNext",
8+ "types": ["vite/client"],
9+ "skipLibCheck": true,
10+
11+ /* Bundler mode */
12+ "moduleResolution": "bundler",
13+ "allowImportingTsExtensions": true,
14+ "verbatimModuleSyntax": true,
15+ "moduleDetection": "force",
16+ "noEmit": true,
17+ "jsx": "react-jsx",
18+
19+ /* Linting */
20+ "strict": true,
21+ "noUnusedLocals": true,
22+ "noUnusedParameters": true,
23+ "erasableSyntaxOnly": true,
24+ "noFallthroughCasesInSwitch": true,
25+ "noUncheckedSideEffectImports": true
26+ },
27+ "include": ["src"]
28+}
tsconfig.jsonadded+7−0View file
@@ -0,0 +1,7 @@
1+{
2+ "files": [],
3+ "references": [
4+ { "path": "./tsconfig.app.json" },
5+ { "path": "./tsconfig.node.json" }
6+ ]
7+}
tsconfig.node.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4+ "target": "ES2023",
5+ "lib": ["ES2023"],
6+ "module": "ESNext",
7+ "types": ["node"],
8+ "skipLibCheck": true,
9+
10+ /* Bundler mode */
11+ "moduleResolution": "bundler",
12+ "allowImportingTsExtensions": true,
13+ "verbatimModuleSyntax": true,
14+ "moduleDetection": "force",
15+ "noEmit": true,
16+
17+ /* Linting */
18+ "strict": true,
19+ "noUnusedLocals": true,
20+ "noUnusedParameters": true,
21+ "erasableSyntaxOnly": true,
22+ "noFallthroughCasesInSwitch": true,
23+ "noUncheckedSideEffectImports": true
24+ },
25+ "include": ["vite.config.ts"]
26+}
vite.config.tsadded+9−0View file
@@ -0,0 +1,9 @@
1+import { defineConfig } from 'vite'
2+import react from '@vitejs/plugin-react'
3+
4+// https://vite.dev/config/
5+export default defineConfig({
6+ plugins: [react()],
7+ // Relative base, so the build works wherever it is mounted
8+ base: './',
9+})