1import { useCallback, useEffect, useRef, useState } from 'react';
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';
9import StrikePicker from './components/StrikePicker';
10import DiagnosticsPanel from './components/DiagnosticsPanel';
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);
25 const { result, error, busy, stage, progress, renderFull } = useRenderer(params, mfiles);
26 const audioCtxRef = useRef<AudioContext | null>(null);
28 useEffect(() => saveParams(params), [params]);
30 const onChange = useCallback((key: keyof CymbalParams, value: number | boolean) => {
31 setParams((p) => ({ ...p, [key]: value }));
32 }, []);
34 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
36 // A new seed redraws the timing and velocities of a repeated figure without
37 // touching anything else. A single stroke is deterministic, so the button is
38 // disabled there.
39 const reseed = useCallback(
40 () => setParams((p) => ({ ...p, seed: 1 + Math.floor(Math.random() * 9999) })),
41 [],
42 );
44 const onChangeFile = useCallback((name: string, source: string) => {
45 setMfiles((fs) => fs.map((f) => (f.name === name ? { ...f, source } : f)));
46 }, []);
48 const onResetFile = useCallback((name: string) => {
49 setMfiles((fs) =>
50 fs.map((f) =>
51 f.name === name
52 ? { ...f, source: DEFAULT_MFILES.find((d) => d.name === name)?.source ?? f.source }
53 : f,
54 ),
55 );
56 }, []);
58 const onResetAllFiles = useCallback(() => setMfiles(DEFAULT_MFILES.map((f) => ({ ...f }))), []);
59 const codeEdited = mfiles.some(
60 (f) => f.source !== DEFAULT_MFILES.find((d) => d.name === f.name)?.source,
61 );
63 const [looping, setLooping] = useState(false);
64 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
66 const getAudioCtx = useCallback((sampleRate: number) => {
67 let ctx = audioCtxRef.current;
68 if (!ctx || ctx.state === 'closed') {
69 ctx = new AudioContext({ sampleRate });
70 audioCtxRef.current = ctx;
71 }
72 void ctx.resume();
73 return ctx;
74 }, []);
76 const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
77 const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
78 buf.copyToChannel(new Float32Array(r.y), 0);
79 return buf;
80 }, []);
82 const play = useCallback(() => {
83 if (!result) return;
84 const ctx = getAudioCtx(result.sampleRate);
85 const src = ctx.createBufferSource();
86 src.buffer = makeBuffer(ctx, result);
87 src.connect(ctx.destination);
88 src.start();
89 }, [result, getAudioCtx, makeBuffer]);
91 // Loop mode: repeat the stroke continuously, swapping in each new render as
92 // parameters change so edits are audible without pressing Play.
93 useEffect(() => {
94 if (!looping || !result) return;
95 const ctx = getAudioCtx(result.sampleRate);
96 const src = ctx.createBufferSource();
97 src.buffer = makeBuffer(ctx, result);
98 src.loop = true;
99 src.connect(ctx.destination);
100 src.start();
101 loopSrcRef.current = src;
102 return () => {
103 loopSrcRef.current = null;
104 try {
105 src.stop();
106 } catch {
107 // already stopped
108 }
109 src.disconnect();
110 };
111 }, [looping, result, getAudioCtx, makeBuffer]);
113 const download = useCallback(() => {
114 if (!result) return;
115 const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
116 const url = URL.createObjectURL(blob);
117 const a = document.createElement('a');
118 a.href = url;
119 a.download = result.quality === 'full' ? 'cymbal.wav' : 'cymbal_preview.wav';
120 a.click();
121 setTimeout(() => URL.revokeObjectURL(url), 5000);
122 }, [result, params.bit_depth]);
124 return (
125 <div className="app">
126 <div className="topbar">
127 <h1>Cymbal Simulator</h1>
128 <button className="primary" onClick={renderFull} disabled={busy}>
129 Render
130 </button>
131 <button
132 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
133 onClick={play}
134 disabled={!result}
135 title={
136 result?.quality === 'preview'
137 ? 'Playing the fast preview render; press Render for full quality'
138 : 'Play the rendered stroke'
139 }
140 >
141 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
142 </button>
143 <button
144 className={`loop ${looping ? 'active' : ''}`}
145 onClick={() => setLooping((v) => !v)}
146 disabled={!result && !looping}
147 title="Repeat the render continuously so parameter changes are audible immediately"
148 >
149 ⟳ Loop{looping ? ' on' : ''}
150 </button>
151 <button
152 onClick={reseed}
153 disabled={params.strike_count < 2}
154 title="Draw new timing and velocities from a new random seed (repeated figures only)"
155 >
156 ⚄ New seed
157 </button>
158 <button onClick={download} disabled={!result}>
159 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
160 </button>
161 <button onClick={reset}>Reset to defaults</button>
162 <button
163 className={codeOpen ? 'code active' : 'code'}
164 onClick={() => setCodeOpen((v) => !v)}
165 title="View and edit the MATLAB source that numbl executes"
166 >
167 {'{ }'} Code{codeEdited ? ' •' : ''}
168 </button>
169 <div className="status">
170 {error && <span className="badge err">code error</span>}
171 {result && (
172 <span className={`badge ${result.quality}`}>
173 {result.quality === 'full' ? 'full quality' : 'preview quality — press Render'}
174 </span>
175 )}
176 {busy ? (
177 <>
178 <span>{stage}…</span>
179 <div className="progressbar">
180 <div style={{ width: `${Math.round(progress * 100)}%` }} />
181 </div>
182 </>
183 ) : (
184 <span>ready</span>
185 )}
186 </div>
187 </div>
188 <div className="main">
189 <div className="sidebar">
190 <ParamPanel params={params} onChange={onChange} />
191 </div>
192 <div className="content">
193 {error && (
194 <div className="card wide error-card">
195 <h2>MATLAB error</h2>
196 <div>
197 <strong>
198 {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
199 </strong>{' '}
200 {error.message}
201 </div>
202 <div className="code-hint">
203 Showing the last successful render. Open Code to fix the source, or revert it.
204 </div>
205 </div>
206 )}
207 <div className="card wide">
208 <h2>Contact point — drag the stick along the profile</h2>
209 <StrikePicker
210 params={params}
211 onChange={onChange}
212 excDb={result?.excDb}
213 excBands={result?.cascadeBands}
214 excFLo={result?.cascadeFLo}
215 excFHi={result?.cascadeFHi}
216 />
217 </div>
218 <div className="card wide">
219 <h2>Geometry — plate, stick, microphone</h2>
220 <Scene3D params={params} />
221 </div>
222 {result && (
223 <>
224 <div className="card">
225 <h2>Diagnostics</h2>
226 <DiagnosticsPanel result={result} />
227 </div>
228 <div className="card">
229 <h2>Strokes</h2>
230 <StrikePlot
231 strikeT={result.strikeT}
232 strikeV={result.strikeV}
233 strikeS={result.strikeS}
234 durationS={result.diag.durationS}
235 />
236 </div>
237 <div className="card wide">
238 <h2>Modal energy — the cascade</h2>
239 <CascadePlot
240 cascadeDb={result.cascadeDb}
241 bands={result.cascadeBands}
242 frames={result.cascadeFrames}
243 fLo={result.cascadeFLo}
244 fHi={result.cascadeFHi}
245 tEnd={result.cascadeTEnd}
246 />
247 </div>
248 <div className="card">
249 <h2>Waveform</h2>
250 <WaveformPlot y={result.y} sampleRate={result.sampleRate} />
251 </div>
252 <div className="card">
253 <h2>Spectrum</h2>
254 <SpectrumPlot
255 y={result.y}
256 sampleRate={result.sampleRate}
257 modeF={result.modeF}
258 modeN={result.modeN}
259 />
260 </div>
261 <div className="card">
262 <h2>Spectrogram</h2>
263 <SpectrogramPlot y={result.y} sampleRate={result.sampleRate} />
264 </div>
265 <div className="card">
266 <h2>Plate modes</h2>
267 <ModeMap
268 modeF={result.modeF}
269 modeZeta={result.modeZeta}
270 modeZetaRad={result.modeZetaRad}
271 modeN={result.modeN}
272 />
273 </div>
274 <div className="card wide">
275 <h2>Mode shapes</h2>
276 <ModeGallery
277 nodeR={result.nodeR}
278 shapeW={result.shapeW}
279 modeF={result.modeF}
280 modeN={result.modeN}
281 />
282 </div>
283 </>
284 )}
285 </div>
286 </div>
287 {codeOpen && (
288 <CodePanel
289 mfiles={mfiles}
290 onChangeFile={onChangeFile}
291 onResetFile={onResetFile}
292 onResetAll={onResetAllFiles}
293 onClose={() => setCodeOpen(false)}
294 onRenderFull={renderFull}
295 error={error}
296 busy={busy}
297 />
298 )}
299 </div>
300 );
301}