import { useCallback, useEffect, useRef, useState } from 'react' import { BlindTest } from './components/BlindTest' import { CyclePanel } from './components/CyclePanel' import { HarmonicSliders } from './components/HarmonicSliders' import { ToneColumn } from './components/ToneColumn' import { getSampleRate, playSequence, stopAll } from './audio' import { getMorphPlayer, type MorphPlayer } from './morph' import { amplitudePresets, degToRad, outputGain, peakBound, randomAmplitudes, randomPhasesDeg, renderTone, resized, round, zeros, type ToneParams } from './synth' const COLOR_A = '#1f5fa8' const COLOR_B = '#d9641e' const HARMONIC_CHOICES = [4, 8, 12, 16] const DURATION_CHOICES = [0.5, 1, 2] const CYCLING = 'cycling tone' export default function App() { const [f0, setF0] = useState(220) const [amplitudes, setAmplitudes] = useState(() => amplitudePresets[0].make(8)) const [phasesA, setPhasesA] = useState(() => zeros(8)) const [phasesB, setPhasesB] = useState(() => randomPhasesDeg(8)) const [duration, setDuration] = useState(1) const [playing, setPlaying] = useState(null) const [cycleHz, setCycleHz] = useState(0.5) const [morph, setMorph] = useState(null) const [morphError, setMorphError] = useState(null) const morphRef = useRef(null) const n = amplitudes.length const toneA: ToneParams = { f0, amplitudes, phases: phasesA.map(degToRad) } const toneB: ToneParams = { f0, amplitudes, phases: phasesB.map(degToRad) } const yMax = round(Math.max(peakBound(amplitudes), 1e-6), 2) const newRandomExample = () => { setAmplitudes(randomAmplitudes(n)) setPhasesA(randomPhasesDeg(n)) setPhasesB(randomPhasesDeg(n)) } const setHarmonicCount = (m: number) => { setAmplitudes((a) => resized(a, m)) setPhasesA((p) => resized(p, m)) setPhasesB((p) => resized(p, m)) } const cycling = playing === CYCLING const cycleParams = { f0, amplitudes, phasesA: toneA.phases, phasesB: toneB.phases, cycleHz, gain: outputGain(amplitudes) } const stopCycle = useCallback(() => { morphRef.current?.stop() setPlaying((p) => (p === CYCLING ? null : p)) }, []) const startCycle = async () => { stopAll() setMorphError(null) let m = morphRef.current if (!m) { try { m = await getMorphPlayer() } catch (err) { setMorphError(`Could not start the AudioWorklet: ${err instanceof Error ? err.message : String(err)}`) return } morphRef.current = m setMorph(m) } m.start(cycleParams) setPlaying(CYCLING) } // Live updates while the cycling tone plays. useEffect(() => { if (cycling) morphRef.current?.update(cycleParams) // eslint-disable-next-line react-hooks/exhaustive-deps }, [cycling, f0, amplitudes, phasesA, phasesB, cycleHz]) const stop = () => { if (cycling) stopCycle() else stopAll() } const play = useCallback( async (label: string, tones: ToneParams[]) => { morphRef.current?.stop() const sr = getSampleRate() const buffers = tones.map((t) => renderTone(t, sr, duration)) setPlaying(label) try { await playSequence(buffers) } finally { setPlaying(null) } }, [duration], ) const busy = playing !== null return (

Phase Perception

Two tones with the same fundamental frequency and the same harmonic amplitudes, but different phases, have the same spectrum and (by Parseval) the same loudness. Do they sound the same? Edit the phases below, listen, then run the blind test.

Shared by both tones

Harmonic amplitudes

{playing ? `Playing ${playing}…` : ''}
setAmplitudes(amplitudes.map((a, i) => (i === k ? v : a)))} />
setPhasesA([...phasesB])} onPlay={() => play('A', [toneA])} playing={busy} yMax={yMax} /> setPhasesB([...phasesA])} onPlay={() => play('B', [toneB])} playing={busy} yMax={yMax} />
void startCycle()} onStop={stopCycle} player={morph} error={morphError} />
) }