1import { UNIFORM_STD, type PhaseMapSpec } from '../phasemap'
2import { phaseColor } from '../plot'
3import type { RenderResult } from '../types'
4import { ImagePlot } from './ImagePlot'
5import { LinePlot } from './LinePlot'
7type Props = {
8 sigmaMs: number
9 setSigmaMs: (v: number) => void
10 map: PhaseMapSpec
11 setMap: (m: PhaseMapSpec) => void
12 fmax: number
13 result: RenderResult | null
14}
16const SIGMA_LOG_MIN = Math.log10(0.3)
17const SIGMA_LOG_MAX = Math.log10(50)
18const TIME_LOG_MIN = 0 // 1 ms
19const TIME_LOG_MAX = Math.log10(3000)
20const sig3 = (x: number) => Number(x.toPrecision(3))
21const THRESHOLD_MS = 1
23type Preset = { name: string; prediction: string; sigmaMs: number; map: PhaseMapSpec }
25const randomMap = (timeScaleMs: number | null): PhaseMapSpec => ({ kind: 'random', smoothBands: 8, timeScaleMs, seed: 1, strength: 1 })
27const PRESETS: Preset[] = [
28 { name: 'Fixed map, sigma 0.5 ms', prediction: 'Group delay below 0.3 ms, under the reported threshold. Predicted inaudible.', sigmaMs: 0.5, map: randomMap(null) },
29 { name: 'Fixed map, sigma 5 ms', prediction: 'Group delay of a few ms, near the reported threshold. Predicted borderline on transients, inaudible on steady sounds.', sigmaMs: 5, map: randomMap(null) },
30 { name: 'Fixed map, sigma 20 ms', prediction: 'Group delay around 10 ms. Predicted audible on transients as smearing, inaudible on steady sounds.', sigmaMs: 20, map: randomMap(null) },
31 { name: 'Time scale 500 ms', prediction: 'Frequency shifts of about 0.6 Hz, below the pitch threshold. Predicted inaudible or nearly so.', sigmaMs: 5, map: randomMap(500) },
32 { name: 'Time scale 20 ms', prediction: 'Frequency shifts of about 14 Hz. Predicted audible as frequency modulation.', sigmaMs: 5, map: randomMap(20) },
33 { name: 'Delay 1.25 ms (control)', prediction: 'B is A delayed by 1.25 ms with 0.3 dB attenuation. Must be inaudible.', sigmaMs: 5, map: { kind: 'delay', delayMs: 1.25 } },
34]
36const sameSpec = (a: PhaseMapSpec, b: PhaseMapSpec) => JSON.stringify(a) === JSON.stringify(b)
38/** Window width, phase map choice and parameters, and the plots that describe the resulting filter. */
39export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }: Props) {
40 const sigmaF = 1000 / (2 * Math.PI * sigmaMs)
41 const setKind = (kind: PhaseMapSpec['kind']) => {
42 if (kind === map.kind) return
43 if (kind === 'random') setMap(randomMap(null))
44 else if (kind === 'delay') setMap({ kind, delayMs: sig3(sigmaMs / 4) })
45 else setMap({ kind, delayAtNyquistMs: sig3(sigmaMs / 4) })
46 }
47 const timeVarying = map.kind === 'random' && map.timeScaleMs !== null
48 // a Gaussian-smoothed field with std s and time scale T has a derivative of std s / T
49 const fmHz = map.kind === 'random' && map.timeScaleMs !== null ? (map.strength * UNIFORM_STD) / (2 * Math.PI * (map.timeScaleMs / 1000)) : 0
50 const active = PRESETS.find((q) => q.sigmaMs === sigmaMs && sameSpec(q.map, map))
52 return (
53 <section className="card">
54 <h2>2. Window and phase map</h2>
55 <p className="hint">
56 Bands are Gaussian with width sigma in time and sigma_f = 1 / (2 pi sigma) in frequency, spaced by sigma_f, normalized to sum to
57 one. A map fixed in time is an all-pass filter whose group delay, minus the slope of the phase across frequency, smears transients;
58 peaks beyond about {THRESHOLD_MS} ms are reported audible on impulsive sounds (Blauert and Laws, 1978). A map varying in time
59 shifts each band's frequency by (d phi / dt) / 2 pi. The map must change by less than about a radian between neighbouring bands,
60 otherwise the bands partly cancel and the magnitude response deviates from 0 dB.
61 </p>
62 <div className="quick-start">
63 <span className="quick-label">Presets:</span>
64 {PRESETS.map((q) => (
65 <button key={q.name} className={active?.name === q.name ? 'selected' : ''} title={q.prediction} onClick={() => { setSigmaMs(q.sigmaMs); setMap(q.map) }}>
66 {q.name}
67 </button>
68 ))}
69 </div>
70 <div className="expect">{active ? active.prediction : ''}</div>
72 <div className="global-controls">
73 <label title="Gaussian window width sigma. The band width is 1 / (2 pi sigma) and the achievable group delay scales with sigma.">
74 <span>Window sigma</span>
75 <input
76 type="range"
77 className="wide-range"
78 min={SIGMA_LOG_MIN}
79 max={SIGMA_LOG_MAX}
80 step={0.01}
81 value={Math.min(SIGMA_LOG_MAX, Math.max(SIGMA_LOG_MIN, Math.log10(sigmaMs)))}
82 onChange={(e) => setSigmaMs(sig3(10 ** Number(e.target.value)))}
83 />
84 <input
85 type="number"
86 className="slider-value"
87 min={0.1}
88 max={200}
89 step="any"
90 value={sigmaMs}
91 onChange={(e) => {
92 const v = Number(e.target.value)
93 if (Number.isFinite(v) && v > 0) setSigmaMs(Math.min(200, Math.max(0.1, v)))
94 }}
95 />
96 <span className="slider-unit">ms</span>
97 <span className="info">
98 sigma_f = {sig3(sigmaF)} Hz{result ? `, ${result.bands} bands` : ''}
99 </span>
100 </label>
101 </div>
102 <div className="global-controls">
103 <label>
104 <span>Phase map</span>
105 <select value={map.kind} onChange={(e) => setKind(e.target.value as PhaseMapSpec['kind'])}>
106 <option value="random">Smoothed random</option>
107 <option value="delay">Pure delay (control)</option>
108 <option value="chirp">Chirp (group delay linear in frequency)</option>
109 </select>
110 </label>
111 {map.kind === 'random' && (
112 <>
113 <label title="Gaussian smoothing width of the map across bands. Below about 6 bands neighbouring bands cancel and the magnitude response develops notches.">
114 <span>Smoothing in frequency</span>
115 <input type="range" min={1} max={40} step={1} value={map.smoothBands} onChange={(e) => setMap({ ...map, smoothBands: Number(e.target.value) })} />
116 <span className="slider-unit">
117 {map.smoothBands} bands ({sig3(map.smoothBands * sigmaF)} Hz)
118 </span>
119 </label>
120 <label title="Scale factor of the map. 1 gives the standard deviation of uniform random phases, pi / sqrt(3).">
121 <span>Strength</span>
122 <input type="range" min={0} max={2} step={0.05} value={map.strength} onChange={(e) => setMap({ ...map, strength: Number(e.target.value) })} />
123 <span className="slider-unit">{map.strength.toFixed(2)}</span>
124 </label>
125 <button onClick={() => setMap({ ...map, seed: (map.seed % 100000) + 1 })}>New random map</button>
126 <span className="info">seed {map.seed}</span>
127 </>
128 )}
129 {map.kind === 'delay' && (
130 <label title="phi(f) = -2 pi f tau. The magnitude is exp(-tau^2 / 2 sigma^2), so tau must stay well below sigma.">
131 <span>Delay tau</span>
132 <input type="range" min={-20} max={20} step={0.05} value={map.delayMs} onChange={(e) => setMap({ ...map, delayMs: Number(e.target.value) })} />
133 <input type="number" className="slider-value" step="any" value={map.delayMs} onChange={(e) => setMap({ ...map, delayMs: Number(e.target.value) || 0 })} />
134 <span className="slider-unit">ms</span>
135 </label>
136 )}
137 {map.kind === 'chirp' && (
138 <label title="Group delay tau(f) = tau_N f / f_Nyquist, so phi(f) = -pi tau_N f^2 / f_Nyquist.">
139 <span>Group delay at Nyquist</span>
140 <input type="range" min={-20} max={20} step={0.05} value={map.delayAtNyquistMs} onChange={(e) => setMap({ ...map, delayAtNyquistMs: Number(e.target.value) })} />
141 <input type="number" className="slider-value" step="any" value={map.delayAtNyquistMs} onChange={(e) => setMap({ ...map, delayAtNyquistMs: Number(e.target.value) || 0 })} />
142 <span className="slider-unit">ms</span>
143 </label>
144 )}
145 </div>
146 {map.kind === 'random' && (
147 <div className="global-controls">
148 <label title="A fixed map is a linear time-invariant all-pass filter. A time-varying map is applied by subband synthesis.">
149 <input type="checkbox" checked={map.timeScaleMs === null} onChange={(e) => setMap({ ...map, timeScaleMs: e.target.checked ? null : 200 })} />
150 <span>Fixed in time</span>
151 </label>
152 {map.timeScaleMs !== null && (
153 <label title="Gaussian smoothing width of the map in time. The typical frequency shift is strength * pi / sqrt(3) / (2 pi T).">
154 <span>Time scale T</span>
155 <input
156 type="range"
157 className="wide-range"
158 min={TIME_LOG_MIN}
159 max={TIME_LOG_MAX}
160 step={0.01}
161 value={Math.min(TIME_LOG_MAX, Math.max(TIME_LOG_MIN, Math.log10(map.timeScaleMs)))}
162 onChange={(e) => setMap({ ...map, timeScaleMs: sig3(10 ** Number(e.target.value)) })}
163 />
164 <input
165 type="number"
166 className="slider-value"
167 min={0.1}
168 step="any"
169 value={map.timeScaleMs}
170 onChange={(e) => {
171 const v = Number(e.target.value)
172 if (Number.isFinite(v) && v > 0) setMap({ ...map, timeScaleMs: v })
173 }}
174 />
175 <span className="slider-unit">ms</span>
176 <span className="info">typical frequency shift {sig3(fmHz)} Hz</span>
177 </label>
178 )}
179 </div>
180 )}
182 {result && result.maxMagDeviationDb > 1 && (
183 <div className="warning">
184 The magnitude response deviates by up to {result.maxMagDeviationDb.toFixed(1)} dB, so B is not a pure phase change of A. The map
185 changes too quickly between neighbouring bands for this window: increase sigma, increase the smoothing, or reduce the delay.
186 </div>
187 )}
189 {result && (
190 <div className="response-plots">
191 <div>
192 <div className="plot-label">
193 phi(f, t){result.timeVarying ? '' : ', fixed in time'} <span className="phase-legend" /> -180° to 180°
194 </div>
195 <ImagePlot image={result.phaseField} color={phaseColor} label="" labelColor="#000" />
196 </div>
197 <div>
198 <div className="plot-label">Magnitude response |G(f)|{timeVarying ? ' at mid-clip' : ''}</div>
199 <LinePlot series={[{ x: result.magDb.f, y: result.magDb.y, color: '#1f5fa8' }]} xRange={[0, fmax]} yRange={[-30, 3]} xLabel="frequency (Hz)" yLabel="dB" zeroLine />
200 </div>
201 <div>
202 <div className="plot-label">
203 Group delay{timeVarying ? ' at mid-clip' : ''}, largest {result.maxGroupDelayMs.toFixed(2)} ms
204 </div>
205 <LinePlot
206 series={[{ x: result.groupDelayMs.f, y: result.groupDelayMs.y, color: '#1f5fa8' }]}
207 xRange={[0, fmax]}
208 symmetric
209 xLabel="frequency (Hz)"
210 yLabel="ms"
211 band={{ y0: -THRESHOLD_MS, y1: THRESHOLD_MS, color: 'rgba(46, 160, 67, 0.15)', label: 'approximate audibility threshold' }}
212 zeroLine
213 />
214 </div>
215 </div>
216 )}
217 </section>
218 )
219}