/ simulated_instruments / woodblock
Sign in
simulated_instruments / woodblock
woodblock / webapp / src / App.tsx
288 lines · 10.0 KBBlameHistoryRaw
1import { useCallback, useEffect, useRef, useState } from 'react';
2import { DEFAULTS, loadParams, saveParams, type WoodblockParams } 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 ContactForcePlot from './components/plots/ContactForcePlot';
14import ModeShapes from './components/plots/ModeShapes';
16export default function App() {
17 const [params, setParams] = useState<WoodblockParams>(() => loadParams());
18 // Editable copy of the MATLAB implementation. Deliberately not persisted:
19 // a reload restores the code the app shipped with.
20 const [mfiles, setMfiles] = useState<MFile[]>(() => DEFAULT_MFILES.map((f) => ({ ...f })));
21 const [codeOpen, setCodeOpen] = useState(false);
22 const { result, error, busy, stage, progress, elapsedS, renderPreview, renderFull, cancel } =
23 useRenderer(params, mfiles);
24 const audioCtxRef = useRef<AudioContext | null>(null);
26 useEffect(() => saveParams(params), [params]);
28 const onChange = useCallback((key: keyof WoodblockParams, value: number | boolean) => {
29 setParams((p) => ({ ...p, [key]: value }));
30 }, []);
32 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
34 const onChangeFile = useCallback((name: string, source: string) => {
35 setMfiles((fs) => fs.map((f) => (f.name === name ? { ...f, source } : f)));
36 }, []);
38 const onResetFile = useCallback((name: string) => {
39 setMfiles((fs) =>
40 fs.map((f) =>
41 f.name === name
42 ? { ...f, source: DEFAULT_MFILES.find((d) => d.name === name)?.source ?? f.source }
43 : f,
44 ),
45 );
46 }, []);
48 const onResetAllFiles = useCallback(() => setMfiles(DEFAULT_MFILES.map((f) => ({ ...f }))), []);
49 const codeEdited = mfiles.some(
50 (f) => f.source !== DEFAULT_MFILES.find((d) => d.name === f.name)?.source,
51 );
53 const [looping, setLooping] = useState(false);
54 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
56 // A failed render clears the result, so a loop left running has nothing to
57 // play; drop out of loop mode rather than leaving the button lit.
58 useEffect(() => {
59 if (!result) setLooping(false);
60 }, [result]);
62 const getAudioCtx = useCallback((sampleRate: number) => {
63 let ctx = audioCtxRef.current;
64 if (!ctx || ctx.state === 'closed') {
65 ctx = new AudioContext({ sampleRate });
66 audioCtxRef.current = ctx;
67 }
68 void ctx.resume();
69 return ctx;
70 }, []);
72 const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
73 const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
74 buf.copyToChannel(new Float32Array(r.y), 0);
75 return buf;
76 }, []);
78 const play = useCallback(() => {
79 if (!result) return;
80 const ctx = getAudioCtx(result.sampleRate);
81 const src = ctx.createBufferSource();
82 src.buffer = makeBuffer(ctx, result);
83 src.connect(ctx.destination);
84 src.start();
85 }, [result, getAudioCtx, makeBuffer]);
87 // Loop mode: repeat the last render continuously, swapping in each new
88 // render as it completes.
89 useEffect(() => {
90 if (!looping || !result) return;
91 const ctx = getAudioCtx(result.sampleRate);
92 const src = ctx.createBufferSource();
93 src.buffer = makeBuffer(ctx, result);
94 src.loop = true;
95 src.connect(ctx.destination);
96 src.start();
97 loopSrcRef.current = src;
98 return () => {
99 loopSrcRef.current = null;
100 try {
101 src.stop();
102 } catch {
103 // already stopped
104 }
105 src.disconnect();
106 };
107 }, [looping, result, getAudioCtx, makeBuffer]);
109 const download = useCallback(() => {
110 if (!result) return;
111 const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
112 const url = URL.createObjectURL(blob);
113 const a = document.createElement('a');
114 a.href = url;
115 a.download = result.quality === 'full' ? 'woodblock.wav' : 'woodblock_preview.wav';
116 a.click();
117 setTimeout(() => URL.revokeObjectURL(url), 5000);
118 }, [result, params.bit_depth]);
120 return (
121 <div className="app">
122 <div className="topbar">
123 <h1>Woodblock Simulator</h1>
124 <button
125 className="primary"
126 onClick={renderPreview}
127 disabled={busy}
128 title="Oversampling capped at 8× and the radiating panel coarsened — the fast pass"
129 >
130 Render preview
131 </button>
132 <button
133 className="primary"
134 onClick={renderFull}
135 disabled={busy}
136 title="Full quality at the oversampling the parameters ask for"
137 >
138 Render full
139 </button>
140 {busy && (
141 <button onClick={cancel} title="Stop the render">
142 ✕ Cancel
143 </button>
144 )}
145 <button
146 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
147 onClick={play}
148 disabled={!result}
149 title={
150 result?.quality === 'preview'
151 ? 'Playing the preview render; press Render full for full quality'
152 : 'Play the rendered strike'
153 }
154 >
155 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
156 </button>
157 <button
158 className={`loop ${looping ? 'active' : ''}`}
159 onClick={() => setLooping((v) => !v)}
160 disabled={!result}
161 title="Repeat the last render continuously"
162 >
163 ⟳ Loop{looping ? ' on' : ''}
164 </button>
165 <button onClick={download} disabled={!result}>
166 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
167 </button>
168 <button onClick={reset}>Reset to defaults</button>
169 <button
170 className={codeOpen ? 'code active' : 'code'}
171 onClick={() => setCodeOpen((v) => !v)}
172 title="View and edit the MATLAB source that numbl executes"
173 >
174 {'{ }'} Code{codeEdited ? ' •' : ''}
175 </button>
176 <div className="status">
177 {error && <span className="badge err">code error</span>}
178 {result && !busy && (
179 <span className={`badge ${result.quality}`}>
180 {result.quality === 'full' ? 'full quality' : 'preview quality'}
181 </span>
182 )}
183 {busy ? (
184 <>
185 <span>
186 {stage}… {elapsedS.toFixed(0)} s
187 </span>
188 <div className="progressbar">
189 <div style={{ width: `${Math.round(progress * 100)}%` }} />
190 </div>
191 </>
192 ) : (
193 <span>ready</span>
194 )}
195 </div>
196 </div>
197 <div className="main">
198 <div className="sidebar">
199 <ParamPanel params={params} onChange={onChange} />
200 </div>
201 <div className="content">
202 {error && (
203 <div className="card wide error-card">
204 <h2>MATLAB error</h2>
205 <div>
206 <strong>
207 {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
208 </strong>{' '}
209 {error.message}
210 </div>
211 <div className="code-hint">
212 Nothing is playable until a render succeeds. Open Code to fix the source, or
213 revert it.
214 </div>
215 </div>
216 )}
217 <div className="card wide">
218 <h2>Geometry — block, strike, slit, microphone</h2>
219 <Scene3D params={params} />
220 </div>
221 {result && (
222 <>
223 <div className="card">
224 <h2>Diagnostics</h2>
225 <DiagnosticsPanel result={result} />
226 </div>
227 <div className="card">
228 <h2>Contact force</h2>
229 <ContactForcePlot
230 force={result.force}
231 forceFsHz={result.forceFsHz}
232 peakForceN={result.diag.peakForceN}
233 contactMs={result.diag.contactMs}
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 partialsHz={result.diag.partialsHz}
246 helmholtzHz={result.diag.helmholtzHz}
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>Mode shapes</h2>
255 <ModeShapes
256 modeShapes={result.modeShapes}
257 shapePts={result.shapePts}
258 partialsHz={result.diag.partialsHz}
259 strikePosition={params.strike_position}
260 />
261 </div>
262 </>
263 )}
264 {!result && !busy && !error && (
265 <div className="card wide">
266 <h2>No render yet</h2>
267 <div className="code-hint">
268 Press Render preview for a fast pass at reduced oversampling, or Render full for the real thing. Nothing renders on its own: change what you like, then render.
269 </div>
270 </div>
271 )}
272 </div>
273 </div>
274 {codeOpen && (
275 <CodePanel
276 mfiles={mfiles}
277 onChangeFile={onChangeFile}
278 onResetFile={onResetFile}
279 onResetAll={onResetAllFiles}
280 onClose={() => setCodeOpen(false)}
281 onRenderFull={renderFull}
282 error={error}
283 busy={busy}
284 />
285 )}
286 </div>
287 );
↑↓ move↵openescclose