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