1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 1import { 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';
15export default function App() {
16 const [params, setParams] = useState<Cymbal2Params>(() => loadParams());
17 // Editable copy of the MATLAB implementation. Deliberately not persisted:
18 // a reload restores the shipped code.
19 const [mfiles, setMfiles] = useState<MFile[]>(() => DEFAULT_MFILES.map((f) => ({ ...f })));
20 const [codeOpen, setCodeOpen] = useState(false);
21 const { result, error, busy, stage, elapsedS, renderPreview, renderFull, cancel } = useRenderer(
22 params,
23 mfiles,
24 );
25 const audioCtxRef = useRef<AudioContext | null>(null);
27 useEffect(() => saveParams(params), [params]);
29 const onChange = useCallback((key: keyof Cymbal2Params, value: number | boolean) => {
30 setParams((p) => ({ ...p, [key]: value }));
31 }, []);
33 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
50ba5ccParameter presets, including the paper-demo strokeJeremy Magland 35 const presetId = matchPresetId(params);
36 const preset = PRESETS.find((p) => p.id === presetId) ?? null;
37 const onPreset = useCallback((id: string) => {
38 const p = PRESETS.find((q) => q.id === id);
39 if (p) setParams(applyPreset(p));
40 }, []);
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 42 const onChangeFile = useCallback((name: string, source: string) => {
43 setMfiles((fs) => fs.map((f) => (f.name === name ? { ...f, source } : f)));
44 }, []);
46 const onResetFile = useCallback((name: string) => {
47 setMfiles((fs) =>
48 fs.map((f) =>
49 f.name === name
50 ? { ...f, source: DEFAULT_MFILES.find((d) => d.name === name)?.source ?? f.source }
51 : f,
52 ),
53 );
54 }, []);
56 const onResetAllFiles = useCallback(() => setMfiles(DEFAULT_MFILES.map((f) => ({ ...f }))), []);
57 const codeEdited = mfiles.some(
58 (f) => f.source !== DEFAULT_MFILES.find((d) => d.name === f.name)?.source,
59 );
61 const [looping, setLooping] = useState(false);
62 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
64 const getAudioCtx = useCallback((sampleRate: number) => {
65 let ctx = audioCtxRef.current;
66 if (!ctx || ctx.state === 'closed') {
67 ctx = new AudioContext({ sampleRate });
68 audioCtxRef.current = ctx;
69 }
70 void ctx.resume();
71 return ctx;
72 }, []);
74 const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
75 const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
76 buf.copyToChannel(new Float32Array(r.y), 0);
77 return buf;
78 }, []);
80 const play = useCallback(() => {
81 if (!result) return;
82 const ctx = getAudioCtx(result.sampleRate);
83 const src = ctx.createBufferSource();
84 src.buffer = makeBuffer(ctx, result);
85 src.connect(ctx.destination);
86 src.start();
87 }, [result, getAudioCtx, makeBuffer]);
89 useEffect(() => {
90 if (!looping || !result) return;
91 const ctx = getAudioCtx(result.sampleRate);
92 const src = ctx.createBufferSource();
93 src.buffer = makeBuffer(ctx, result);
94 src.loop = true;
95 src.connect(ctx.destination);
96 src.start();
97 loopSrcRef.current = src;
98 return () => {
99 loopSrcRef.current = null;
100 try {
101 src.stop();
102 } catch {
103 // already stopped
104 }
105 src.disconnect();
106 };
107 }, [looping, result, getAudioCtx, makeBuffer]);
109 const download = useCallback(() => {
110 if (!result) return;
111 const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
112 const url = URL.createObjectURL(blob);
113 const a = document.createElement('a');
114 a.href = url;
115 a.download = result.quality === 'full' ? 'cymbal2.wav' : 'cymbal2_preview.wav';
116 a.click();
117 setTimeout(() => URL.revokeObjectURL(url), 5000);
118 }, [result, params.bit_depth]);
120 return (
121 <div className="app">
122 <div className="topbar">
123 <h1>Cymbal2 Simulator</h1>
124 <button
125 className="primary"
126 onClick={renderPreview}
127 disabled={busy}
128 title="Coarse azimuthal grid, no oversampling, short tail — tens of seconds"
129 >
130 Render preview
131 </button>
132 <button
133 className="primary"
134 onClick={renderFull}
135 disabled={busy}
136 title="Full time-domain integration at the set quality — takes minutes in the browser"
137 >
138 Render full
139 </button>
140 {busy && (
141 <button onClick={cancel} title="Stop the integration">
142 ✕ Cancel
143 </button>
144 )}
145 <button
146 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
147 onClick={play}
148 disabled={!result}
149 >
150 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
151 </button>
152 <button
153 className={`loop ${looping ? 'active' : ''}`}
154 onClick={() => setLooping((v) => !v)}
155 disabled={!result && !looping}
156 title="Repeat the last render continuously"
157 >
158 ⟳ Loop{looping ? ' on' : ''}
159 </button>
160 <button onClick={download} disabled={!result}>
161 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
162 </button>
163 <button onClick={reset}>Reset to defaults</button>
164 <button
165 className={codeOpen ? 'code active' : 'code'}
166 onClick={() => setCodeOpen((v) => !v)}
167 title="View and edit the MATLAB source that numbl executes"
168 >
169 {'{ }'} Code{codeEdited ? ' •' : ''}
170 </button>
171 <div className="status">
172 {error && <span className="badge err">code error</span>}
173 {result && !busy && (
174 <span className={`badge ${result.quality}`}>
175 {result.quality === 'full' ? 'full quality' : 'preview quality'}
176 </span>
177 )}
178 {busy ? (
179 <span>
180 {stage}… {elapsedS.toFixed(0)} s (a full render is a real time-domain
181 integration; expect minutes)
182 </span>
183 ) : (
184 <span>ready — renders are explicit, nothing runs on its own</span>
185 )}
186 </div>
187 </div>
188 <div className="main">
189 <div className="sidebar">
50ba5ccParameter presets, including the paper-demo strokeJeremy Magland 190 <div className="preset-row">
191 <label htmlFor="preset-select">Preset</label>
192 <select
193 id="preset-select"
194 value={presetId ?? 'custom'}
195 onChange={(e) => onPreset(e.target.value)}
196 title="Replaces the whole parameter set; pick a preset, then adjust"
197 >
198 {presetId === null && (
199 <option value="custom" disabled>
200 custom
201 </option>
202 )}
203 {PRESETS.map((p) => (
204 <option key={p.id} value={p.id}>
205 {p.name}
206 </option>
207 ))}
208 </select>
209 </div>
210 {preset && <div className="preset-note">{preset.note}</div>}
1793cecTime-domain nonlinear shell cymbal after Samejima (2021)Jeremy Magland 211 <ParamPanel params={params} onChange={onChange} />
212 </div>
213 <div className="content">
214 {error && (
215 <div className="card wide error-card">
216 <h2>MATLAB error</h2>
217 <div>
218 <strong>
219 {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
220 </strong>{' '}
221 {error.message}
222 </div>
223 <div className="code-hint">
224 Showing the last successful render. Open Code to fix the source, or revert it.
225 </div>
226 </div>
227 )}
228 <div className="card wide">
229 <h2>Cross-section — drag the strike and pickup points</h2>
230 <StrikePicker params={params} onChange={onChange} />
231 </div>
232 {result && (
233 <>
234 <div className="card">
235 <h2>Diagnostics</h2>
236 <DiagnosticsPanel result={result} />
237 </div>
238 <div className="card">
239 <h2>Contact force</h2>
240 <ContactPlot forceT={result.forceT} forceN={result.forceN} />
241 </div>
242 <div className="card">
243 <h2>Waveform</h2>
244 <WaveformPlot y={result.y} sampleRate={result.sampleRate} />
245 </div>
246 <div className="card">
247 <h2>Spectrum</h2>
248 <SpectrumPlot
249 y={result.y}
250 sampleRate={result.sampleRate}
251 modeF={result.modeF}
252 modeN={result.modeM}
253 />
254 </div>
255 <div className="card wide">
256 <h2>Spectrogram — watch the crash build after the attack</h2>
257 <SpectrogramPlot y={result.y} sampleRate={result.sampleRate} />
258 </div>
259 </>
260 )}
261 {!result && !busy && (
262 <div className="card wide">
263 <h2>No render yet</h2>
264 <div className="code-hint">
265 Press Render preview for a quick coarse pass, or Render full for the real thing.
266 Unlike the sibling modal-cymbal app, every render here is a nonlinear time-domain
267 integration of the shell, so nothing renders automatically.
268 </div>
269 </div>
270 )}
271 </div>
272 </div>
273 {codeOpen && (
274 <CodePanel
275 mfiles={mfiles}
276 onChangeFile={onChangeFile}
277 onResetFile={onResetFile}
278 onResetAll={onResetAllFiles}
279 onClose={() => setCodeOpen(false)}
280 onRenderFull={renderFull}
281 error={error}
282 busy={busy}
283 />
284 )}
285 </div>
286 );
287}