/ simulated_instruments / bass
Sign in
simulated_instruments / bass
bass / webapp / src / App.tsx
315 lines · 10.9 KBCodeBlameHistory
0e92d35Pizzicato standup bass: coupled string-body modal model, numbl MATLAB reference, and interactive web appJeremy Magland 1import { useCallback, useEffect, useRef, useState } from 'react';
2import { DEFAULTS, loadParams, saveParams, type BassParams } 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 NotesPlot from './components/plots/NotesPlot';
14import ModeMap from './components/plots/ModeMap';
15import AdmittancePlot from './components/plots/AdmittancePlot';
17export default function App() {
18 const [params, setParams] = useState<BassParams>(() => 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);
70a9b6fExplicit 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);
0e92d35Pizzicato standup bass: coupled string-body modal model, numbl MATLAB reference, and interactive web appJeremy Magland 25 const audioCtxRef = useRef<AudioContext | null>(null);
27 useEffect(() => saveParams(params), [params]);
29 const onChange = useCallback((key: keyof BassParams, value: number | boolean) => {
30 setParams((p) => ({ ...p, [key]: value }));
31 }, []);
33 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
35 // A new seed redraws the walking line without touching anything else. For
36 // a single note nothing is random, so the button is 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);
70a9b6fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 64 // A failed render clears the result, so a loop left running has nothing to
65 // play; drop out of loop mode rather than leaving the button lit.
66 useEffect(() => {
67 if (!result) setLooping(false);
68 }, [result]);
0e92d35Pizzicato standup bass: coupled string-body modal model, numbl MATLAB reference, and interactive web appJeremy Magland 70 const getAudioCtx = useCallback((sampleRate: number) => {
71 let ctx = audioCtxRef.current;
72 if (!ctx || ctx.state === 'closed') {
73 ctx = new AudioContext({ sampleRate });
74 audioCtxRef.current = ctx;
75 }
76 void ctx.resume();
77 return ctx;
78 }, []);
80 const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
81 const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
82 buf.copyToChannel(new Float32Array(r.y), 0);
83 return buf;
84 }, []);
86 const play = useCallback(() => {
87 if (!result) return;
88 const ctx = getAudioCtx(result.sampleRate);
89 const src = ctx.createBufferSource();
90 src.buffer = makeBuffer(ctx, result);
91 src.connect(ctx.destination);
92 src.start();
93 }, [result, getAudioCtx, makeBuffer]);
70a9b6fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 95 // Loop mode: repeat the last render continuously, swapping in each new
96 // render as it completes.
98 if (!looping || !result) return;
99 const ctx = getAudioCtx(result.sampleRate);
100 const src = ctx.createBufferSource();
101 src.buffer = makeBuffer(ctx, result);
102 src.loop = true;
103 src.connect(ctx.destination);
104 src.start();
105 loopSrcRef.current = src;
106 return () => {
107 loopSrcRef.current = null;
108 try {
109 src.stop();
110 } catch {
111 // already stopped
112 }
113 src.disconnect();
114 };
115 }, [looping, result, getAudioCtx, makeBuffer]);
117 const download = useCallback(() => {
118 if (!result) return;
119 const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
120 const url = URL.createObjectURL(blob);
121 const a = document.createElement('a');
122 a.href = url;
123 a.download = result.quality === 'full' ? 'bass.wav' : 'bass_preview.wav';
124 a.click();
125 setTimeout(() => URL.revokeObjectURL(url), 5000);
126 }, [result, params.bit_depth]);
128 return (
129 <div className="app">
130 <div className="topbar">
131 <h1>Standup Bass Simulator</h1>
133 className="primary"
134 onClick={renderPreview}
135 disabled={busy}
136 title="No oversampling, everything else unchanged — the fast pass"
137 >
138 Render preview
139 </button>
140 <button
141 className="primary"
142 onClick={renderFull}
143 disabled={busy}
144 title="Full quality at the oversampling the parameters ask for"
145 >
146 Render full
149 <button onClick={cancel} title="Stop the render">
150 ✕ Cancel
151 </button>
152 )}
154 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
155 onClick={play}
156 disabled={!result}
157 title={
158 result?.quality === 'preview'
70a9b6fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 159 ? 'Playing the preview render; press Render full for full quality'
161 }
162 >
163 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
164 </button>
165 <button
166 className={`loop ${looping ? 'active' : ''}`}
167 onClick={() => setLooping((v) => !v)}
70a9b6fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 168 disabled={!result}
169 title="Repeat the last render continuously"
171 ⟳ Loop{looping ? ' on' : ''}
172 </button>
173 <button
174 onClick={reseed}
175 disabled={params.note_count < 2}
176 title="Draw a new walking line from a new random seed (lines only)"
177 >
178 ⚄ New seed
179 </button>
180 <button onClick={download} disabled={!result}>
181 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
182 </button>
183 <button onClick={reset}>Reset to defaults</button>
184 <button
185 className={codeOpen ? 'code active' : 'code'}
186 onClick={() => setCodeOpen((v) => !v)}
187 title="View and edit the MATLAB source that numbl executes"
188 >
189 {'{ }'} Code{codeEdited ? ' •' : ''}
190 </button>
191 <div className="status">
192 {error && <span className="badge err">code error</span>}
70a9b6fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 193 {result && !busy && (
70a9b6fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 195 {result.quality === 'full' ? 'full quality' : 'preview quality'}
197 )}
198 {busy ? (
199 <>
201 {stage}… {elapsedS.toFixed(0)} s
202 </span>
204 <div style={{ width: `${Math.round(progress * 100)}%` }} />
205 </div>
206 </>
207 ) : (
208 <span>ready</span>
209 )}
210 </div>
211 </div>
212 <div className="main">
213 <div className="sidebar">
214 <ParamPanel params={params} onChange={onChange} />
215 </div>
216 <div className="content">
217 {error && (
218 <div className="card wide error-card">
219 <h2>MATLAB error</h2>
220 <div>
221 <strong>
222 {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
223 </strong>{' '}
224 {error.message}
225 </div>
226 <div className="code-hint">
70a9b6fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 227 Nothing is playable until a render succeeds. Open Code to fix the source, or
228 revert it.
230 </div>
231 )}
232 <div className="card wide">
233 <h2>Geometry — bass, pluck, microphone</h2>
234 <Scene3D params={params} />
235 </div>
236 {result && (
237 <>
238 <div className="card">
239 <h2>Diagnostics</h2>
240 <DiagnosticsPanel result={result} />
241 </div>
242 <div className="card">
243 <h2>Notes</h2>
244 <NotesPlot
245 noteT={result.noteT}
246 noteMidi={result.noteMidi}
247 noteStr={result.noteStr}
248 noteForce={result.noteForce}
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.mode1F}
262 modeBodyFrac={result.mode1BodyFrac}
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>Bridge admittance</h2>
271 <AdmittancePlot
272 bodyF={result.bodyF}
273 bodyZeta={result.bodyZeta}
274 bodyA={result.bodyA}
275 bodyMono={result.bodyMono}
276 noteMidi={result.noteMidi}
277 tuningTrim={params.tuning_trim}
278 residualY={params.residual_y}
279 />
280 </div>
281 <div className="card wide">
282 <h2>Coupled modes</h2>
283 <ModeMap
284 modeF={result.mode1F}
285 modeZeta={result.mode1Zeta}
286 modeBodyFrac={result.mode1BodyFrac}
287 />
288 </div>
289 </>
290 )}
70a9b6fExplicit preview and full renders, as in the cymbal2 appJeremy Magland 291 {!result && !busy && !error && (
292 <div className="card wide">
293 <h2>No render yet</h2>
294 <div className="code-hint">
295 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.
296 </div>
297 </div>
298 )}
300 </div>
301 {codeOpen && (
302 <CodePanel
303 mfiles={mfiles}
304 onChangeFile={onChangeFile}
305 onResetFile={onResetFile}
306 onResetAll={onResetAllFiles}
307 onClose={() => setCodeOpen(false)}
308 onRenderFull={renderFull}
309 error={error}
310 busy={busy}
311 />
312 )}
313 </div>
314 );
↑↓ move↵openescclose