/ simulated_instruments / cymbal
Sign in
simulated_instruments / cymbal
cymbal / webapp / src / App.tsx
337 lines · 11.7 KBCodeBlameHistory
2import { DEFAULTS, loadParams, saveParams, type CymbalParams } 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 Scene3D from './components/Scene3D';
a136eacMake the contact point a first-class controlJeremy Magland 9import StrikePicker from './components/StrikePicker';
11import WaveformPlot from './components/plots/WaveformPlot';
12import SpectrumPlot from './components/plots/SpectrumPlot';
13import SpectrogramPlot from './components/plots/SpectrogramPlot';
14import StrikePlot from './components/plots/StrikePlot';
15import ModeMap from './components/plots/ModeMap';
16import ModeGallery from './components/plots/ModeGallery';
17import CascadePlot from './components/plots/CascadePlot';
19export default function App() {
20 const [params, setParams] = useState<CymbalParams>(() => loadParams());
21 // Editable copy of the MATLAB implementation. Deliberately not persisted:
22 // a reload restores the code the app shipped with.
23 const [mfiles, setMfiles] = useState<MFile[]>(() => DEFAULT_MFILES.map((f) => ({ ...f })));
24 const [codeOpen, setCodeOpen] = useState(false);
5fefed3Explicit preview and full renders, as in the cymbal2 appJeremy Magland 25 const { result, error, busy, stage, progress, elapsedS, renderPreview, renderFull, cancel } =
26 useRenderer(params, mfiles);
29 useEffect(() => saveParams(params), [params]);
31 const onChange = useCallback((key: keyof CymbalParams, value: number | boolean) => {
32 setParams((p) => ({ ...p, [key]: value }));
33 }, []);
35 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
37 // A new seed redraws the timing and velocities of a repeated figure without
38 // touching anything else. A single stroke is deterministic, so the button is
39 // disabled there.
40 const reseed = useCallback(
41 () => setParams((p) => ({ ...p, seed: 1 + Math.floor(Math.random() * 9999) })),
42 [],
43 );
45 const onChangeFile = useCallback((name: string, source: string) => {
46 setMfiles((fs) => fs.map((f) => (f.name === name ? { ...f, source } : f)));
47 }, []);
49 const onResetFile = useCallback((name: string) => {
50 setMfiles((fs) =>
51 fs.map((f) =>
52 f.name === name
53 ? { ...f, source: DEFAULT_MFILES.find((d) => d.name === name)?.source ?? f.source }
54 : f,
55 ),
56 );
57 }, []);
59 const onResetAllFiles = useCallback(() => setMfiles(DEFAULT_MFILES.map((f) => ({ ...f }))), []);
60 const codeEdited = mfiles.some(
61 (f) => f.source !== DEFAULT_MFILES.find((d) => d.name === f.name)?.source,
62 );
64 const [looping, setLooping] = useState(false);
65 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
5fefed3Explicit preview and full renders, as in the cymbal2 appJeremy Magland 67 // A failed render clears the result, so a loop left running has nothing to
68 // play; drop out of loop mode rather than leaving the button lit.
69 useEffect(() => {
70 if (!result) setLooping(false);
71 }, [result]);
74 let ctx = audioCtxRef.current;
75 if (!ctx || ctx.state === 'closed') {
76 ctx = new AudioContext({ sampleRate });
77 audioCtxRef.current = ctx;
78 }
79 void ctx.resume();
80 return ctx;
81 }, []);
83 const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
84 const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
85 buf.copyToChannel(new Float32Array(r.y), 0);
86 return buf;
87 }, []);
89 const play = useCallback(() => {
90 if (!result) return;
91 const ctx = getAudioCtx(result.sampleRate);
92 const src = ctx.createBufferSource();
93 src.buffer = makeBuffer(ctx, result);
94 src.connect(ctx.destination);
95 src.start();
96 }, [result, getAudioCtx, makeBuffer]);
5fefed3Explicit preview and full renders, as in the cymbal2 appJeremy Magland 98 // Loop mode: repeat the last render continuously, swapping in each new
99 // render as it completes.
101 if (!looping || !result) return;
102 const ctx = getAudioCtx(result.sampleRate);
103 const src = ctx.createBufferSource();
104 src.buffer = makeBuffer(ctx, result);
105 src.loop = true;
106 src.connect(ctx.destination);
107 src.start();
108 loopSrcRef.current = src;
109 return () => {
110 loopSrcRef.current = null;
111 try {
112 src.stop();
113 } catch {
114 // already stopped
115 }
116 src.disconnect();
117 };
118 }, [looping, result, getAudioCtx, makeBuffer]);
120 const download = useCallback(() => {
121 if (!result) return;
122 const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
123 const url = URL.createObjectURL(blob);
124 const a = document.createElement('a');
125 a.href = url;
126 a.download = result.quality === 'full' ? 'cymbal.wav' : 'cymbal_preview.wav';
127 a.click();
128 setTimeout(() => URL.revokeObjectURL(url), 5000);
129 }, [result, params.bit_depth]);
131 return (
132 <div className="app">
133 <div className="topbar">
134 <h1>Cymbal Simulator</h1>
136 className="primary"
137 onClick={renderPreview}
138 disabled={busy}
139 title="No oversampling, everything else unchanged — the fast pass"
140 >
141 Render preview
142 </button>
143 <button
144 className="primary"
145 onClick={renderFull}
146 disabled={busy}
147 title="Full quality at the oversampling the parameters ask for"
148 >
149 Render full
152 <button onClick={cancel} title="Stop the render">
153 ✕ Cancel
154 </button>
155 )}
157 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
158 onClick={play}
159 disabled={!result}
160 title={
161 result?.quality === 'preview'
5fefed3Explicit preview and full renders, as in the cymbal2 appJeremy Magland 162 ? 'Playing the preview render; press Render full for full quality'
164 }
165 >
166 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
167 </button>
168 <button
169 className={`loop ${looping ? 'active' : ''}`}
170 onClick={() => setLooping((v) => !v)}
5fefed3Explicit preview and full renders, as in the cymbal2 appJeremy Magland 171 disabled={!result}
172 title="Repeat the last render continuously"
174 ⟳ Loop{looping ? ' on' : ''}
175 </button>
176 <button
177 onClick={reseed}
178 disabled={params.strike_count < 2}
179 title="Draw new timing and velocities from a new random seed (repeated figures only)"
180 >
181 ⚄ New seed
182 </button>
183 <button onClick={download} disabled={!result}>
184 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
185 </button>
186 <button onClick={reset}>Reset to defaults</button>
187 <button
188 className={codeOpen ? 'code active' : 'code'}
189 onClick={() => setCodeOpen((v) => !v)}
190 title="View and edit the MATLAB source that numbl executes"
191 >
192 {'{ }'} Code{codeEdited ? ' •' : ''}
193 </button>
194 <div className="status">
195 {error && <span className="badge err">code error</span>}
5fefed3Explicit preview and full renders, as in the cymbal2 appJeremy Magland 196 {result && !busy && (
5fefed3Explicit preview and full renders, as in the cymbal2 appJeremy Magland 198 {result.quality === 'full' ? 'full quality' : 'preview quality'}
200 )}
201 {busy ? (
202 <>
204 {stage}… {elapsedS.toFixed(0)} s
205 </span>
207 <div style={{ width: `${Math.round(progress * 100)}%` }} />
208 </div>
209 </>
210 ) : (
211 <span>ready</span>
212 )}
213 </div>
214 </div>
215 <div className="main">
216 <div className="sidebar">
217 <ParamPanel params={params} onChange={onChange} />
218 </div>
219 <div className="content">
220 {error && (
221 <div className="card wide error-card">
222 <h2>MATLAB error</h2>
223 <div>
224 <strong>
225 {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
226 </strong>{' '}
227 {error.message}
228 </div>
229 <div className="code-hint">
5fefed3Explicit preview and full renders, as in the cymbal2 appJeremy Magland 230 Nothing is playable until a render succeeds. Open Code to fix the source, or
231 revert it.
233 </div>
234 )}
a136eacMake the contact point a first-class controlJeremy Magland 235 <div className="card wide">
236 <h2>Contact point — drag the stick along the profile</h2>
237 <StrikePicker
238 params={params}
239 onChange={onChange}
240 excDb={result?.excDb}
241 excBands={result?.cascadeBands}
242 excFLo={result?.cascadeFLo}
243 excFHi={result?.cascadeFHi}
244 />
245 </div>
247 <h2>Geometry — plate, stick, microphone</h2>
248 <Scene3D params={params} />
249 </div>
250 {result && (
251 <>
252 <div className="card">
253 <h2>Diagnostics</h2>
254 <DiagnosticsPanel result={result} />
255 </div>
256 <div className="card">
257 <h2>Strokes</h2>
258 <StrikePlot
259 strikeT={result.strikeT}
260 strikeV={result.strikeV}
261 strikeS={result.strikeS}
262 durationS={result.diag.durationS}
263 />
264 </div>
265 <div className="card wide">
266 <h2>Modal energy — the cascade</h2>
267 <CascadePlot
268 cascadeDb={result.cascadeDb}
269 bands={result.cascadeBands}
270 frames={result.cascadeFrames}
271 fLo={result.cascadeFLo}
272 fHi={result.cascadeFHi}
273 tEnd={result.cascadeTEnd}
274 />
275 </div>
276 <div className="card">
277 <h2>Waveform</h2>
278 <WaveformPlot y={result.y} sampleRate={result.sampleRate} />
279 </div>
280 <div className="card">
281 <h2>Spectrum</h2>
282 <SpectrumPlot
283 y={result.y}
284 sampleRate={result.sampleRate}
285 modeF={result.modeF}
286 modeN={result.modeN}
287 />
288 </div>
289 <div className="card">
290 <h2>Spectrogram</h2>
291 <SpectrogramPlot y={result.y} sampleRate={result.sampleRate} />
292 </div>
293 <div className="card">
294 <h2>Plate modes</h2>
295 <ModeMap
296 modeF={result.modeF}
297 modeZeta={result.modeZeta}
298 modeZetaRad={result.modeZetaRad}
299 modeN={result.modeN}
300 />
301 </div>
302 <div className="card wide">
303 <h2>Mode shapes</h2>
304 <ModeGallery
305 nodeR={result.nodeR}
306 shapeW={result.shapeW}
307 modeF={result.modeF}
308 modeN={result.modeN}
309 />
310 </div>
311 </>
312 )}
5fefed3Explicit preview and full renders, as in the cymbal2 appJeremy Magland 313 {!result && !busy && !error && (
314 <div className="card wide">
315 <h2>No render yet</h2>
316 <div className="code-hint">
317 Press Render preview for a fast pass with no oversampling, or Render full for the real thing. Nothing renders on its own: change what you like, then render.
318 </div>
319 </div>
320 )}
322 </div>
323 {codeOpen && (
324 <CodePanel
325 mfiles={mfiles}
326 onChangeFile={onChangeFile}
327 onResetFile={onResetFile}
328 onResetAll={onResetAllFiles}
329 onClose={() => setCodeOpen(false)}
330 onRenderFull={renderFull}
331 error={error}
332 busy={busy}
333 />
334 )}
335 </div>
336 );
↑↓ move↵openescclose