/ simulated_instruments / triangle
Sign in
simulated_instruments / triangle
triangle / webapp / src / App.tsx
280 lines · 9.8 KBCodeBlameHistory
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);
23 const { result, error, busy, stage, progress, renderFull } = useRenderer(params, mfiles);
24 const audioCtxRef = useRef<AudioContext | null>(null);
26 useEffect(() => saveParams(params), [params]);
28 const onChange = useCallback((key: keyof TriangleParams, value: number | boolean) => {
29 setParams((p) => ({ ...p, [key]: value }));
30 }, []);
32 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
34 // A new seed redraws the roll timing and velocities without touching
35 // anything else. For a single strike nothing is random, so the button is
36 // disabled there.
37 const reseed = useCallback(
38 () => setParams((p) => ({ ...p, seed: 1 + Math.floor(Math.random() * 9999) })),
39 [],
40 );
42 const onChangeFile = useCallback((name: string, source: string) => {
43 setMfiles((fs) => fs.map((f) => (f.name === name ? { ...f, source } : f)));
44 }, []);
46 const onResetFile = useCallback((name: string) => {
47 setMfiles((fs) =>
48 fs.map((f) =>
49 f.name === name
50 ? { ...f, source: DEFAULT_MFILES.find((d) => d.name === name)?.source ?? f.source }
51 : f,
52 ),
53 );
54 }, []);
56 const onResetAllFiles = useCallback(() => setMfiles(DEFAULT_MFILES.map((f) => ({ ...f }))), []);
57 const codeEdited = mfiles.some(
58 (f) => f.source !== DEFAULT_MFILES.find((d) => d.name === f.name)?.source,
59 );
61 const [looping, setLooping] = useState(false);
62 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
64 const getAudioCtx = useCallback((sampleRate: number) => {
65 let ctx = audioCtxRef.current;
66 if (!ctx || ctx.state === 'closed') {
67 ctx = new AudioContext({ sampleRate });
68 audioCtxRef.current = ctx;
69 }
70 void ctx.resume();
71 return ctx;
72 }, []);
74 const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
75 const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
76 buf.copyToChannel(new Float32Array(r.y), 0);
77 return buf;
78 }, []);
80 const play = useCallback(() => {
81 if (!result) return;
82 const ctx = getAudioCtx(result.sampleRate);
83 const src = ctx.createBufferSource();
84 src.buffer = makeBuffer(ctx, result);
85 src.connect(ctx.destination);
86 src.start();
87 }, [result, getAudioCtx, makeBuffer]);
89 // Loop mode: repeat the strike or roll continuously, swapping in each new
90 // render as parameters change so edits are audible without pressing Play.
91 useEffect(() => {
92 if (!looping || !result) return;
93 const ctx = getAudioCtx(result.sampleRate);
94 const src = ctx.createBufferSource();
95 src.buffer = makeBuffer(ctx, result);
96 src.loop = true;
97 src.connect(ctx.destination);
98 src.start();
99 loopSrcRef.current = src;
100 return () => {
101 loopSrcRef.current = null;
102 try {
103 src.stop();
104 } catch {
105 // already stopped
106 }
107 src.disconnect();
108 };
109 }, [looping, result, getAudioCtx, makeBuffer]);
111 const download = useCallback(() => {
112 if (!result) return;
113 const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
114 const url = URL.createObjectURL(blob);
115 const a = document.createElement('a');
116 a.href = url;
117 a.download = result.quality === 'full' ? 'triangle.wav' : 'triangle_preview.wav';
118 a.click();
119 setTimeout(() => URL.revokeObjectURL(url), 5000);
120 }, [result, params.bit_depth]);
122 return (
123 <div className="app">
124 <div className="topbar">
125 <h1>Triangle Simulator</h1>
126 <button className="primary" onClick={renderFull} disabled={busy}>
127 Render
128 </button>
129 <button
130 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
131 onClick={play}
132 disabled={!result}
133 title={
134 result?.quality === 'preview'
135 ? 'Playing the fast preview render; press Render for full quality'
136 : 'Play the rendered strike'
137 }
138 >
139 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
140 </button>
141 <button
142 className={`loop ${looping ? 'active' : ''}`}
143 onClick={() => setLooping((v) => !v)}
144 disabled={!result && !looping}
145 title="Repeat the strike continuously so parameter changes are audible immediately"
146 >
147 ⟳ Loop{looping ? ' on' : ''}
148 </button>
149 <button
150 onClick={reseed}
151 disabled={params.strike_count < 2}
152 title="Draw a new roll pattern from a new random seed (rolls only)"
153 >
154 ⚄ New seed
155 </button>
156 <button onClick={download} disabled={!result}>
157 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
158 </button>
159 <button onClick={reset}>Reset to defaults</button>
160 <button
161 className={codeOpen ? 'code active' : 'code'}
162 onClick={() => setCodeOpen((v) => !v)}
163 title="View and edit the MATLAB source that numbl executes"
164 >
165 {'{ }'} Code{codeEdited ? ' •' : ''}
166 </button>
167 <div className="status">
168 {error && <span className="badge err">code error</span>}
169 {result && (
170 <span className={`badge ${result.quality}`}>
171 {result.quality === 'full' ? 'full quality' : 'preview quality — press Render'}
172 </span>
173 )}
174 {busy ? (
175 <>
176 <span>{stage}…</span>
177 <div className="progressbar">
178 <div style={{ width: `${Math.round(progress * 100)}%` }} />
179 </div>
180 </>
181 ) : (
182 <span>ready</span>
183 )}
184 </div>
185 </div>
186 <div className="main">
187 <div className="sidebar">
188 <ParamPanel params={params} onChange={onChange} />
189 </div>
190 <div className="content">
191 {error && (
192 <div className="card wide error-card">
193 <h2>MATLAB error</h2>
194 <div>
195 <strong>
196 {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
197 </strong>{' '}
198 {error.message}
199 </div>
200 <div className="code-hint">
201 Showing the last successful render. Open Code to fix the source, or revert it.
202 </div>
203 </div>
204 )}
205 <div className="card wide">
206 <h2>Geometry — rod, beater, microphone</h2>
207 <Scene3D params={params} />
208 </div>
209 {result && (
210 <>
211 <div className="card">
212 <h2>Diagnostics</h2>
213 <DiagnosticsPanel result={result} />
214 </div>
215 <div className="card">
216 <h2>Strikes</h2>
217 <StrikePlot
218 strikeT={result.strikeT}
219 strikeV={result.strikeV}
220 strikeS={result.strikeS}
221 durationS={result.diag.durationS}
222 />
223 </div>
224 <div className="card">
225 <h2>Waveform</h2>
226 <WaveformPlot y={result.y} sampleRate={result.sampleRate} />
227 </div>
228 <div className="card">
229 <h2>Spectrum</h2>
230 <SpectrumPlot
231 y={result.y}
232 sampleRate={result.sampleRate}
233 modeF={result.modeF}
234 modeFamily={result.modeFamily}
235 />
236 </div>
237 <div className="card">
238 <h2>Spectrogram</h2>
239 <SpectrogramPlot y={result.y} sampleRate={result.sampleRate} />
240 </div>
241 <div className="card">
242 <h2>Rod modes</h2>
243 <ModeMap
244 modeF={result.modeF}
245 modeZeta={result.modeZeta}
246 modeZetaRad={result.modeZetaRad}
247 modeFamily={result.modeFamily}
248 />
249 </div>
250 <div className="card wide">
251 <h2>Mode shapes</h2>
252 <ModeShapes
253 nodeX={result.nodeX}
254 nodeY={result.nodeY}
255 shapeUx={result.shapeUx}
256 shapeUy={result.shapeUy}
257 shapeUz={result.shapeUz}
258 modeF={result.modeF}
259 modeFamily={result.modeFamily}
260 />
261 </div>
262 </>
263 )}
264 </div>
265 </div>
266 {codeOpen && (
267 <CodePanel
268 mfiles={mfiles}
269 onChangeFile={onChangeFile}
270 onResetFile={onResetFile}
271 onResetAll={onResetAllFiles}
272 onClose={() => setCodeOpen(false)}
273 onRenderFull={renderFull}
274 error={error}
275 busy={busy}
276 />
277 )}
278 </div>
279 );
↑↓ move↵openescclose