1import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
2import { DEFAULTS, PRESETS, applyPreset, matchPresetId, loadParams, saveParams, type Cymbal2Params } from './params';
3import { DEFAULT_MFILES, type MFile } from './mfiles';
4import { useRenderer } from './hooks/useRenderer';
5import { encodeWav } from './lib/wav';
6import ParamPanel from './components/ParamPanel';
7import CodePanel from './components/CodePanel';
8import StrikePicker from './components/StrikePicker';
9import DiagnosticsPanel from './components/DiagnosticsPanel';
10import WaveformPlot from './components/plots/WaveformPlot';
11import SpectrumPlot from './components/plots/SpectrumPlot';
12import SpectrogramPlot from './components/plots/SpectrogramPlot';
13import ContactPlot from './components/plots/ContactPlot';
15// The tutorial carries KaTeX and its own figures; load it only when opened.
16const Tutorial = lazy(() => import('./tutorial/Tutorial'));
18export default function App() {
19 const [params, setParams] = useState<Cymbal2Params>(() => loadParams());
20 // Editable copy of the MATLAB implementation. Deliberately not persisted:
21 // a reload restores the shipped code.
22 const [mfiles, setMfiles] = useState<MFile[]>(() => DEFAULT_MFILES.map((f) => ({ ...f })));
23 const [codeOpen, setCodeOpen] = useState(false);
24 // The tutorial is linkable: #how-it-works opens it (and its internal
25 // anchors keep it open across a reload).
26 const [tutorialOpen, setTutorialOpenRaw] = useState(
27 () => window.location.hash === '#how-it-works' || window.location.hash.startsWith('#tut-'),
28 );
29 const setTutorialOpen = useCallback((open: boolean) => {
30 setTutorialOpenRaw(open);
31 try {
32 const url = open
33 ? '#how-it-works'
34 : window.location.pathname + window.location.search;
35 window.history.replaceState(null, '', url);
36 } catch {
37 // sandboxed iframe or similar; the hash is a convenience only
38 }
39 }, []);
40 const { result, error, busy, stage, progress, elapsedS, renderPreview, renderFull, cancel } =
41 useRenderer(params, mfiles);
42 const audioCtxRef = useRef<AudioContext | null>(null);
44 useEffect(() => saveParams(params), [params]);
46 const onChange = useCallback((key: keyof Cymbal2Params, value: number | boolean) => {
47 setParams((p) => ({ ...p, [key]: value }));
48 }, []);
50 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
52 const presetId = matchPresetId(params);
53 const preset = PRESETS.find((p) => p.id === presetId) ?? null;
54 const onPreset = useCallback((id: string) => {
55 const p = PRESETS.find((q) => q.id === id);
56 if (p) setParams(applyPreset(p));
57 }, []);
59 const onChangeFile = useCallback((name: string, source: string) => {
60 setMfiles((fs) => fs.map((f) => (f.name === name ? { ...f, source } : f)));
61 }, []);
63 const onResetFile = useCallback((name: string) => {
64 setMfiles((fs) =>
65 fs.map((f) =>
66 f.name === name
67 ? { ...f, source: DEFAULT_MFILES.find((d) => d.name === name)?.source ?? f.source }
68 : f,
69 ),
70 );
71 }, []);
73 const onResetAllFiles = useCallback(() => setMfiles(DEFAULT_MFILES.map((f) => ({ ...f }))), []);
74 const codeEdited = mfiles.some(
75 (f) => f.source !== DEFAULT_MFILES.find((d) => d.name === f.name)?.source,
76 );
78 const [looping, setLooping] = useState(false);
79 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
81 // A failed render clears the result, so a loop left running has nothing to
82 // play; drop out of loop mode rather than leaving the button lit.
83 useEffect(() => {
84 if (!result) setLooping(false);
85 }, [result]);
87 const getAudioCtx = useCallback((sampleRate: number) => {
88 let ctx = audioCtxRef.current;
89 if (!ctx || ctx.state === 'closed') {
90 ctx = new AudioContext({ sampleRate });
91 audioCtxRef.current = ctx;
92 }
93 void ctx.resume();
94 return ctx;
95 }, []);
97 const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
98 const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
99 buf.copyToChannel(new Float32Array(r.y), 0);
100 return buf;
101 }, []);
103 const play = useCallback(() => {
104 if (!result) return;
105 const ctx = getAudioCtx(result.sampleRate);
106 const src = ctx.createBufferSource();
107 src.buffer = makeBuffer(ctx, result);
108 src.connect(ctx.destination);
109 src.start();
110 }, [result, getAudioCtx, makeBuffer]);
112 useEffect(() => {
113 if (!looping || !result) return;
114 const ctx = getAudioCtx(result.sampleRate);
115 const src = ctx.createBufferSource();
116 src.buffer = makeBuffer(ctx, result);
117 src.loop = true;
118 src.connect(ctx.destination);
119 src.start();
120 loopSrcRef.current = src;
121 return () => {
122 loopSrcRef.current = null;
123 try {
124 src.stop();
125 } catch {
126 // already stopped
127 }
128 src.disconnect();
129 };
130 }, [looping, result, getAudioCtx, makeBuffer]);
132 const download = useCallback(() => {
133 if (!result) return;
134 const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
135 const url = URL.createObjectURL(blob);
136 const a = document.createElement('a');
137 a.href = url;
138 a.download = result.quality === 'full' ? 'cymbal2.wav' : 'cymbal2_preview.wav';
139 a.click();
140 setTimeout(() => URL.revokeObjectURL(url), 5000);
141 }, [result, params.bit_depth]);
143 return (
144 <div className="app">
145 <div className="topbar">
146 <h1>Cymbal2 Simulator</h1>
147 <button
148 className="primary"
149 onClick={renderPreview}
150 disabled={busy}
151 title="No oversampling, full duration and grid otherwise — the fast pass"
152 >
153 Render preview
154 </button>
155 <button
156 className="primary"
157 onClick={renderFull}
158 disabled={busy}
159 title="Full quality at the oversampling the parameters ask for"
160 >
161 Render full
162 </button>
163 {busy && (
164 <button onClick={cancel} title="Stop the render">
165 ✕ Cancel
166 </button>
167 )}
168 <button
169 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
170 onClick={play}
171 disabled={!result}
172 title={
173 result?.quality === 'preview'
174 ? 'Playing the preview render; press Render full for full quality'
175 : 'Play the rendered stroke'
176 }
177 >
178 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
179 </button>
180 <button
181 className={`loop ${looping ? 'active' : ''}`}
182 onClick={() => setLooping((v) => !v)}
183 disabled={!result}
184 title="Repeat the last render continuously"
185 >
186 ⟳ Loop{looping ? ' on' : ''}
187 </button>
188 <button onClick={download} disabled={!result}>
189 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
190 </button>
191 <button onClick={reset}>Reset to defaults</button>
192 <button
193 className={codeOpen ? 'code active' : 'code'}
194 onClick={() => setCodeOpen((v) => !v)}
195 title="View and edit the MATLAB source that numbl executes"
196 >
197 {'{ }'} Code{codeEdited ? ' •' : ''}
198 </button>
199 <button
200 className={tutorialOpen ? 'howto active' : 'howto'}
201 onClick={() => setTutorialOpen(!tutorialOpen)}
202 title="Interactive tutorial: the theory (Samejima 2021) and how the solver integrates it"
203 >
204 ? How it works
205 </button>
206 <div className="status">
207 {error && <span className="badge err">code error</span>}
208 {result && !busy && (
209 <span className={`badge ${result.quality}`}>
210 {result.quality === 'full' ? 'full quality' : 'preview quality'}
211 </span>
212 )}
213 {busy ? (
214 <>
215 <span>
216 {stage}… {elapsedS.toFixed(0)} s
217 </span>
218 <div className="progressbar">
219 <div style={{ width: `${Math.round(progress * 100)}%` }} />
220 </div>
221 </>
222 ) : (
223 <span>ready</span>
224 )}
225 </div>
226 </div>
227 {tutorialOpen && (
228 <Suspense fallback={<div className="tut-loading">Loading the tutorial…</div>}>
229 <Tutorial onClose={() => setTutorialOpen(false)} />
230 </Suspense>
231 )}
232 <div className="main" style={tutorialOpen ? { display: 'none' } : undefined}>
233 <div className="sidebar">
234 <div className="preset-row">
235 <label htmlFor="preset-select">Preset</label>
236 <select
237 id="preset-select"
238 value={presetId ?? 'custom'}
239 onChange={(e) => onPreset(e.target.value)}
240 title="Replaces the whole parameter set; pick a preset, then adjust"
241 >
242 {presetId === null && (
243 <option value="custom" disabled>
244 custom
245 </option>
246 )}
247 {PRESETS.map((p) => (
248 <option key={p.id} value={p.id}>
249 {p.name}
250 </option>
251 ))}
252 </select>
253 </div>
254 {preset && <div className="preset-note">{preset.note}</div>}
255 <ParamPanel params={params} onChange={onChange} />
256 <div className="sidebar-credit">
257 Physical model: T. Samejima, “Nonlinear physical modeling sound synthesis
258 of cymbals involving dynamics of washers and sticks/mallets,”{' '}
259 <i>Acoust. Sci. & Tech.</i> <b>42</b>(6), 314–325 (2021).{' '}
260 <a href="https://doi.org/10.1250/ast.42.314" target="_blank" rel="noreferrer">
261 doi:10.1250/ast.42.314
262 </a>
263 <br />
264 <button className="linklike" onClick={() => setTutorialOpen(true)}>
265 How the solver works: an interactive tutorial →
266 </button>
267 </div>
268 </div>
269 <div className="content">
270 {error && (
271 <div className="card wide error-card">
272 <h2>MATLAB error</h2>
273 <div>
274 <strong>
275 {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
276 </strong>{' '}
277 {error.message}
278 </div>
279 <div className="code-hint">
280 Nothing is playable until a render succeeds. Open Code to fix the source, or
281 revert it.
282 </div>
283 </div>
284 )}
285 <div className="card wide">
286 <h2>Cross-section — drag the strike and pickup points</h2>
287 <StrikePicker params={params} onChange={onChange} />
288 </div>
289 {result && (
290 <>
291 <div className="card">
292 <h2>Diagnostics</h2>
293 <DiagnosticsPanel result={result} />
294 </div>
295 <div className="card">
296 <h2>Contact force</h2>
297 <ContactPlot forceT={result.forceT} forceN={result.forceN} />
298 </div>
299 <div className="card">
300 <h2>Waveform</h2>
301 <WaveformPlot y={result.y} sampleRate={result.sampleRate} />
302 </div>
303 <div className="card">
304 <h2>Spectrum</h2>
305 <SpectrumPlot
306 y={result.y}
307 sampleRate={result.sampleRate}
308 modeF={result.modeF}
309 modeN={result.modeM}
310 />
311 </div>
312 <div className="card wide">
313 <h2>Spectrogram — watch the crash build after the attack</h2>
314 <SpectrogramPlot y={result.y} sampleRate={result.sampleRate} />
315 </div>
316 </>
317 )}
318 {!result && !busy && !error && (
319 <div className="card wide">
320 <h2>No render yet</h2>
321 <div className="code-hint">
322 Press Render preview for a fast pass with no oversampling, or Render full for the
323 real thing. Nothing renders on its own: change what you like, then render.
324 </div>
325 </div>
326 )}
327 </div>
328 </div>
329 {codeOpen && (
330 <CodePanel
331 mfiles={mfiles}
332 onChangeFile={onChangeFile}
333 onResetFile={onResetFile}
334 onResetAll={onResetAllFiles}
335 onClose={() => setCodeOpen(false)}
336 onRenderFull={renderFull}
337 error={error}
338 busy={busy}
339 />
340 )}
341 </div>
342 );
343}