/ proof-of-concept / phase-perception
Sign in
proof-of-concept / phase-perception
phase-perception / src / components / CyclePanel.tsx
100 lines · 3.2 KBCodeBlameHistory
3118485Phase perception app: tone editor, blind same/different test, cycling tone, and site buildJeremy Magland 1import { useEffect, useState } from 'react'
2import type { MorphPlayer } from '../morph'
4type 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
15const LOG_MIN = -2 // 0.01 Hz
16const LOG_MAX = 2 // 100 Hz
17const BOX_MIN = 0.001
18const BOX_MAX = 1000
20/** Round to three significant figures so slider positions give tidy numbers. */
21const sig3 = (x: number) => Number(x.toPrecision(3))
23const 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`
30/** Controls for the continuous tone whose phases cycle between A and B. */
31export function CyclePanel({ active, busy, cycleHz, setCycleHz, onStart, onStop, player, error }: Props) {
32 const [pos, setPos] = useState(0)
34 useEffect(() => {
35 if (!active || !player) {
36 setPos(0)
37 return
38 }
39 return player.subscribe(setPos)
40 }, [active, player])
42 const period = 1 / cycleHz
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 )
↑↓ move↵openescclose