96aec9cTriangle strike simulator: MATLAB model and interactive web appJeremy Magland 1import { useCallback, useEffect, useRef, useState } from 'react';
2import { DEFAULTS, loadParams, saveParams, type TriangleParams } 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 ModeShapes from './components/plots/ModeShapes';
17export default function App() {
18 const [params, setParams] = useState<TriangleParams>(() => loadParams());
19 // Editable copy of the MATLAB implementation. Deliberately not persisted:
20 // a reload restores the code the app shipped with.
21 const [mfiles, setMfiles] = useState<MFile[]>(() => DEFAULT_MFILES.map((f) => ({ ...f })));
22 const [codeOpen, setCodeOpen] = useState(false);
90d6c4fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 23 const { result, error, busy, stage, progress, elapsedS, renderPreview, renderFull, cancel } =
24 useRenderer(params, mfiles);
96aec9cTriangle strike simulator: MATLAB model and interactive web appJeremy Magland 25 const audioCtxRef = useRef<AudioContext | null>(null);
27 useEffect(() => saveParams(params), [params]);
29 const onChange = useCallback((key: keyof TriangleParams, value: number | boolean) => {
30 setParams((p) => ({ ...p, [key]: value }));
31 }, []);
33 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
35 // A new seed redraws the roll timing and velocities without touching
36 // anything else. For a single strike nothing is random, 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);
90d6c4fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 65 // A failed render clears the result, so a loop left running has nothing to
66 // play; drop out of loop mode rather than leaving the button lit.
67 useEffect(() => {
68 if (!result) setLooping(false);
69 }, [result]);
96aec9cTriangle strike simulator: MATLAB model and interactive web appJeremy Magland 71 const getAudioCtx = useCallback((sampleRate: number) => {
72 let ctx = audioCtxRef.current;
73 if (!ctx || ctx.state === 'closed') {
74 ctx = new AudioContext({ sampleRate });
75 audioCtxRef.current = ctx;
76 }
77 void ctx.resume();
78 return ctx;
79 }, []);
81 const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
82 const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
83 buf.copyToChannel(new Float32Array(r.y), 0);
84 return buf;
85 }, []);
87 const play = useCallback(() => {
88 if (!result) return;
89 const ctx = getAudioCtx(result.sampleRate);
90 const src = ctx.createBufferSource();
91 src.buffer = makeBuffer(ctx, result);
92 src.connect(ctx.destination);
93 src.start();
94 }, [result, getAudioCtx, makeBuffer]);
90d6c4fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 96 // Loop mode: repeat the last render continuously, swapping in each new
97 // render as it completes.
96aec9cTriangle strike simulator: MATLAB model and interactive web appJeremy Magland 98 useEffect(() => {
99 if (!looping || !result) return;
100 const ctx = getAudioCtx(result.sampleRate);
101 const src = ctx.createBufferSource();
102 src.buffer = makeBuffer(ctx, result);
103 src.loop = true;
104 src.connect(ctx.destination);
105 src.start();
106 loopSrcRef.current = src;
107 return () => {
108 loopSrcRef.current = null;
109 try {
110 src.stop();
111 } catch {
112 // already stopped
113 }
114 src.disconnect();
115 };
116 }, [looping, result, getAudioCtx, makeBuffer]);
118 const download = useCallback(() => {
119 if (!result) return;
120 const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
121 const url = URL.createObjectURL(blob);
122 const a = document.createElement('a');
123 a.href = url;
124 a.download = result.quality === 'full' ? 'triangle.wav' : 'triangle_preview.wav';
125 a.click();
126 setTimeout(() => URL.revokeObjectURL(url), 5000);
127 }, [result, params.bit_depth]);
129 return (
130 <div className="app">
131 <div className="topbar">
132 <h1>Triangle Simulator</h1>
134 className="primary"
135 onClick={renderPreview}
136 disabled={busy}
137 title="No oversampling, everything else unchanged — the fast pass"
138 >
139 Render preview
140 </button>
141 <button
142 className="primary"
143 onClick={renderFull}
144 disabled={busy}
145 title="Full quality at the oversampling the parameters ask for"
146 >
147 Render full
150 <button onClick={cancel} title="Stop the render">
151 ✕ Cancel
152 </button>
153 )}
155 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
156 onClick={play}
157 disabled={!result}
158 title={
159 result?.quality === 'preview'
90d6c4fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 160 ? 'Playing the preview render; press Render full for full quality'
163 >
164 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
165 </button>
166 <button
167 className={`loop ${looping ? 'active' : ''}`}
168 onClick={() => setLooping((v) => !v)}
90d6c4fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 169 disabled={!result}
170 title="Repeat the last render continuously"
172 ⟳ Loop{looping ? ' on' : ''}
173 </button>
174 <button
175 onClick={reseed}
176 disabled={params.strike_count < 2}
177 title="Draw a new roll pattern from a new random seed (rolls only)"
178 >
179 ⚄ New seed
180 </button>
181 <button onClick={download} disabled={!result}>
182 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
183 </button>
184 <button onClick={reset}>Reset to defaults</button>
185 <button
186 className={codeOpen ? 'code active' : 'code'}
187 onClick={() => setCodeOpen((v) => !v)}
188 title="View and edit the MATLAB source that numbl executes"
189 >
190 {'{ }'} Code{codeEdited ? ' •' : ''}
191 </button>
192 <div className="status">
193 {error && <span className="badge err">code error</span>}
90d6c4fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 194 {result && !busy && (
96aec9cTriangle strike simulator: MATLAB model and interactive web appJeremy Magland 195 <span className={`badge ${result.quality}`}>
90d6c4fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 196 {result.quality === 'full' ? 'full quality' : 'preview quality'}
198 )}
199 {busy ? (
200 <>
202 {stage}… {elapsedS.toFixed(0)} s
203 </span>
96aec9cTriangle strike simulator: MATLAB model and interactive web appJeremy Magland 204 <div className="progressbar">
205 <div style={{ width: `${Math.round(progress * 100)}%` }} />
206 </div>
207 </>
208 ) : (
209 <span>ready</span>
210 )}
211 </div>
212 </div>
213 <div className="main">
214 <div className="sidebar">
215 <ParamPanel params={params} onChange={onChange} />
216 </div>
217 <div className="content">
218 {error && (
219 <div className="card wide error-card">
220 <h2>MATLAB error</h2>
221 <div>
222 <strong>
223 {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
224 </strong>{' '}
225 {error.message}
226 </div>
227 <div className="code-hint">
90d6c4fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 228 Nothing is playable until a render succeeds. Open Code to fix the source, or
229 revert it.
231 </div>
232 )}
233 <div className="card wide">
234 <h2>Geometry — rod, beater, microphone</h2>
235 <Scene3D params={params} />
236 </div>
237 {result && (
238 <>
239 <div className="card">
240 <h2>Diagnostics</h2>
241 <DiagnosticsPanel result={result} />
242 </div>
243 <div className="card">
244 <h2>Strikes</h2>
245 <StrikePlot
246 strikeT={result.strikeT}
247 strikeV={result.strikeV}
248 strikeS={result.strikeS}
249 durationS={result.diag.durationS}
250 />
251 </div>
252 <div className="card">
253 <h2>Waveform</h2>
254 <WaveformPlot y={result.y} sampleRate={result.sampleRate} />
255 </div>
256 <div className="card">
257 <h2>Spectrum</h2>
258 <SpectrumPlot
259 y={result.y}
260 sampleRate={result.sampleRate}
261 modeF={result.modeF}
262 modeFamily={result.modeFamily}
263 />
264 </div>
265 <div className="card">
266 <h2>Spectrogram</h2>
267 <SpectrogramPlot y={result.y} sampleRate={result.sampleRate} />
268 </div>
269 <div className="card">
270 <h2>Rod modes</h2>
271 <ModeMap
272 modeF={result.modeF}
273 modeZeta={result.modeZeta}
274 modeZetaRad={result.modeZetaRad}
275 modeFamily={result.modeFamily}
276 />
277 </div>
278 <div className="card wide">
279 <h2>Mode shapes</h2>
280 <ModeShapes
281 nodeX={result.nodeX}
282 nodeY={result.nodeY}
283 shapeUx={result.shapeUx}
284 shapeUy={result.shapeUy}
285 shapeUz={result.shapeUz}
286 modeF={result.modeF}
287 modeFamily={result.modeFamily}
288 />
289 </div>
290 </>
291 )}
90d6c4fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 292 {!result && !busy && !error && (
293 <div className="card wide">
294 <h2>No render yet</h2>
295 <div className="code-hint">
296 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.
297 </div>
298 </div>
299 )}
301 </div>
302 {codeOpen && (
303 <CodePanel
304 mfiles={mfiles}
305 onChangeFile={onChangeFile}
306 onResetFile={onResetFile}
307 onResetAll={onResetAllFiles}
308 onClose={() => setCodeOpen(false)}
309 onRenderFull={renderFull}
310 error={error}
311 busy={busy}
312 />
313 )}
314 </div>
315 );
316}