/ proof-of-concept / phase-perception
Sign in
proof-of-concept / phase-perception
phase-perception / src / App.tsx
225 lines · 7.7 KBBlameHistoryRaw
1import { useCallback, useEffect, useRef, useState } from 'react'
2import { BlindTest } from './components/BlindTest'
3import { CyclePanel } from './components/CyclePanel'
4import { HarmonicSliders } from './components/HarmonicSliders'
5import { ToneColumn } from './components/ToneColumn'
6import { getSampleRate, playSequence, stopAll } from './audio'
7import { getMorphPlayer, type MorphPlayer } from './morph'
8import { amplitudePresets, degToRad, outputGain, peakBound, randomAmplitudes, randomPhasesDeg, renderTone, resized, round, zeros, type ToneParams } from './synth'
10const COLOR_A = '#1f5fa8'
11const COLOR_B = '#d9641e'
12const HARMONIC_CHOICES = [4, 8, 12, 16]
13const DURATION_CHOICES = [0.5, 1, 2]
14const CYCLING = 'cycling tone'
16export default function App() {
17 const [f0, setF0] = useState(220)
18 const [amplitudes, setAmplitudes] = useState<number[]>(() => amplitudePresets[0].make(8))
19 const [phasesA, setPhasesA] = useState<number[]>(() => zeros(8))
20 const [phasesB, setPhasesB] = useState<number[]>(() => randomPhasesDeg(8))
21 const [duration, setDuration] = useState(1)
22 const [playing, setPlaying] = useState<string | null>(null)
23 const [cycleHz, setCycleHz] = useState(0.5)
24 const [morph, setMorph] = useState<MorphPlayer | null>(null)
25 const [morphError, setMorphError] = useState<string | null>(null)
26 const morphRef = useRef<MorphPlayer | null>(null)
28 const n = amplitudes.length
29 const toneA: ToneParams = { f0, amplitudes, phases: phasesA.map(degToRad) }
30 const toneB: ToneParams = { f0, amplitudes, phases: phasesB.map(degToRad) }
31 const yMax = round(Math.max(peakBound(amplitudes), 1e-6), 2)
33 const newRandomExample = () => {
34 setAmplitudes(randomAmplitudes(n))
35 setPhasesA(randomPhasesDeg(n))
36 setPhasesB(randomPhasesDeg(n))
37 }
39 const setHarmonicCount = (m: number) => {
40 setAmplitudes((a) => resized(a, m))
41 setPhasesA((p) => resized(p, m))
42 setPhasesB((p) => resized(p, m))
43 }
45 const cycling = playing === CYCLING
46 const cycleParams = { f0, amplitudes, phasesA: toneA.phases, phasesB: toneB.phases, cycleHz, gain: outputGain(amplitudes) }
48 const stopCycle = useCallback(() => {
49 morphRef.current?.stop()
50 setPlaying((p) => (p === CYCLING ? null : p))
51 }, [])
53 const startCycle = async () => {
54 stopAll()
55 setMorphError(null)
56 let m = morphRef.current
57 if (!m) {
58 try {
59 m = await getMorphPlayer()
60 } catch (err) {
61 setMorphError(`Could not start the AudioWorklet: ${err instanceof Error ? err.message : String(err)}`)
62 return
63 }
64 morphRef.current = m
65 setMorph(m)
66 }
67 m.start(cycleParams)
68 setPlaying(CYCLING)
69 }
71 // Live updates while the cycling tone plays.
72 useEffect(() => {
73 if (cycling) morphRef.current?.update(cycleParams)
74 // eslint-disable-next-line react-hooks/exhaustive-deps
75 }, [cycling, f0, amplitudes, phasesA, phasesB, cycleHz])
77 const stop = () => {
78 if (cycling) stopCycle()
79 else stopAll()
80 }
82 const play = useCallback(
83 async (label: string, tones: ToneParams[]) => {
84 morphRef.current?.stop()
85 const sr = getSampleRate()
86 const buffers = tones.map((t) => renderTone(t, sr, duration))
87 setPlaying(label)
88 try {
89 await playSequence(buffers)
90 } finally {
91 setPlaying(null)
92 }
93 },
94 [duration],
95 )
96 const busy = playing !== null
98 return (
99 <div className="app">
100 <header className="app-header">
101 <h1>Phase Perception</h1>
102 <p>
103 Two tones with the same fundamental frequency and the same harmonic amplitudes, but different phases, have the same spectrum
104 and (by Parseval) the same loudness. Do they sound the same? Edit the phases below, listen, then run the blind test.
105 </p>
106 </header>
108 <section className="card shared">
109 <h2>Shared by both tones</h2>
110 <div className="global-controls">
111 <label>
112 <span>
113 Fundamental f<sub>0</sub>
114 </span>
115 <input type="range" className="f0-range" min={55} max={880} step={1} value={f0} onChange={(e) => setF0(Number(e.target.value))} />
116 <input type="number" className="slider-value" min={20} max={4000} value={f0} onChange={(e) => setF0(Math.max(20, Number(e.target.value) || 20))} />
117 <span className="slider-unit">Hz</span>
118 </label>
119 <label>
120 <span>Harmonics</span>
121 <select value={n} onChange={(e) => setHarmonicCount(Number(e.target.value))}>
122 {HARMONIC_CHOICES.map((m) => (
123 <option key={m} value={m}>
124 {m}
125 </option>
126 ))}
127 </select>
128 </label>
129 <label>
130 <span>Duration</span>
131 <select value={duration} onChange={(e) => setDuration(Number(e.target.value))}>
132 {DURATION_CHOICES.map((d) => (
133 <option key={d} value={d}>
134 {d} s
135 </option>
136 ))}
137 </select>
138 </label>
139 <label>
140 <span>Amplitude preset</span>
141 <select value="" onChange={(e) => { const p = amplitudePresets.find((q) => q.name === e.target.value); if (p) setAmplitudes(p.make(n)) }}>
142 <option value="" disabled>
143 Choose…
144 </option>
145 {amplitudePresets.map((p) => (
146 <option key={p.name} value={p.name}>
147 {p.name}
148 </option>
149 ))}
150 </select>
151 </label>
152 <button onClick={newRandomExample}>New random example</button>
153 </div>
155 <div className="controls-header">
156 <h3>Harmonic amplitudes</h3>
157 <div className="button-row">
158 <button className="primary" onClick={() => play('A then B', [toneA, toneB])} disabled={busy}>
159 Play A then B
160 </button>
161 <button className="primary" onClick={() => play('B then A', [toneB, toneA])} disabled={busy}>
162 Play B then A
163 </button>
164 <button onClick={stop} disabled={!busy}>
165 Stop
166 </button>
167 <span className="playing-indicator">{playing ? `Playing ${playing}…` : ''}</span>
168 </div>
169 </div>
170 <HarmonicSliders
171 values={amplitudes}
172 min={0}
173 max={1}
174 step={0.01}
175 onChange={(k, v) => setAmplitudes(amplitudes.map((a, i) => (i === k ? v : a)))}
176 />
177 </section>
179 <div className="columns">
180 <ToneColumn
181 name="A"
182 otherName="B"
183 color={COLOR_A}
184 amplitudes={amplitudes}
185 phasesDeg={phasesA}
186 setPhasesDeg={setPhasesA}
187 onCopyFromOther={() => setPhasesA([...phasesB])}
188 onPlay={() => play('A', [toneA])}
189 playing={busy}
190 yMax={yMax}
191 />
192 <ToneColumn
193 name="B"
194 otherName="A"
195 color={COLOR_B}
196 amplitudes={amplitudes}
197 phasesDeg={phasesB}
198 setPhasesDeg={setPhasesB}
199 onCopyFromOther={() => setPhasesB([...phasesA])}
200 onPlay={() => play('B', [toneB])}
201 playing={busy}
202 yMax={yMax}
203 />
204 </div>
206 <CyclePanel
207 active={cycling}
208 busy={busy}
209 cycleHz={cycleHz}
210 setCycleHz={setCycleHz}
211 onStart={() => void startCycle()}
212 onStop={stopCycle}
213 player={morph}
214 error={morphError}
215 />
217 <BlindTest toneA={toneA} toneB={toneB} play={play} busy={busy} />
219 <footer className="app-footer">
220 Both tones are scaled by the same gain, computed from the amplitudes alone, so they have identical RMS level and never clip.
221 Tones are played monaurally with 10 ms fades.
222 </footer>
223 </div>
224 )
↑↓ move↵openescclose