import { useCallback, useEffect, useRef, useState } from 'react' import type { ToneParams } from '../synth' import { binomialTailP, formatP } from '../stats' type Which = 'A' | 'B' type Trial = { order: [Which, Which] } type Answer = 'same' | 'different' type Result = { trial: Trial; answer: Answer; correct: boolean } type Phase = 'idle' | 'playing' | 'awaiting' | 'answered' const isSame = (t: Trial) => t.order[0] === t.order[1] /** * Half the trials play the same tone twice (A A or B B), the other half play * both tones (A B or B A). Which kind, and the order, are chosen at random. */ function makeTrial(): Trial { const same = Math.random() < 0.5 const first: Which = Math.random() < 0.5 ? 'A' : 'B' const second: Which = same ? first : first === 'A' ? 'B' : 'A' return { order: [first, second] } } type Props = { toneA: ToneParams toneB: ToneParams play: (label: string, tones: ToneParams[]) => Promise busy: boolean } export function BlindTest({ toneA, toneB, play, busy }: Props) { const [trial, setTrial] = useState(null) const [phase, setPhase] = useState('idle') const [results, setResults] = useState([]) const [last, setLast] = useState(null) // Latest props in refs so keyboard handlers never see stale closures. const latest = useRef({ toneA, toneB, play, trial, phase, busy }) latest.current = { toneA, toneB, play, trial, phase, busy } const playTrial = useCallback(async (t: Trial, after: Phase) => { const { toneA, toneB, play } = latest.current const tones = t.order.map((w) => (w === 'A' ? toneA : toneB)) setPhase('playing') await play('trial', tones) setPhase(after) }, []) const startTrial = useCallback(() => { const t = makeTrial() setTrial(t) setLast(null) void playTrial(t, 'awaiting') }, [playTrial]) const replay = useCallback(() => { const { trial, phase } = latest.current if (!trial || phase === 'playing') return void playTrial(trial, phase === 'answered' ? 'answered' : 'awaiting') }, [playTrial]) const answer = useCallback((a: Answer) => { const { trial, phase } = latest.current if (!trial || phase !== 'awaiting') return const correct = (a === 'same') === isSame(trial) const r = { trial, answer: a, correct } setResults((rs) => [...rs, r]) setLast(r) setPhase('answered') }, []) useEffect(() => { const onKey = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement | null)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || e.metaKey || e.ctrlKey || e.altKey) return const { phase, busy } = latest.current switch (e.key.toLowerCase()) { case 'n': if (phase !== 'playing' && !busy) startTrial() break case 'r': replay() break case 's': answer('same') break case 'd': answer('different') break } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [startTrial, replay, answer]) const n = results.length const k = results.filter((r) => r.correct).length const diff = results.filter((r) => !isSame(r.trial)) const same = results.filter((r) => isSame(r.trial)) const p = binomialTailP(k, n) const canStart = !busy && phase !== 'playing' const canAnswer = phase === 'awaiting' return (

Blind test: same or different?

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). Decide whether the two sounded the same or different. If phase is inaudible, your accuracy should stay near 50%. Keys: N next trial, R replay, S same, D different.

{phase === 'playing' && 'Listen…'} {phase === 'awaiting' && 'Same or different?'} {phase === 'answered' && last && ( {last.correct ? 'Correct.' : 'Incorrect.'} The tones were {isSame(last.trial) ? 'the same' : 'different'} ({last.trial.order[0]} then {last.trial.order[1]}). )}
Correct {k} / {n} {n > 0 && ({Math.round((100 * k) / n)}%)}
"Different" trials caught {diff.filter((r) => r.correct).length} / {diff.length}
"Same" trials caught {same.filter((r) => r.correct).length} / {same.length}
P(this many or more correct by guessing) {n > 0 ? formatP(p) : '–'}
) }