/ proof-of-concept / phase-perception
Sign in
proof-of-concept / phase-perception
phase-perception / src / components / BlindTest.tsx
169 lines · 6.0 KBBlameHistoryRaw
1import { useCallback, useEffect, useRef, useState } from 'react'
2import type { ToneParams } from '../synth'
3import { binomialTailP, formatP } from '../stats'
5type Which = 'A' | 'B'
6type Trial = { order: [Which, Which] }
7type Answer = 'same' | 'different'
8type Result = { trial: Trial; answer: Answer; correct: boolean }
9type Phase = 'idle' | 'playing' | 'awaiting' | 'answered'
11const isSame = (t: Trial) => t.order[0] === t.order[1]
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 */
17function 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] }
24type Props = {
25 toneA: ToneParams
26 toneB: ToneParams
27 play: (label: string, tones: ToneParams[]) => Promise<void>
28 busy: boolean
31export 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)
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 }
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 }, [])
49 const startTrial = useCallback(() => {
50 const t = makeTrial()
51 setTrial(t)
52 setLast(null)
53 void playTrial(t, 'awaiting')
54 }, [playTrial])
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])
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 }, [])
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])
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'
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>
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>
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>
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 )
↑↓ move↵openescclose