/ proof-of-concept / phase-perception
Sign in
proof-of-concept / phase-perception
Phase perception app: tone editor, blind same/different test, cycling tone, and site build
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 3118485781a6 Browse files
28 changed files+5176−0
.github/workflows/deploy.ymladded+53−0View file
@@ -0,0 +1,53 @@
1+name: Build and deploy site
2+
3+on:
4+ push:
5+ branches:
6+ - main
7+ workflow_dispatch:
8+
9+permissions:
10+ contents: read
11+ pages: write
12+ id-token: write
13+
14+concurrency:
15+ group: "pages"
16+ cancel-in-progress: false
17+
18+jobs:
19+ build:
20+ runs-on: ubuntu-latest
21+ steps:
22+ - name: Checkout
23+ uses: actions/checkout@v4
24+
25+ - name: Setup Node
26+ uses: actions/setup-node@v4
27+ with:
28+ node-version: '22'
29+
30+ - name: Install dependencies
31+ run: npm ci
32+
33+ - name: Lint
34+ run: npm run lint
35+
36+ - name: Build
37+ run: npm run build
38+
39+ - name: Upload artifact
40+ uses: actions/upload-pages-artifact@v3
41+ with:
42+ path: ./dist
43+
44+ deploy:
45+ environment:
46+ name: github-pages
47+ url: ${{ steps.deployment.outputs.page_url }}
48+ runs-on: ubuntu-latest
49+ needs: build
50+ steps:
51+ - name: Deploy site
52+ id: deployment
53+ uses: actions/deploy-pages@v4
.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+26−0View file
@@ -0,0 +1,26 @@
1+# Phase Perception
2+
3+[Open the live app](https://vault1.magland.org/proof-of-concept/phase-perception/site/)
4+
5+A small web app for testing whether a listener can hear the difference between two periodic tones that have the same harmonic amplitudes but different harmonic phases.
6+
7+Both tones are built by additive synthesis from a fundamental frequency f0 and N harmonics: x(t) = sum_k a_k sin(2 pi k f0 t + phi_k). The amplitudes a_k are shared between tone A and tone B; each tone has its own phases phi_k. The app plots one period of each waveform together with the amplitude and phase spectra, so you can see that the waveforms differ while the amplitude spectra do not.
8+
9+The hypothesis under test is that the phases are perceptually irrelevant: the two tones should sound identical. The blind test checks this. Each trial plays two tones in a row, either the same tone twice or A and B in random order, and you answer "same" or "different". The app tallies accuracy and reports the one-sided binomial probability of doing at least that well by guessing.
10+
11+There is also a continuous mode: a steady tone whose harmonic phases glide from A's values to B's and back, following a raised cosine at an adjustable rate. Amplitudes stay fixed, so any audible change is due to phase alone. This mode runs in an AudioWorklet, and edits to the phases, amplitudes, fundamental, or rate take effect while it plays.
12+
13+## Running
14+
15+ npm install
16+ npm run dev
17+
18+Then open the printed URL. `npm run build` produces a static site in `dist/` with a relative base path, so it can be served from any subdirectory.
19+
20+## Notes
21+
22+Both tones are scaled by the same output gain, computed from the amplitudes alone, so they have identical RMS level regardless of phase and cannot clip. Tones are monaural with 10 ms raised-cosine fades.
23+
24+Phase is not always inaudible. Phase differences between harmonics become audible when several harmonics fall within one auditory filter, which happens at low fundamental frequencies (roughly below 100 Hz) or with many high harmonics, and are more easily heard over headphones than loudspeakers. The controls for f0 and the number of harmonics are there to explore these conditions.
25+
26+A planned next step is to extend the test from steady tones to more complex, time-varying sounds.
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: can you hear the difference between tones with identical harmonic amplitudes but different phases?" />
8+ <title>Phase Perception</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",
3+ "private": true,
4+ "version": "0.1.0",
5+ "type": "module",
6+ "description": "Can you hear the phase? A perceptual test of whether tones with identical harmonic amplitudes but different phases sound different",
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+225−0View file
@@ -0,0 +1,225 @@
1+import { useCallback, useEffect, useRef, useState } from 'react'
2+import { BlindTest } from './components/BlindTest'
3+import { CyclePanel } from './components/CyclePanel'
4+import { HarmonicSliders } from './components/HarmonicSliders'
5+import { ToneColumn } from './components/ToneColumn'
6+import { getSampleRate, playSequence, stopAll } from './audio'
7+import { getMorphPlayer, type MorphPlayer } from './morph'
8+import { amplitudePresets, degToRad, outputGain, peakBound, randomAmplitudes, randomPhasesDeg, renderTone, resized, round, zeros, type ToneParams } from './synth'
9+
10+const COLOR_A = '#1f5fa8'
11+const COLOR_B = '#d9641e'
12+const HARMONIC_CHOICES = [4, 8, 12, 16]
13+const DURATION_CHOICES = [0.5, 1, 2]
14+const CYCLING = 'cycling tone'
15+
16+export 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)
27+
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)
32+
33+ const newRandomExample = () => {
34+ setAmplitudes(randomAmplitudes(n))
35+ setPhasesA(randomPhasesDeg(n))
36+ setPhasesB(randomPhasesDeg(n))
37+ }
38+
39+ const setHarmonicCount = (m: number) => {
40+ setAmplitudes((a) => resized(a, m))
41+ setPhasesA((p) => resized(p, m))
42+ setPhasesB((p) => resized(p, m))
43+ }
44+
45+ const cycling = playing === CYCLING
46+ const cycleParams = { f0, amplitudes, phasesA: toneA.phases, phasesB: toneB.phases, cycleHz, gain: outputGain(amplitudes) }
47+
48+ const stopCycle = useCallback(() => {
49+ morphRef.current?.stop()
50+ setPlaying((p) => (p === CYCLING ? null : p))
51+ }, [])
52+
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+ }
70+
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])
76+
77+ const stop = () => {
78+ if (cycling) stopCycle()
79+ else stopAll()
80+ }
81+
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
97+
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>
107+
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>
154+
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>
178+
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>
205+
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+ />
216+
217+ <BlindTest toneA={toneA} toneB={toneB} play={play} busy={busy} />
218+
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+ )
225+}
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/components/BlindTest.tsxadded+169−0View file
@@ -0,0 +1,169 @@
1+import { useCallback, useEffect, useRef, useState } from 'react'
2+import type { ToneParams } from '../synth'
3+import { binomialTailP, formatP } from '../stats'
4+
5+type Which = 'A' | 'B'
6+type Trial = { order: [Which, Which] }
7+type Answer = 'same' | 'different'
8+type Result = { trial: Trial; answer: Answer; correct: boolean }
9+type Phase = 'idle' | 'playing' | 'awaiting' | 'answered'
10+
11+const isSame = (t: Trial) => t.order[0] === t.order[1]
12+
13+/**
14+ * Half the trials play the same tone twice (A A or B B), the other half play
15+ * both tones (A B or B A). Which kind, and the order, are chosen at random.
16+ */
17+function makeTrial(): Trial {
18+ const same = Math.random() < 0.5
19+ const first: Which = Math.random() < 0.5 ? 'A' : 'B'
20+ const second: Which = same ? first : first === 'A' ? 'B' : 'A'
21+ return { order: [first, second] }
22+}
23+
24+type Props = {
25+ toneA: ToneParams
26+ toneB: ToneParams
27+ play: (label: string, tones: ToneParams[]) => Promise<void>
28+ busy: boolean
29+}
30+
31+export function BlindTest({ toneA, toneB, play, busy }: Props) {
32+ const [trial, setTrial] = useState<Trial | null>(null)
33+ const [phase, setPhase] = useState<Phase>('idle')
34+ const [results, setResults] = useState<Result[]>([])
35+ const [last, setLast] = useState<Result | null>(null)
36+
37+ // Latest props in refs so keyboard handlers never see stale closures.
38+ const latest = useRef({ toneA, toneB, play, trial, phase, busy })
39+ latest.current = { toneA, toneB, play, trial, phase, busy }
40+
41+ const playTrial = useCallback(async (t: Trial, after: Phase) => {
42+ const { toneA, toneB, play } = latest.current
43+ const tones = t.order.map((w) => (w === 'A' ? toneA : toneB))
44+ setPhase('playing')
45+ await play('trial', tones)
46+ setPhase(after)
47+ }, [])
48+
49+ const startTrial = useCallback(() => {
50+ const t = makeTrial()
51+ setTrial(t)
52+ setLast(null)
53+ void playTrial(t, 'awaiting')
54+ }, [playTrial])
55+
56+ const replay = useCallback(() => {
57+ const { trial, phase } = latest.current
58+ if (!trial || phase === 'playing') return
59+ void playTrial(trial, phase === 'answered' ? 'answered' : 'awaiting')
60+ }, [playTrial])
61+
62+ const answer = useCallback((a: Answer) => {
63+ const { trial, phase } = latest.current
64+ if (!trial || phase !== 'awaiting') return
65+ const correct = (a === 'same') === isSame(trial)
66+ const r = { trial, answer: a, correct }
67+ setResults((rs) => [...rs, r])
68+ setLast(r)
69+ setPhase('answered')
70+ }, [])
71+
72+ useEffect(() => {
73+ const onKey = (e: KeyboardEvent) => {
74+ const tag = (e.target as HTMLElement | null)?.tagName
75+ if (tag === 'INPUT' || tag === 'TEXTAREA' || e.metaKey || e.ctrlKey || e.altKey) return
76+ const { phase, busy } = latest.current
77+ switch (e.key.toLowerCase()) {
78+ case 'n':
79+ if (phase !== 'playing' && !busy) startTrial()
80+ break
81+ case 'r':
82+ replay()
83+ break
84+ case 's':
85+ answer('same')
86+ break
87+ case 'd':
88+ answer('different')
89+ break
90+ }
91+ }
92+ window.addEventListener('keydown', onKey)
93+ return () => window.removeEventListener('keydown', onKey)
94+ }, [startTrial, replay, answer])
95+
96+ const n = results.length
97+ const k = results.filter((r) => r.correct).length
98+ const diff = results.filter((r) => !isSame(r.trial))
99+ const same = results.filter((r) => isSame(r.trial))
100+ const p = binomialTailP(k, n)
101+ const canStart = !busy && phase !== 'playing'
102+ const canAnswer = phase === 'awaiting'
103+
104+ return (
105+ <section className="card blind">
106+ <h2>Blind test: same or different?</h2>
107+ <p className="hint">
108+ Each trial plays two tones in a row. Half the time they are the same tone (A A or B B); half the time they are different (A B or B A).
109+ Decide whether the two sounded the same or different. If phase is inaudible, your accuracy should stay near 50%.
110+ Keys: <kbd>N</kbd> next trial, <kbd>R</kbd> replay, <kbd>S</kbd> same, <kbd>D</kbd> different.
111+ </p>
112+
113+ <div className="button-row blind-controls">
114+ <button className="primary" onClick={startTrial} disabled={!canStart}>
115+ {trial ? 'Next trial' : 'Start trial'}
116+ </button>
117+ <button onClick={replay} disabled={!trial || phase === 'playing'}>
118+ Replay
119+ </button>
120+ <span className="spacer" />
121+ <button className="answer" onClick={() => answer('same')} disabled={!canAnswer}>
122+ Same
123+ </button>
124+ <button className="answer" onClick={() => answer('different')} disabled={!canAnswer}>
125+ Different
126+ </button>
127+ </div>
128+
129+ <div className="feedback" aria-live="polite">
130+ {phase === 'playing' && 'Listen…'}
131+ {phase === 'awaiting' && 'Same or different?'}
132+ {phase === 'answered' && last && (
133+ <span className={last.correct ? 'ok' : 'bad'}>
134+ {last.correct ? 'Correct.' : 'Incorrect.'} The tones were {isSame(last.trial) ? 'the same' : 'different'} ({last.trial.order[0]} then {last.trial.order[1]}).
135+ </span>
136+ )}
137+ </div>
138+
139+ <div className="stats">
140+ <div>
141+ <span className="stat-label">Correct</span>
142+ <span className="stat-value">
143+ {k} / {n}
144+ {n > 0 && <small> ({Math.round((100 * k) / n)}%)</small>}
145+ </span>
146+ </div>
147+ <div>
148+ <span className="stat-label">"Different" trials caught</span>
149+ <span className="stat-value">
150+ {diff.filter((r) => r.correct).length} / {diff.length}
151+ </span>
152+ </div>
153+ <div>
154+ <span className="stat-label">"Same" trials caught</span>
155+ <span className="stat-value">
156+ {same.filter((r) => r.correct).length} / {same.length}
157+ </span>
158+ </div>
159+ <div>
160+ <span className="stat-label">P(this many or more correct by guessing)</span>
161+ <span className="stat-value">{n > 0 ? formatP(p) : '–'}</span>
162+ </div>
163+ <button className="small" onClick={() => { setResults([]); setLast(null); setTrial(null); setPhase('idle') }} disabled={n === 0 && !trial}>
164+ Reset
165+ </button>
166+ </div>
167+ </section>
168+ )
169+}
src/components/CyclePanel.tsxadded+100−0View file
@@ -0,0 +1,100 @@
1+import { useEffect, useState } from 'react'
2+import type { MorphPlayer } from '../morph'
3+
4+type Props = {
5+ active: boolean
6+ busy: boolean
7+ cycleHz: number
8+ setCycleHz: (hz: number) => void
9+ onStart: () => void
10+ onStop: () => void
11+ player: MorphPlayer | null
12+ error: string | null
13+}
14+
15+const LOG_MIN = -2 // 0.01 Hz
16+const LOG_MAX = 2 // 100 Hz
17+const BOX_MIN = 0.001
18+const BOX_MAX = 1000
19+
20+/** Round to three significant figures so slider positions give tidy numbers. */
21+const sig3 = (x: number) => Number(x.toPrecision(3))
22+
23+const formatPeriod = (t: number) => {
24+ if (t >= 100) return `${t.toFixed(0)} s`
25+ if (t >= 10) return `${t.toFixed(1)} s`
26+ if (t >= 1) return `${t.toFixed(2)} s`
27+ return `${Number((t * 1000).toPrecision(3))} ms`
28+}
29+
30+/** Controls for the continuous tone whose phases cycle between A and B. */
31+export function CyclePanel({ active, busy, cycleHz, setCycleHz, onStart, onStop, player, error }: Props) {
32+ const [pos, setPos] = useState(0)
33+
34+ useEffect(() => {
35+ if (!active || !player) {
36+ setPos(0)
37+ return
38+ }
39+ return player.subscribe(setPos)
40+ }, [active, player])
41+
42+ const period = 1 / cycleHz
43+
44+ return (
45+ <section className="card cycle">
46+ <h2>Continuous tone cycling between A and B</h2>
47+ <p className="hint">
48+ A steady tone whose harmonic phases glide smoothly from A's values to B's and back, following a raised cosine at the rate below.
49+ The amplitudes never change, so any change you hear is due to phase alone. Phase, amplitude, f<sub>0</sub>, and rate edits take
50+ effect while it plays.
51+ </p>
52+ <div className="cycle-controls">
53+ {active ? (
54+ <button className="primary" onClick={onStop}>
55+ Stop
56+ </button>
57+ ) : (
58+ <button className="primary" onClick={onStart} disabled={busy}>
59+ Play cycling tone
60+ </button>
61+ )}
62+ <label>
63+ <span>Cycle rate</span>
64+ <input
65+ type="range"
66+ className="cycle-range"
67+ min={LOG_MIN}
68+ max={LOG_MAX}
69+ step={0.01}
70+ value={Math.min(LOG_MAX, Math.max(LOG_MIN, Math.log10(cycleHz)))}
71+ onChange={(e) => setCycleHz(sig3(10 ** Number(e.target.value)))}
72+ aria-label="cycle rate, logarithmic"
73+ />
74+ <input
75+ type="number"
76+ className="slider-value"
77+ min={BOX_MIN}
78+ max={BOX_MAX}
79+ step="any"
80+ value={cycleHz}
81+ onChange={(e) => {
82+ const x = Number(e.target.value)
83+ if (Number.isFinite(x) && x > 0) setCycleHz(Math.min(BOX_MAX, Math.max(BOX_MIN, x)))
84+ }}
85+ />
86+ <span className="slider-unit">Hz</span>
87+ <span className="cycle-period">(period {formatPeriod(period)})</span>
88+ </label>
89+ </div>
90+ <div className="cycle-position" aria-label="cycle position">
91+ <span className="cycle-end cycle-a">A</span>
92+ <div className="cycle-track">
93+ <div className="cycle-marker" style={{ left: `${pos * 100}%`, opacity: active ? 1 : 0.35 }} />
94+ </div>
95+ <span className="cycle-end cycle-b">B</span>
96+ </div>
97+ {error && <div className="error">{error}</div>}
98+ </section>
99+ )
100+}
src/components/HarmonicSliders.tsxadded+35−0View file
@@ -0,0 +1,35 @@
1+type Props = {
2+ values: number[]
3+ min: number
4+ max: number
5+ step: number
6+ unit?: string
7+ onChange: (index: number, value: number) => void
8+}
9+
10+/** One labelled slider per harmonic, with a numeric box for exact entry. */
11+export function HarmonicSliders({ values, min, max, step, unit, onChange }: Props) {
12+ return (
13+ <div className="slider-grid">
14+ {values.map((v, k) => (
15+ <div className="slider-row" key={k}>
16+ <span className="slider-label">{k + 1}</span>
17+ <input type="range" min={min} max={max} step={step} value={v} onChange={(e) => onChange(k, Number(e.target.value))} />
18+ <input
19+ type="number"
20+ className="slider-value"
21+ min={min}
22+ max={max}
23+ step={step}
24+ value={v}
25+ onChange={(e) => {
26+ const x = Number(e.target.value)
27+ if (Number.isFinite(x)) onChange(k, Math.min(max, Math.max(min, x)))
28+ }}
29+ />
30+ {unit && <span className="slider-unit">{unit}</span>}
31+ </div>
32+ ))}
33+ </div>
34+ )
35+}
src/components/SpectrumPlot.tsxadded+25−0View file
@@ -0,0 +1,25 @@
1+import { StemPlot } from './StemPlot'
2+
3+type Props = {
4+ amplitudes: number[]
5+ /** degrees */
6+ phasesDeg: number[]
7+ color: string
8+}
9+
10+const PHASE_TICKS = [-180, -90, 0, 90, 180]
11+const degLabel = (v: number) => `${v}°`
12+
13+/** Fourier coefficients of the tone: amplitude and phase per harmonic. */
14+export function SpectrumPlot({ amplitudes, phasesDeg, color }: Props) {
15+ const aMax = Math.max(1, ...amplitudes)
16+ const aTicks = [0, aMax / 2, aMax]
17+ return (
18+ <div className="spectrum">
19+ <div className="plot-label">Amplitude by harmonic</div>
20+ <StemPlot values={amplitudes} yMin={0} yMax={aMax * 1.05} yTicks={aTicks} color={color} tickLabel={(v) => v.toFixed(1)} />
21+ <div className="plot-label">Phase by harmonic</div>
22+ <StemPlot values={phasesDeg} yMin={-190} yMax={190} yTicks={PHASE_TICKS} color={color} tickLabel={degLabel} />
23+ </div>
24+ )
25+}
src/components/StemPlot.tsxadded+72−0View file
@@ -0,0 +1,72 @@
1+import { useCallback } from 'react'
2+import { useCanvas } from '../hooks/useCanvas'
3+
4+type Props = {
5+ values: number[]
6+ yMin: number
7+ yMax: number
8+ yTicks: number[]
9+ color: string
10+ tickLabel?: (v: number) => string
11+}
12+
13+const M = { l: 40, r: 10, t: 8, b: 22 }
14+
15+/** Discrete spectrum as stems at harmonic numbers 1..N. */
16+export function StemPlot({ values, yMin, yMax, yTicks, color, tickLabel }: Props) {
17+ const draw = useCallback(
18+ (ctx: CanvasRenderingContext2D, w: number, h: number) => {
19+ const pw = w - M.l - M.r
20+ const ph = h - M.t - M.b
21+ const n = values.length
22+ const toY = (v: number) => M.t + ph - ((v - yMin) / (yMax - yMin)) * ph
23+ const toX = (k: number) => M.l + (pw * (k + 0.5)) / n
24+ const fmt = tickLabel ?? ((v: number) => String(v))
25+
26+ ctx.font = '11px system-ui, sans-serif'
27+ ctx.lineWidth = 1
28+ ctx.strokeStyle = '#ddd'
29+ ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph)
30+
31+ // horizontal grid + tick labels
32+ ctx.fillStyle = '#666'
33+ ctx.textAlign = 'right'
34+ ctx.textBaseline = 'middle'
35+ for (const v of yTicks) {
36+ const y = Math.round(toY(v)) + 0.5
37+ ctx.strokeStyle = v === 0 ? '#bbb' : '#eee'
38+ ctx.beginPath()
39+ ctx.moveTo(M.l, y)
40+ ctx.lineTo(M.l + pw, y)
41+ ctx.stroke()
42+ ctx.fillText(fmt(v), M.l - 4, y)
43+ }
44+
45+ // x labels (harmonic number)
46+ ctx.textAlign = 'center'
47+ ctx.textBaseline = 'top'
48+ const step = n > 16 ? 2 : 1
49+ for (let k = 0; k < n; k += step) ctx.fillText(String(k + 1), toX(k), M.t + ph + 4)
50+
51+ // stems
52+ const base = toY(Math.min(Math.max(0, yMin), yMax))
53+ ctx.strokeStyle = color
54+ ctx.fillStyle = color
55+ ctx.lineWidth = 2
56+ for (let k = 0; k < n; k++) {
57+ const x = toX(k)
58+ const y = toY(values[k])
59+ ctx.beginPath()
60+ ctx.moveTo(x, base)
61+ ctx.lineTo(x, y)
62+ ctx.stroke()
63+ ctx.beginPath()
64+ ctx.arc(x, y, 3.5, 0, 2 * Math.PI)
65+ ctx.fill()
66+ }
67+ },
68+ [values, yMin, yMax, yTicks, color, tickLabel],
69+ )
70+ const ref = useCanvas(draw)
71+ return <canvas ref={ref} className="plot stem-plot" />
72+}
src/components/ToneColumn.tsxadded+55−0View file
@@ -0,0 +1,55 @@
1+import { HarmonicSliders } from './HarmonicSliders'
2+import { SpectrumPlot } from './SpectrumPlot'
3+import { WaveformPlot } from './WaveformPlot'
4+import { degToRad, randomPhasesDeg, zeros } from '../synth'
5+
6+type Props = {
7+ name: 'A' | 'B'
8+ otherName: 'A' | 'B'
9+ color: string
10+ amplitudes: number[]
11+ phasesDeg: number[]
12+ setPhasesDeg: (p: number[]) => void
13+ onCopyFromOther: () => void
14+ onPlay: () => void
15+ playing: boolean
16+ yMax: number
17+}
18+
19+/** Editor and plots for one tone. Amplitudes are shared; only phases are edited here. */
20+export function ToneColumn({ name, otherName, color, amplitudes, phasesDeg, setPhasesDeg, onCopyFromOther, onPlay, playing, yMax }: Props) {
21+ const n = amplitudes.length
22+ const phases = phasesDeg.map(degToRad)
23+ return (
24+ <section className="card tone" style={{ borderTopColor: color }}>
25+ <header className="tone-header">
26+ <h2 style={{ color }}>Tone {name}</h2>
27+ <button className="primary" onClick={onPlay} disabled={playing}>
28+ Play {name}
29+ </button>
30+ </header>
31+
32+ <div className="plot-label">One period of the waveform</div>
33+ <WaveformPlot amplitudes={amplitudes} phases={phases} color={color} yMax={yMax} />
34+
35+ <SpectrumPlot amplitudes={amplitudes} phasesDeg={phasesDeg} color={color} />
36+
37+ <div className="controls-header">
38+ <h3>Phases</h3>
39+ <div className="button-row">
40+ <button onClick={() => setPhasesDeg(zeros(n))}>Zero</button>
41+ <button onClick={() => setPhasesDeg(randomPhasesDeg(n))}>Random</button>
42+ <button onClick={onCopyFromOther}>Copy from {otherName}</button>
43+ </div>
44+ </div>
45+ <HarmonicSliders
46+ values={phasesDeg}
47+ min={-180}
48+ max={180}
49+ step={1}
50+ unit="°"
51+ onChange={(k, v) => setPhasesDeg(phasesDeg.map((p, i) => (i === k ? v : p)))}
52+ />
53+ </section>
54+ )
55+}
src/components/WaveformPlot.tsxadded+77−0View file
@@ -0,0 +1,77 @@
1+import { useCallback } from 'react'
2+import { useCanvas } from '../hooks/useCanvas'
3+import { renderPeriod } from '../synth'
4+
5+type Props = {
6+ amplitudes: number[]
7+ /** radians */
8+ phases: number[]
9+ color: string
10+ /** vertical half-range; pass the same value for both tones so they share a scale */
11+ yMax: number
12+}
13+
14+const M = { l: 40, r: 10, t: 10, b: 22 }
15+
16+/** One period of the waveform x(t), unscaled. */
17+export function WaveformPlot({ amplitudes, phases, color, yMax }: Props) {
18+ const draw = useCallback(
19+ (ctx: CanvasRenderingContext2D, w: number, h: number) => {
20+ const pw = w - M.l - M.r
21+ const ph = h - M.t - M.b
22+ const y0 = M.t + ph / 2
23+ const ymax = yMax > 0 ? yMax : 1
24+ const toY = (v: number) => y0 - (v / ymax) * (ph / 2)
25+
26+ ctx.font = '11px system-ui, sans-serif'
27+ ctx.fillStyle = '#666'
28+ ctx.strokeStyle = '#ddd'
29+ ctx.lineWidth = 1
30+
31+ // frame and zero line
32+ ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph)
33+ ctx.beginPath()
34+ ctx.moveTo(M.l, y0 + 0.5)
35+ ctx.lineTo(M.l + pw, y0 + 0.5)
36+ ctx.stroke()
37+ // quarter-period grid
38+ for (let q = 1; q < 4; q++) {
39+ const x = Math.round(M.l + (pw * q) / 4) + 0.5
40+ ctx.beginPath()
41+ ctx.moveTo(x, M.t)
42+ ctx.lineTo(x, M.t + ph)
43+ ctx.stroke()
44+ }
45+
46+ // ticks
47+ ctx.textAlign = 'right'
48+ ctx.textBaseline = 'middle'
49+ ctx.fillText(ymax.toFixed(1), M.l - 4, toY(ymax))
50+ ctx.fillText('0', M.l - 4, y0)
51+ ctx.fillText((-ymax).toFixed(1), M.l - 4, toY(-ymax))
52+ ctx.textAlign = 'center'
53+ ctx.textBaseline = 'top'
54+ ctx.fillText('0', M.l, M.t + ph + 4)
55+ ctx.fillText('T/2', M.l + pw / 2, M.t + ph + 4)
56+ ctx.fillText('T', M.l + pw, M.t + ph + 4)
57+
58+ // curve
59+ const n = Math.max(64, Math.round(pw * 2))
60+ const y = renderPeriod(amplitudes, phases, n)
61+ ctx.strokeStyle = color
62+ ctx.lineWidth = 1.8
63+ ctx.lineJoin = 'round'
64+ ctx.beginPath()
65+ for (let i = 0; i <= n; i++) {
66+ const px = M.l + (pw * i) / n
67+ const py = toY(y[i % n])
68+ if (i === 0) ctx.moveTo(px, py)
69+ else ctx.lineTo(px, py)
70+ }
71+ ctx.stroke()
72+ },
73+ [amplitudes, phases, color, yMax],
74+ )
75+ const ref = useCanvas(draw)
76+ return <canvas ref={ref} className="plot waveform-plot" />
77+}
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+385−0View file
@@ -0,0 +1,385 @@
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+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
11+ color: var(--text);
12+ background: var(--bg);
13+ line-height: 1.45;
14+}
15+
16+* {
17+ box-sizing: border-box;
18+}
19+
20+body {
21+ margin: 0;
22+}
23+
24+.app {
25+ max-width: 1100px;
26+ margin: 0 auto;
27+ padding: 16px 16px 48px;
28+}
29+
30+.app-header h1 {
31+ margin: 8px 0 4px;
32+ font-size: 1.7rem;
33+}
34+
35+.app-header p,
36+.hint {
37+ color: var(--muted);
38+ margin: 0 0 16px;
39+ max-width: 70em;
40+}
41+
42+.app-footer {
43+ color: var(--muted);
44+ font-size: 0.85rem;
45+ margin-top: 16px;
46+}
47+
48+.card {
49+ background: var(--card);
50+ border: 1px solid var(--border);
51+ border-radius: 8px;
52+ padding: 14px 16px 16px;
53+ margin-bottom: 16px;
54+}
55+
56+.card h2 {
57+ margin: 0 0 10px;
58+ font-size: 1.15rem;
59+}
60+
61+.card h3 {
62+ margin: 0;
63+ font-size: 0.95rem;
64+ color: var(--muted);
65+ font-weight: 600;
66+}
67+
68+.tone {
69+ border-top: 4px solid;
70+ flex: 1 1 380px;
71+ min-width: 0;
72+}
73+
74+.tone-header {
75+ display: flex;
76+ align-items: center;
77+ justify-content: space-between;
78+ gap: 8px;
79+}
80+
81+.columns {
82+ display: flex;
83+ gap: 16px;
84+ flex-wrap: wrap;
85+ margin-bottom: 16px;
86+}
87+
88+.columns .card {
89+ margin-bottom: 0;
90+}
91+
92+.plot {
93+ display: block;
94+ width: 100%;
95+ background: #fff;
96+}
97+
98+.waveform-plot {
99+ height: 170px;
100+}
101+
102+.stem-plot {
103+ height: 120px;
104+}
105+
106+.plot-label {
107+ font-size: 0.8rem;
108+ color: var(--muted);
109+ margin: 10px 0 2px;
110+}
111+
112+.controls-header {
113+ display: flex;
114+ align-items: center;
115+ justify-content: space-between;
116+ flex-wrap: wrap;
117+ gap: 8px;
118+ margin: 14px 0 6px;
119+}
120+
121+.button-row {
122+ display: flex;
123+ align-items: center;
124+ gap: 8px;
125+ flex-wrap: wrap;
126+}
127+
128+.spacer {
129+ flex: 1;
130+}
131+
132+button {
133+ font: inherit;
134+ font-size: 0.9rem;
135+ padding: 6px 12px;
136+ border-radius: 6px;
137+ border: 1px solid var(--border);
138+ background: #fff;
139+ color: var(--text);
140+ cursor: pointer;
141+}
142+
143+button:hover:not(:disabled) {
144+ background: #f0f2f5;
145+}
146+
147+button:disabled {
148+ opacity: 0.5;
149+ cursor: default;
150+}
151+
152+button.primary {
153+ background: var(--accent);
154+ border-color: var(--accent);
155+ color: #fff;
156+}
157+
158+button.primary:hover:not(:disabled) {
159+ background: #184c87;
160+}
161+
162+button.answer {
163+ font-weight: 600;
164+ min-width: 100px;
165+}
166+
167+button.small {
168+ font-size: 0.8rem;
169+ padding: 4px 10px;
170+}
171+
172+.playing-indicator {
173+ color: var(--muted);
174+ font-size: 0.9rem;
175+ min-width: 8em;
176+}
177+
178+.global-controls {
179+ display: flex;
180+ flex-wrap: wrap;
181+ gap: 10px 24px;
182+ align-items: center;
183+}
184+
185+.global-controls label {
186+ display: flex;
187+ align-items: center;
188+ gap: 8px;
189+ font-size: 0.9rem;
190+}
191+
192+.global-controls label > span {
193+ white-space: nowrap;
194+}
195+
196+.f0-range {
197+ width: 180px;
198+}
199+
200+select {
201+ font: inherit;
202+ font-size: 0.9rem;
203+ padding: 4px 6px;
204+ border-radius: 6px;
205+ border: 1px solid var(--border);
206+ background: #fff;
207+}
208+
209+.slider-grid {
210+ display: grid;
211+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
212+ gap: 2px 24px;
213+}
214+
215+.slider-row {
216+ display: grid;
217+ grid-template-columns: 1.6em 1fr 5.5em 1.2em;
218+ align-items: center;
219+ gap: 6px;
220+}
221+
222+.slider-label {
223+ font-variant-numeric: tabular-nums;
224+ color: var(--muted);
225+ font-size: 0.85rem;
226+ text-align: right;
227+}
228+
229+.slider-unit {
230+ color: var(--muted);
231+ font-size: 0.85rem;
232+}
233+
234+input[type='range'] {
235+ width: 100%;
236+ min-width: 80px;
237+ accent-color: var(--accent);
238+}
239+
240+.slider-value {
241+ font: inherit;
242+ font-size: 0.85rem;
243+ width: 5.5em;
244+ padding: 2px 4px;
245+ border: 1px solid var(--border);
246+ border-radius: 4px;
247+}
248+
249+.blind-controls {
250+ margin: 8px 0;
251+}
252+
253+.feedback {
254+ min-height: 1.6em;
255+ margin: 6px 0 12px;
256+ font-size: 0.95rem;
257+}
258+
259+.feedback .ok {
260+ color: var(--ok);
261+ font-weight: 600;
262+}
263+
264+.feedback .bad {
265+ color: var(--bad);
266+ font-weight: 600;
267+}
268+
269+.stats {
270+ display: flex;
271+ flex-wrap: wrap;
272+ gap: 8px 28px;
273+ align-items: flex-end;
274+ padding-top: 10px;
275+ border-top: 1px solid var(--border);
276+}
277+
278+.stats > div {
279+ display: flex;
280+ flex-direction: column;
281+}
282+
283+.stat-label {
284+ font-size: 0.75rem;
285+ color: var(--muted);
286+}
287+
288+.stat-value {
289+ font-size: 1.15rem;
290+ font-variant-numeric: tabular-nums;
291+}
292+
293+.stat-value small {
294+ font-size: 0.8rem;
295+ color: var(--muted);
296+}
297+
298+kbd {
299+ font-family: inherit;
300+ font-size: 0.85em;
301+ border: 1px solid var(--border);
302+ border-bottom-width: 2px;
303+ border-radius: 4px;
304+ padding: 0 5px;
305+ background: #fafbfc;
306+}
307+
308+.cycle-controls {
309+ display: flex;
310+ align-items: center;
311+ flex-wrap: wrap;
312+ gap: 8px 20px;
313+ margin: 4px 0 12px;
314+}
315+
316+.cycle-controls label {
317+ display: flex;
318+ align-items: center;
319+ gap: 8px;
320+ font-size: 0.9rem;
321+}
322+
323+.cycle-controls label > span {
324+ white-space: nowrap;
325+}
326+
327+.cycle-range {
328+ width: 220px;
329+}
330+
331+.cycle-period {
332+ color: var(--muted);
333+ font-variant-numeric: tabular-nums;
334+}
335+
336+.cycle-position {
337+ display: flex;
338+ align-items: center;
339+ gap: 10px;
340+ max-width: 520px;
341+}
342+
343+.cycle-end {
344+ font-weight: 700;
345+}
346+
347+.cycle-a {
348+ color: #1f5fa8;
349+}
350+
351+.cycle-b {
352+ color: #d9641e;
353+}
354+
355+.cycle-track {
356+ position: relative;
357+ flex: 1;
358+ height: 8px;
359+ border-radius: 4px;
360+ background: linear-gradient(90deg, #1f5fa8, #d9641e);
361+}
362+
363+.cycle-marker {
364+ position: absolute;
365+ top: 50%;
366+ width: 16px;
367+ height: 16px;
368+ border-radius: 50%;
369+ background: #fff;
370+ border: 2px solid var(--text);
371+ transform: translate(-50%, -50%);
372+ transition: opacity 0.2s;
373+}
374+
375+.error {
376+ color: var(--bad);
377+ margin-top: 8px;
378+ font-size: 0.9rem;
379+}
380+
381+@media (max-width: 600px) {
382+ .slider-row {
383+ grid-template-columns: 1.6em 1fr 4.5em 1.2em;
384+ }
385+}
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/morph-worklet.tsadded+109−0View file
@@ -0,0 +1,109 @@
1+/**
2+ * AudioWorklet processor for a continuous tone whose harmonic phases glide
3+ * between two settings, A and B.
4+ *
5+ * phi_k(t) = phiA_k + s(t) * delta_k, s(t) = (1 - cos(2 pi f_cycle t)) / 2
6+ *
7+ * where delta_k is the shortest signed angle from phiA_k to phiB_k. s runs
8+ * smoothly from 0 (tone A) to 1 (tone B) and back once per cycle. Parameters
9+ * arrive over the message port and take effect immediately; the running
10+ * phase accumulators are never reset, so updates do not click.
11+ */
12+
13+declare const sampleRate: number
14+declare function registerProcessor(name: string, ctor: unknown): void
15+declare class AudioWorkletProcessor {
16+ readonly port: MessagePort
17+}
18+
19+type Params = {
20+ f0?: number
21+ amplitudes?: number[]
22+ phasesA?: number[]
23+ phasesB?: number[]
24+ cycleHz?: number
25+ gain?: number
26+ active?: boolean
27+ /** restart the cycle at s = 0 (tone A) */
28+ reset?: boolean
29+}
30+
31+const TWO_PI = 2 * Math.PI
32+const REPORT_EVERY = 2048 // frames between position messages (~43 ms at 48 kHz)
33+
34+const wrapAngle = (x: number): number => {
35+ let y = x % TWO_PI
36+ if (y > Math.PI) y -= TWO_PI
37+ if (y <= -Math.PI) y += TWO_PI
38+ return y
39+}
40+
41+class PhaseMorphProcessor extends AudioWorkletProcessor {
42+ private f0 = 220
43+ private amplitudes: number[] = []
44+ private phasesA: number[] = []
45+ private phasesB: number[] = []
46+ private delta: number[] = []
47+ private cycleHz = 0.5
48+ private gain = 0
49+ private active = false
50+ private theta = 0
51+ private mod = 0
52+ private sinceReport = 0
53+
54+ constructor() {
55+ super()
56+ this.port.onmessage = (e: MessageEvent<Params>) => this.set(e.data)
57+ }
58+
59+ private set(p: Params): void {
60+ if (p.f0 !== undefined) this.f0 = p.f0
61+ if (p.amplitudes) this.amplitudes = p.amplitudes
62+ if (p.phasesA) this.phasesA = p.phasesA
63+ if (p.phasesB) this.phasesB = p.phasesB
64+ if (p.phasesA || p.phasesB) {
65+ this.delta = this.phasesA.map((a, k) => wrapAngle((this.phasesB[k] ?? 0) - a))
66+ }
67+ if (p.cycleHz !== undefined) this.cycleHz = p.cycleHz
68+ if (p.gain !== undefined) this.gain = p.gain
69+ if (p.reset) {
70+ this.mod = 0
71+ this.sinceReport = 0
72+ }
73+ if (p.active !== undefined) this.active = p.active
74+ }
75+
76+ process(_inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
77+ const out = outputs[0]?.[0]
78+ if (!out || !this.active) return true
79+ const { amplitudes, phasesA, delta, gain } = this
80+ const dTheta = (TWO_PI * this.f0) / sampleRate
81+ const dMod = (TWO_PI * this.cycleHz) / sampleRate
82+ let theta = this.theta
83+ let mod = this.mod
84+ for (let i = 0; i < out.length; i++) {
85+ const s = 0.5 - 0.5 * Math.cos(mod)
86+ let sum = 0
87+ for (let k = 0; k < amplitudes.length; k++) {
88+ const a = amplitudes[k]
89+ if (a === 0) continue
90+ sum += a * Math.sin((k + 1) * theta + phasesA[k] + s * delta[k])
91+ }
92+ out[i] = gain * sum
93+ theta += dTheta
94+ if (theta >= TWO_PI) theta -= TWO_PI
95+ mod += dMod
96+ if (mod >= TWO_PI) mod -= TWO_PI
97+ }
98+ this.theta = theta
99+ this.mod = mod
100+ this.sinceReport += out.length
101+ if (this.sinceReport >= REPORT_EVERY) {
102+ this.sinceReport = 0
103+ this.port.postMessage({ s: 0.5 - 0.5 * Math.cos(mod) })
104+ }
105+ return true
106+ }
107+}
108+
109+registerProcessor('phase-morph', PhaseMorphProcessor)
src/morph.tsadded+99−0View file
@@ -0,0 +1,99 @@
1+/** Main-thread controller for the cycling tone (see morph-worklet.ts). */
2+import { getAudioContext } from './audio'
3+import workletUrl from './morph-worklet.ts?worker&url'
4+
5+export type MorphParams = {
6+ f0: number
7+ amplitudes: number[]
8+ /** radians */
9+ phasesA: number[]
10+ /** radians */
11+ phasesB: number[]
12+ cycleHz: number
13+ gain: number
14+}
15+
16+const FADE_SEC = 0.03
17+
18+export class MorphPlayer {
19+ private ctx: AudioContext
20+ private node: AudioWorkletNode
21+ private gainNode: GainNode
22+ private listeners = new Set<(s: number) => void>()
23+ private stopTimer: ReturnType<typeof setTimeout> | null = null
24+ running = false
25+
26+ private constructor(ctx: AudioContext, node: AudioWorkletNode, gainNode: GainNode) {
27+ this.ctx = ctx
28+ this.node = node
29+ this.gainNode = gainNode
30+ node.port.onmessage = (e: MessageEvent<{ s: number }>) => {
31+ for (const fn of this.listeners) fn(e.data.s)
32+ }
33+ }
34+
35+ static async create(): Promise<MorphPlayer> {
36+ const ctx = getAudioContext()
37+ await ctx.audioWorklet.addModule(workletUrl)
38+ const node = new AudioWorkletNode(ctx, 'phase-morph', { numberOfInputs: 0, outputChannelCount: [1] })
39+ const gainNode = ctx.createGain()
40+ gainNode.gain.value = 0
41+ node.connect(gainNode).connect(ctx.destination)
42+ return new MorphPlayer(ctx, node, gainNode)
43+ }
44+
45+ /** Change parameters; safe to call while running. */
46+ update(p: Partial<MorphParams>): void {
47+ this.node.port.postMessage(p)
48+ }
49+
50+ start(p: MorphParams): void {
51+ if (this.stopTimer) {
52+ clearTimeout(this.stopTimer)
53+ this.stopTimer = null
54+ }
55+ void this.ctx.resume()
56+ this.node.port.postMessage({ ...p, reset: true, active: true })
57+ const g = this.gainNode.gain
58+ const t = this.ctx.currentTime
59+ g.cancelScheduledValues(t)
60+ g.setValueAtTime(0, t)
61+ g.linearRampToValueAtTime(1, t + FADE_SEC)
62+ this.running = true
63+ }
64+
65+ stop(): void {
66+ if (!this.running) return
67+ this.running = false
68+ const g = this.gainNode.gain
69+ const t = this.ctx.currentTime
70+ g.cancelScheduledValues(t)
71+ g.setValueAtTime(g.value, t)
72+ g.linearRampToValueAtTime(0, t + FADE_SEC)
73+ this.stopTimer = setTimeout(() => {
74+ this.stopTimer = null
75+ this.node.port.postMessage({ active: false })
76+ }, FADE_SEC * 1000 + 20)
77+ }
78+
79+ /** Subscribe to the cycle position s in [0, 1] (0 = tone A, 1 = tone B). */
80+ subscribe(fn: (s: number) => void): () => void {
81+ this.listeners.add(fn)
82+ return () => {
83+ this.listeners.delete(fn)
84+ }
85+ }
86+}
87+
88+let playerPromise: Promise<MorphPlayer> | null = null
89+
90+/** The single shared player, created on first use (needs a user gesture for audio). */
91+export function getMorphPlayer(): Promise<MorphPlayer> {
92+ if (!playerPromise) {
93+ playerPromise = MorphPlayer.create().catch((err: unknown) => {
94+ playerPromise = null
95+ throw err
96+ })
97+ }
98+ return playerPromise
99+}
src/stats.tsadded+22−0View file
@@ -0,0 +1,22 @@
1+/**
2+ * One-sided binomial tail: the probability of getting at least k correct out
3+ * of n trials by guessing (p = 1/2 per trial). Computed in log space so it
4+ * stays finite for large n.
5+ */
6+export function binomialTailP(k: number, n: number): number {
7+ if (n <= 0) return 1
8+ if (k <= 0) return 1
9+ let logTerm = -n * Math.LN2 // log C(n,0) 0.5^n
10+ let cum = 0
11+ for (let i = 0; i <= n; i++) {
12+ if (i >= k) cum += Math.exp(logTerm)
13+ if (i < n) logTerm += Math.log(n - i) - Math.log(i + 1)
14+ }
15+ return Math.min(1, cum)
16+}
17+
18+export function formatP(p: number): string {
19+ if (p >= 0.01) return p.toFixed(2)
20+ if (p >= 1e-4) return p.toFixed(4)
21+ return p.toExponential(1)
22+}
src/synth.tsadded+121−0View file
@@ -0,0 +1,121 @@
1+/**
2+ * Additive synthesis of a periodic tone from harmonic amplitudes and phases.
3+ *
4+ * A tone with fundamental frequency f0 is
5+ *
6+ * x(t) = sum_k a_k sin(2 pi k f0 t + phi_k), k = 1..N
7+ *
8+ * Everything here is a pure function of the parameters, so the same code
9+ * produces the plotted period and the audio that is played.
10+ */
11+
12+export type ToneParams = {
13+ f0: number
14+ /** harmonic amplitudes a_1..a_N (non-negative) */
15+ amplitudes: number[]
16+ /** harmonic phases phi_1..phi_N in radians */
17+ phases: number[]
18+}
19+
20+export const degToRad = (d: number): number => (d * Math.PI) / 180
21+
22+/** Sum of harmonics at angle theta = 2 pi f0 t (radians). Not gain-scaled. */
23+export function harmonicSum(amplitudes: number[], phases: number[], theta: number): number {
24+ let s = 0
25+ for (let k = 0; k < amplitudes.length; k++) {
26+ const a = amplitudes[k]
27+ if (a === 0) continue
28+ s += a * Math.sin((k + 1) * theta + (phases[k] ?? 0))
29+ }
30+ return s
31+}
32+
33+/** Upper bound on |x(t)| given the amplitudes, whatever the phases are. */
34+export function peakBound(amplitudes: number[]): number {
35+ return amplitudes.reduce((s, a) => s + Math.abs(a), 0)
36+}
37+
38+/** RMS of the unscaled waveform. By Parseval this depends only on the amplitudes. */
39+export function rms(amplitudes: number[]): number {
40+ return Math.sqrt(amplitudes.reduce((s, a) => s + a * a, 0) / 2)
41+}
42+
43+/**
44+ * Output gain applied before playback. It is a function of the amplitudes
45+ * alone, so two tones with the same amplitudes and different phases receive
46+ * exactly the same gain and therefore the same RMS level. The gain targets a
47+ * fixed RMS but never lets the worst-case peak exceed 0.9, so no phase
48+ * setting can clip.
49+ */
50+export function outputGain(amplitudes: number[]): number {
51+ const p = peakBound(amplitudes)
52+ const r = rms(amplitudes)
53+ if (p === 0 || r === 0) return 0
54+ return Math.min(0.9 / p, 0.2 / r)
55+}
56+
57+/** One period of the unscaled waveform, sampled at n points. */
58+export function renderPeriod(amplitudes: number[], phases: number[], n = 512): Float32Array {
59+ const out = new Float32Array(n)
60+ for (let i = 0; i < n; i++) out[i] = harmonicSum(amplitudes, phases, (2 * Math.PI * i) / n)
61+ return out
62+}
63+
64+/** The full audio signal: gain-scaled, with raised-cosine fades to avoid clicks. */
65+export function renderTone(tone: ToneParams, sampleRate: number, durationSec: number, fadeSec = 0.01): Float32Array {
66+ const n = Math.round(sampleRate * durationSec)
67+ const out = new Float32Array(n)
68+ const g = outputGain(tone.amplitudes)
69+ const nFade = Math.min(Math.round(sampleRate * fadeSec), Math.floor(n / 2))
70+ const w = (2 * Math.PI * tone.f0) / sampleRate
71+ for (let i = 0; i < n; i++) {
72+ let env = 1
73+ if (i < nFade) env = 0.5 - 0.5 * Math.cos((Math.PI * i) / nFade)
74+ else if (i >= n - nFade) env = 0.5 - 0.5 * Math.cos((Math.PI * (n - 1 - i)) / nFade)
75+ out[i] = g * env * harmonicSum(tone.amplitudes, tone.phases, w * i)
76+ }
77+ return out
78+}
79+
80+export type AmplitudePreset = { name: string; make: (n: number) => number[] }
81+
82+const rawPresets: AmplitudePreset[] = [
83+ { name: '1/k, all harmonics', make: (n) => Array.from({ length: n }, (_, k) => 1 / (k + 1)) },
84+ { name: '1/k, odd harmonics', make: (n) => Array.from({ length: n }, (_, k) => (k % 2 === 0 ? 1 / (k + 1) : 0)) },
85+ { name: '1/k², odd harmonics', make: (n) => Array.from({ length: n }, (_, k) => (k % 2 === 0 ? 1 / (k + 1) ** 2 : 0)) },
86+ { name: 'Flat', make: (n) => Array.from({ length: n }, () => 1) },
87+ { name: 'Fundamental only', make: (n) => Array.from({ length: n }, (_, k) => (k === 0 ? 1 : 0)) },
88+]
89+
90+/** Presets with values rounded to the slider resolution (two decimals). */
91+export const amplitudePresets: AmplitudePreset[] = rawPresets.map((p) => ({
92+ name: p.name,
93+ make: (n) => p.make(n).map((a) => round(a, 2)),
94+}))
95+
96+export const zeros = (n: number): number[] => Array.from({ length: n }, () => 0)
97+
98+/**
99+ * Random amplitudes for a new example: a decaying envelope with random
100+ * per-harmonic variation, normalized so the largest is 1. Rounded to the
101+ * slider resolution.
102+ */
103+export function randomAmplitudes(n: number): number[] {
104+ const raw = Array.from({ length: n }, (_, k) => Math.random() / (k + 1) ** (0.5 + Math.random()))
105+ const m = Math.max(...raw)
106+ return raw.map((a) => round(a / m, 2))
107+}
108+
109+export const randomPhasesDeg = (n: number): number[] =>
110+ Array.from({ length: n }, () => Math.round(Math.random() * 360 - 180))
111+
112+/** Resize an array to length n, keeping existing entries and padding with `fill`. */
113+export function resized(values: number[], n: number, fill = 0): number[] {
114+ return Array.from({ length: n }, (_, k) => (k < values.length ? values[k] : fill))
115+}
116+
117+/** Round to a fixed number of decimals (for slider values). */
118+export const round = (x: number, decimals: number): number => {
119+ const p = 10 ** decimals
120+ return Math.round(x * p) / p
121+}
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+})
↑↓ move↵openescclose