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