/ simulated_instruments / cymbal2
Sign in
simulated_instruments / cymbal2
cymbal2 / webapp / src / App.tsx
336 lines · 12.2 KBCodeBlameHistory
17959acInteractive tutorial on the theory and the solverJeremy Magland 1import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
50ba5ccParameter presets, including the paper-demo strokeJeremy Magland 2import { DEFAULTS, PRESETS, applyPreset, matchPresetId, loadParams, saveParams, type Cymbal2Params } from './params';
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 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';
17959acInteractive tutorial on the theory and the solverJeremy Magland 15// The tutorial carries KaTeX and its own figures; load it only when opened.
16const Tutorial = lazy(() => import('./tutorial/Tutorial'));
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 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);
17959acInteractive tutorial on the theory and the solverJeremy Magland 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 }, []);
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 40 const { result, error, busy, stage, elapsedS, renderPreview, renderFull, cancel } = useRenderer(
41 params,
42 mfiles,
43 );
44 const audioCtxRef = useRef<AudioContext | null>(null);
46 useEffect(() => saveParams(params), [params]);
48 const onChange = useCallback((key: keyof Cymbal2Params, value: number | boolean) => {
49 setParams((p) => ({ ...p, [key]: value }));
50 }, []);
52 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
50ba5ccParameter presets, including the paper-demo strokeJeremy Magland 54 const presetId = matchPresetId(params);
55 const preset = PRESETS.find((p) => p.id === presetId) ?? null;
56 const onPreset = useCallback((id: string) => {
57 const p = PRESETS.find((q) => q.id === id);
58 if (p) setParams(applyPreset(p));
59 }, []);
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 61 const onChangeFile = useCallback((name: string, source: string) => {
62 setMfiles((fs) => fs.map((f) => (f.name === name ? { ...f, source } : f)));
63 }, []);
65 const onResetFile = useCallback((name: string) => {
66 setMfiles((fs) =>
67 fs.map((f) =>
68 f.name === name
69 ? { ...f, source: DEFAULT_MFILES.find((d) => d.name === name)?.source ?? f.source }
70 : f,
71 ),
72 );
73 }, []);
75 const onResetAllFiles = useCallback(() => setMfiles(DEFAULT_MFILES.map((f) => ({ ...f }))), []);
76 const codeEdited = mfiles.some(
77 (f) => f.source !== DEFAULT_MFILES.find((d) => d.name === f.name)?.source,
78 );
80 const [looping, setLooping] = useState(false);
81 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
17959acInteractive tutorial on the theory and the solverJeremy Magland 83 // A failed render clears the result, so a loop left running has nothing to
84 // play; drop out of loop mode rather than leaving the button lit.
85 useEffect(() => {
86 if (!result) setLooping(false);
87 }, [result]);
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 89 const getAudioCtx = useCallback((sampleRate: number) => {
90 let ctx = audioCtxRef.current;
91 if (!ctx || ctx.state === 'closed') {
92 ctx = new AudioContext({ sampleRate });
93 audioCtxRef.current = ctx;
94 }
95 void ctx.resume();
96 return ctx;
97 }, []);
99 const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
100 const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
101 buf.copyToChannel(new Float32Array(r.y), 0);
102 return buf;
103 }, []);
105 const play = useCallback(() => {
106 if (!result) return;
107 const ctx = getAudioCtx(result.sampleRate);
108 const src = ctx.createBufferSource();
109 src.buffer = makeBuffer(ctx, result);
110 src.connect(ctx.destination);
111 src.start();
112 }, [result, getAudioCtx, makeBuffer]);
114 useEffect(() => {
115 if (!looping || !result) return;
116 const ctx = getAudioCtx(result.sampleRate);
117 const src = ctx.createBufferSource();
118 src.buffer = makeBuffer(ctx, result);
119 src.loop = true;
120 src.connect(ctx.destination);
121 src.start();
122 loopSrcRef.current = src;
123 return () => {
124 loopSrcRef.current = null;
125 try {
126 src.stop();
127 } catch {
128 // already stopped
129 }
130 src.disconnect();
131 };
132 }, [looping, result, getAudioCtx, makeBuffer]);
134 const download = useCallback(() => {
135 if (!result) return;
136 const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
137 const url = URL.createObjectURL(blob);
138 const a = document.createElement('a');
139 a.href = url;
140 a.download = result.quality === 'full' ? 'cymbal2.wav' : 'cymbal2_preview.wav';
141 a.click();
142 setTimeout(() => URL.revokeObjectURL(url), 5000);
143 }, [result, params.bit_depth]);
145 return (
146 <div className="app">
147 <div className="topbar">
148 <h1>Cymbal2 Simulator</h1>
149 <button
150 className="primary"
151 onClick={renderPreview}
152 disabled={busy}
17959acInteractive tutorial on the theory and the solverJeremy Magland 153 title="No oversampling, full duration and grid otherwise — about half the cost of a full render"
155 Render preview
156 </button>
157 <button
158 className="primary"
159 onClick={renderFull}
160 disabled={busy}
161 title="Full time-domain integration at the set quality — takes minutes in the browser"
162 >
163 Render full
164 </button>
165 {busy && (
166 <button onClick={cancel} title="Stop the integration">
167 ✕ Cancel
168 </button>
169 )}
170 <button
171 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
172 onClick={play}
173 disabled={!result}
174 >
175 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
176 </button>
177 <button
178 className={`loop ${looping ? 'active' : ''}`}
179 onClick={() => setLooping((v) => !v)}
17959acInteractive tutorial on the theory and the solverJeremy Magland 180 disabled={!result}
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 181 title="Repeat the last render continuously"
182 >
183 ⟳ Loop{looping ? ' on' : ''}
184 </button>
185 <button onClick={download} disabled={!result}>
186 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
187 </button>
188 <button onClick={reset}>Reset to defaults</button>
189 <button
190 className={codeOpen ? 'code active' : 'code'}
191 onClick={() => setCodeOpen((v) => !v)}
192 title="View and edit the MATLAB source that numbl executes"
193 >
194 {'{ }'} Code{codeEdited ? ' •' : ''}
195 </button>
197 className={tutorialOpen ? 'howto active' : 'howto'}
198 onClick={() => setTutorialOpen(!tutorialOpen)}
199 title="Interactive tutorial: the theory (Samejima 2021) and how the solver integrates it"
200 >
201 ? How it works
202 </button>
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 203 <div className="status">
204 {error && <span className="badge err">code error</span>}
205 {result && !busy && (
206 <span className={`badge ${result.quality}`}>
207 {result.quality === 'full' ? 'full quality' : 'preview quality'}
208 </span>
209 )}
210 {busy ? (
211 <span>
17959acInteractive tutorial on the theory and the solverJeremy Magland 212 {stage}… {elapsedS.toFixed(0)} s
214 ) : (
17959acInteractive tutorial on the theory and the solverJeremy Magland 215 <span>ready</span>
217 </div>
218 </div>
17959acInteractive tutorial on the theory and the solverJeremy Magland 219 {tutorialOpen && (
220 <Suspense fallback={<div className="tut-loading">Loading the tutorial…</div>}>
221 <Tutorial onClose={() => setTutorialOpen(false)} />
222 </Suspense>
223 )}
224 <div className="main" style={tutorialOpen ? { display: 'none' } : undefined}>
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 225 <div className="sidebar">
50ba5ccParameter presets, including the paper-demo strokeJeremy Magland 226 <div className="preset-row">
227 <label htmlFor="preset-select">Preset</label>
228 <select
229 id="preset-select"
230 value={presetId ?? 'custom'}
231 onChange={(e) => onPreset(e.target.value)}
232 title="Replaces the whole parameter set; pick a preset, then adjust"
233 >
234 {presetId === null && (
235 <option value="custom" disabled>
236 custom
237 </option>
238 )}
239 {PRESETS.map((p) => (
240 <option key={p.id} value={p.id}>
241 {p.name}
242 </option>
243 ))}
244 </select>
245 </div>
246 {preset && <div className="preset-note">{preset.note}</div>}
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 247 <ParamPanel params={params} onChange={onChange} />
17959acInteractive tutorial on the theory and the solverJeremy Magland 248 <div className="sidebar-credit">
249 Physical model: T. Samejima, &ldquo;Nonlinear physical modeling sound synthesis
250 of cymbals involving dynamics of washers and sticks/mallets,&rdquo;{' '}
251 <i>Acoust. Sci. &amp; Tech.</i> <b>42</b>(6), 314&ndash;325 (2021).{' '}
252 <a href="https://doi.org/10.1250/ast.42.314" target="_blank" rel="noreferrer">
253 doi:10.1250/ast.42.314
254 </a>
255 <br />
256 <button className="linklike" onClick={() => setTutorialOpen(true)}>
257 How the solver works: an interactive tutorial →
258 </button>
259 </div>
261 <div className="content">
262 {error && (
263 <div className="card wide error-card">
264 <h2>MATLAB error</h2>
265 <div>
266 <strong>
267 {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
268 </strong>{' '}
269 {error.message}
270 </div>
271 <div className="code-hint">
17959acInteractive tutorial on the theory and the solverJeremy Magland 272 Nothing is playable until a render succeeds. Open Code to fix the source, or
273 revert it.
275 </div>
276 )}
277 <div className="card wide">
278 <h2>Cross-section — drag the strike and pickup points</h2>
279 <StrikePicker params={params} onChange={onChange} />
280 </div>
281 {result && (
282 <>
283 <div className="card">
284 <h2>Diagnostics</h2>
285 <DiagnosticsPanel result={result} />
286 </div>
287 <div className="card">
288 <h2>Contact force</h2>
289 <ContactPlot forceT={result.forceT} forceN={result.forceN} />
290 </div>
291 <div className="card">
292 <h2>Waveform</h2>
293 <WaveformPlot y={result.y} sampleRate={result.sampleRate} />
294 </div>
295 <div className="card">
296 <h2>Spectrum</h2>
297 <SpectrumPlot
298 y={result.y}
299 sampleRate={result.sampleRate}
300 modeF={result.modeF}
301 modeN={result.modeM}
302 />
303 </div>
304 <div className="card wide">
305 <h2>Spectrogram — watch the crash build after the attack</h2>
306 <SpectrogramPlot y={result.y} sampleRate={result.sampleRate} />
307 </div>
308 </>
309 )}
17959acInteractive tutorial on the theory and the solverJeremy Magland 310 {!result && !busy && !error && (
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 311 <div className="card wide">
312 <h2>No render yet</h2>
313 <div className="code-hint">
17959acInteractive tutorial on the theory and the solverJeremy Magland 314 Press Render preview for a cheaper pass at the same duration, or Render full for the real thing.
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 315 Unlike the sibling modal-cymbal app, every render here is a nonlinear time-domain
316 integration of the shell, so nothing renders automatically.
317 </div>
318 </div>
319 )}
320 </div>
321 </div>
322 {codeOpen && (
323 <CodePanel
324 mfiles={mfiles}
325 onChangeFile={onChangeFile}
326 onResetFile={onResetFile}
327 onResetAll={onResetAllFiles}
328 onClose={() => setCodeOpen(false)}
329 onRenderFull={renderFull}
330 error={error}
331 busy={busy}
332 />
333 )}
334 </div>
335 );
↑↓ move↵openescclose