Technical wording and labels, presets with stated predictions, phase map plots always visible
4 changed files+81−88
src/App.tsxmodified+7−6View file
@@ -84,13 +84,14 @@ export default function App() {
8484 <header className="app-header">
8585 <h1>Phase Perception 2</h1>
8686 <p>
87- Take a sound and keep how strong each frequency is at each moment, but scramble the timing (the phase) of the frequency
88- components. Does it still sound the same? Sound A is the original and sound B is the scrambled version.
87+ Sound A is split into overlapping Gaussian frequency bands (a short-time Fourier transform with a hop of one sample). Sound B is
88+ built by rotating the phase of each band by phi(f, t) and summing the bands. A and B have identical subband envelopes at the
89+ analysis resolution and different phases. The question is whether they sound the same, and under which conditions they do not.
8990 </p>
9091 <ol className="steps">
91- <li>Choose a sound.</li>
92- <li>Choose how the phases are scrambled. The quick-start buttons cover the main cases.</li>
93- <li>Play A and B and listen for a difference.</li>
92+ <li>Select a source sound.</li>
93+ <li>Set the window width and the phase map, or use a preset.</li>
94+ <li>Play A and B.</li>
9495 </ol>
9596 </header>
9697
@@ -105,7 +106,7 @@ export default function App() {
105106 <footer className="app-footer">
106107 {result && `Last render took ${result.elapsedMs.toFixed(0)} ms. `}
107108 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
108- device's sample rate of {fs} Hz. Headphones make small differences easier to hear.
109+ device's sample rate of {fs} Hz. Headphones are recommended.
109110 </footer>
110111 </div>
111112 )
src/components/AnalysisPanel.tsxmodified+66−73View file
@@ -20,17 +20,17 @@ const TIME_LOG_MAX = Math.log10(3000)
2020 const sig3 = (x: number) => Number(x.toPrecision(3))
2121 const THRESHOLD_MS = 1
2222
23-type QuickStart = { name: string; expect: string; sigmaMs: number; map: PhaseMapSpec }
23+type Preset = { name: string; prediction: string; sigmaMs: number; map: PhaseMapSpec }
2424
2525 const randomMap = (timeScaleMs: number | null): PhaseMapSpec => ({ kind: 'random', smoothBands: 8, timeScaleMs, seed: 1, strength: 1 })
2626
27-const QUICK: QuickStart[] = [
28- { name: 'Fixed scramble, short window', expect: 'Expected inaudible: transients can only smear by a fraction of a millisecond.', sigmaMs: 0.5, map: randomMap(null) },
29- { name: 'Fixed scramble, medium window', expect: 'Borderline: attacks smear by a few milliseconds, near the reported threshold. Steady parts should sound the same.', sigmaMs: 5, map: randomMap(null) },
30- { name: 'Fixed scramble, long window', expect: 'Expected audible on attacks: they smear over tens of milliseconds. Steady parts should sound the same.', sigmaMs: 20, map: randomMap(null) },
31- { name: 'Slowly changing scramble', expect: 'Expected inaudible or nearly so: each frequency drifts by about 1 Hz.', sigmaMs: 5, map: randomMap(500) },
32- { name: 'Quickly changing scramble', expect: 'Expected audible: each frequency wobbles by tens of Hz, heard as warble or roughness.', sigmaMs: 5, map: randomMap(20) },
33- { name: 'Pure delay (control)', expect: 'B is A delayed by a little over a millisecond. Must be inaudible; a check on the pipeline and the listener.', sigmaMs: 5, map: { kind: 'delay', delayMs: 1.25 } },
27+const 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 } },
3434 ]
3535
3636 const sameSpec = (a: PhaseMapSpec, b: PhaseMapSpec) => JSON.stringify(a) === JSON.stringify(b)
@@ -47,28 +47,31 @@ export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }
4747 const timeVarying = map.kind === 'random' && map.timeScaleMs !== null
4848 // a Gaussian-smoothed field with std s and time scale T has a derivative of std s / T
4949 const fmHz = map.kind === 'random' && map.timeScaleMs !== null ? (map.strength * UNIFORM_STD) / (2 * Math.PI * (map.timeScaleMs / 1000)) : 0
50- const active = QUICK.find((q) => q.sigmaMs === sigmaMs && sameSpec(q.map, map))
50+ const active = PRESETS.find((q) => q.sigmaMs === sigmaMs && sameSpec(q.map, map))
5151
5252 return (
5353 <section className="card">
54- <h2>2. Choose how the phases are scrambled</h2>
54+ <h2>2. Window and phase map</h2>
5555 <p className="hint">
56- The sound is split into overlapping frequency bands. Each band keeps its loudness over time but its phase is shifted by an amount
57- that depends on the band and, optionally, on time. The bands are then added back together to make B.
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.
5861 </p>
5962 <div className="quick-start">
60- <span className="quick-label">Quick start:</span>
61- {QUICK.map((q) => (
62- <button key={q.name} className={active?.name === q.name ? 'selected' : ''} title={q.expect} onClick={() => { setSigmaMs(q.sigmaMs); setMap(q.map) }}>
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) }}>
6366 {q.name}
6467 </button>
6568 ))}
6669 </div>
67- <div className="expect">{active ? active.expect : 'Custom settings.'}</div>
70+ <div className="expect">{active ? active.prediction : ''}</div>
6871
6972 <div className="global-controls">
70- <label title="Width of the time window, and so of each frequency band. Longer windows mean narrower bands and allow attacks to smear more.">
71- <span>Time window</span>
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>
7275 <input
7376 type="range"
7477 className="wide-range"
@@ -92,47 +95,48 @@ export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }
9295 />
9396 <span className="slider-unit">ms</span>
9497 <span className="info">
95- bands {sig3(sigmaF)} Hz wide{result ? `, ${result.bands} of them` : ''}
98+ sigma_f = {sig3(sigmaF)} Hz{result ? `, ${result.bands} bands` : ''}
9699 </span>
97100 </label>
98101 </div>
99102 <div className="global-controls">
100103 <label>
101- <span>Scramble pattern</span>
104+ <span>Phase map</span>
102105 <select value={map.kind} onChange={(e) => setKind(e.target.value as PhaseMapSpec['kind'])}>
103- <option value="random">Random, smoothed across bands</option>
104- <option value="delay">Pure delay (control: must be inaudible)</option>
105- <option value="chirp">Chirp: delay growing with frequency</option>
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>
106109 </select>
107110 </label>
108111 {map.kind === 'random' && (
109112 <>
110- <label title="How many neighbouring bands share a similar phase. Too little smoothing and neighbouring bands cancel each other (see the warning).">
111- <span>Smoothness across bands</span>
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>
112115 <input type="range" min={1} max={40} step={1} value={map.smoothBands} onChange={(e) => setMap({ ...map, smoothBands: Number(e.target.value) })} />
113116 <span className="slider-unit">
114117 {map.smoothBands} bands ({sig3(map.smoothBands * sigmaF)} Hz)
115118 </span>
116119 </label>
117- <label title="Size of the phase shifts. 0 makes B identical to A.">
118- <span>Amount</span>
120+ <label title="Scale factor of the map. 1 gives the standard deviation of uniform random phases, pi / sqrt(3).">
121+ <span>Strength</span>
119122 <input type="range" min={0} max={2} step={0.05} value={map.strength} onChange={(e) => setMap({ ...map, strength: Number(e.target.value) })} />
120123 <span className="slider-unit">{map.strength.toFixed(2)}</span>
121124 </label>
122- <button onClick={() => setMap({ ...map, seed: (map.seed % 100000) + 1 })}>New random pattern</button>
125+ <button onClick={() => setMap({ ...map, seed: (map.seed % 100000) + 1 })}>New random map</button>
126+ <span className="info">seed {map.seed}</span>
123127 </>
124128 )}
125129 {map.kind === 'delay' && (
126- <label>
127- <span>Delay</span>
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>
128132 <input type="range" min={-20} max={20} step={0.05} value={map.delayMs} onChange={(e) => setMap({ ...map, delayMs: Number(e.target.value) })} />
129133 <input type="number" className="slider-value" step="any" value={map.delayMs} onChange={(e) => setMap({ ...map, delayMs: Number(e.target.value) || 0 })} />
130134 <span className="slider-unit">ms</span>
131135 </label>
132136 )}
133137 {map.kind === 'chirp' && (
134- <label>
135- <span>Delay at the highest frequency</span>
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>
136140 <input type="range" min={-20} max={20} step={0.05} value={map.delayAtNyquistMs} onChange={(e) => setMap({ ...map, delayAtNyquistMs: Number(e.target.value) })} />
137141 <input type="number" className="slider-value" step="any" value={map.delayAtNyquistMs} onChange={(e) => setMap({ ...map, delayAtNyquistMs: Number(e.target.value) || 0 })} />
138142 <span className="slider-unit">ms</span>
@@ -141,13 +145,13 @@ export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }
141145 </div>
142146 {map.kind === 'random' && (
143147 <div className="global-controls">
144- <label title="A fixed pattern is an all-pass filter. A changing pattern shifts each band's frequency by the rate of change of its phase.">
148+ <label title="A fixed map is a linear time-invariant all-pass filter. A time-varying map is applied by subband synthesis.">
145149 <input type="checkbox" checked={map.timeScaleMs === null} onChange={(e) => setMap({ ...map, timeScaleMs: e.target.checked ? null : 200 })} />
146- <span>Pattern fixed in time</span>
150+ <span>Fixed in time</span>
147151 </label>
148152 {map.timeScaleMs !== null && (
149- <label title="How slowly the pattern changes. Faster change means larger frequency wobble.">
150- <span>Changes over</span>
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>
151155 <input
152156 type="range"
153157 className="wide-range"
@@ -169,7 +173,7 @@ export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }
169173 }}
170174 />
171175 <span className="slider-unit">ms</span>
172- <span className="info">frequencies wobble by about {sig3(fmHz)} Hz</span>
176+ <span className="info">typical frequency shift {sig3(fmHz)} Hz</span>
173177 </label>
174178 )}
175179 </div>
@@ -177,49 +181,38 @@ export function AnalysisPanel({ sigmaMs, setSigmaMs, map, setMap, fmax, result }
177181
178182 {result && result.maxMagDeviationDb > 1 && (
179183 <div className="warning">
180- The loudness of some frequencies changes by up to {result.maxMagDeviationDb.toFixed(1)} dB, so B is not just a phase change of A.
181- The pattern changes too quickly from one band to the next for this window: lengthen the time window, increase the smoothness, or
182- reduce the delay.
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.
183186 </div>
184187 )}
185188
186189 {result && (
187- <details className="details">
188- <summary>Show the phase pattern and the equivalent filter</summary>
189- <p className="hint">
190- A pattern that is fixed in time is an all-pass filter: it leaves every frequency's loudness alone and only delays each
191- frequency by its <em>group delay</em>, the slope of the phase across frequency. Group delay peaks beyond about {THRESHOLD_MS} ms
192- have been reported to be audible on impulsive sounds, and the achievable group delay grows with the time window. A pattern
193- that changes in time shifts each band's frequency by the rate of change of its phase, which becomes audible on steady sounds
194- once the shift approaches the pitch threshold, roughly 1 Hz for low tones.
195- </p>
196- <div className="response-plots">
197- <div>
198- <div className="plot-label">
199- Phase shift by frequency and time{result.timeVarying ? '' : ' (fixed in time)'} <span className="phase-legend" /> -180° to 180°
200- </div>
201- <ImagePlot image={result.phaseField} color={phaseColor} label="" labelColor="#000" />
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°
202194 </div>
203- <div>
204- <div className="plot-label">Loudness change by frequency{timeVarying ? ', at mid-clip' : ''} (should stay near 0 dB)</div>
205- <LinePlot series={[{ x: result.magDb.f, y: result.magDb.y, color: '#1f5fa8' }]} xRange={[0, fmax]} yRange={[-30, 3]} xLabel="frequency (Hz)" yLabel="dB" zeroLine />
206- </div>
207- <div>
208- <div className="plot-label">
209- Group delay by frequency{timeVarying ? ', at mid-clip' : ''} (largest {result.maxGroupDelayMs.toFixed(2)} ms)
210- </div>
211- <LinePlot
212- series={[{ x: result.groupDelayMs.f, y: result.groupDelayMs.y, color: '#1f5fa8' }]}
213- xRange={[0, fmax]}
214- symmetric
215- xLabel="frequency (Hz)"
216- yLabel="ms"
217- band={{ y0: -THRESHOLD_MS, y1: THRESHOLD_MS, color: 'rgba(46, 160, 67, 0.15)', label: 'approximate audibility threshold' }}
218- zeroLine
219- />
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
220204 </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+ />
221214 </div>
222- </details>
215+ </div>
223216 )}
224217 </section>
225218 )
src/components/PlaybackPanel.tsxmodified+5−6View file
@@ -62,12 +62,11 @@ export function PlaybackPanel({ source, result, stale, playing, play, stop }: Pr
6262
6363 return (
6464 <section className="card">
65- <h2>3. Listen: can you hear a difference?</h2>
65+ <h2>3. Listen</h2>
6666 <p className="hint">
67- A is the original and B is the scrambled version, played at the same level{stale ? ' (B is being re-rendered)' : ''}. If they sound
68- the same, phase is inaudible for this sound and these settings. The waveforms differ by {Math.round(100 * result.relDiff)}% of A's
69- RMS; the spectrograms, which show how strong each frequency is over time, should look the same
70- {result.timeVarying ? ' as long as the scramble changes slowly compared with the time window' : ''}.
67+ A is the source, B is the phase-rotated version; both use the same gain{stale ? ' (B is being re-rendered)' : ''}. RMS of B minus A
68+ is {Math.round(100 * result.relDiff)}% of the RMS of A. The spectrograms are computed at the analysis resolution and should match
69+ {result.timeVarying ? ' when the map changes slowly compared with the window' : ''}.
7170 </p>
7271 <div className="button-row">
7372 <button className="primary" onClick={() => play('A', ['A'])} disabled={busy || stale}>
@@ -89,7 +88,7 @@ export function PlaybackPanel({ source, result, stale, playing, play, stop }: Pr
8988 </div>
9089
9190 <div className="view-controls">
92- <span className="info">Drag across a waveform to zoom into that range. The scroll wheel also zooms; the bar below the waveforms pans.</span>
91+ <span className="info">Drag to zoom into a range; wheel zooms; the bar below pans. Both waveforms share the view.</span>
9392 <span className="spacer" />
9493 <span className="info">{zoomed ? `${formatSeconds(view.span)} from ${formatSeconds(view.start)}` : `full clip, ${formatSeconds(duration)}`}</span>
9594 <span className="button-group">
src/components/SourcePanel.tsxmodified+3−3View file
@@ -66,10 +66,10 @@ export function SourcePanel({ fs, source, setSource, busy }: Props) {
6666
6767 return (
6868 <section className="card">
69- <h2>1. Choose a sound</h2>
69+ <h2>1. Source sound</h2>
7070 <p className="hint">
71- Pick a synthesized sound, load an audio file, or record from the microphone (clips are mono, up to {MAX_SECONDS} s). Sounds with
72- sharp attacks are where scrambled phases are most likely to be heard; steady sounds are where they are least likely.
71+ Synthesized sounds, an audio file, or a microphone recording. Clips are mono and limited to {MAX_SECONDS} s. Phase changes are
72+ expected to be audible on transients and inaudible on steady sounds.
7373 </p>
7474 <div className="button-row">
7575 {presets.map((p, i) => (