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);
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 BassParams, value: number | boolean) => {
29 setParams((p) => ({ ...p, [key]: value }));
30 }, []);
32 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
34 // A new seed redraws the walking line without touching anything else. For
35 // a single note nothing is random, so the button is disabled there.
36 const reseed = useCallback(
37 () => setParams((p) => ({ ...p, seed: 1 + Math.floor(Math.random() * 9999) })),
38 [],
39 );
41 const onChangeFile = useCallback((name: string, source: string) => {
42 setMfiles((fs) => fs.map((f) => (f.name === name ? { ...f, source } : f)));
43 }, []);
45 const onResetFile = useCallback((name: string) => {
46 setMfiles((fs) =>
47 fs.map((f) =>
48 f.name === name
49 ? { ...f, source: DEFAULT_MFILES.find((d) => d.name === name)?.source ?? f.source }
50 : f,
51 ),
52 );
53 }, []);
55 const onResetAllFiles = useCallback(() => setMfiles(DEFAULT_MFILES.map((f) => ({ ...f }))), []);
56 const codeEdited = mfiles.some(
57 (f) => f.source !== DEFAULT_MFILES.find((d) => d.name === f.name)?.source,
58 );
60 const [looping, setLooping] = useState(false);
61 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
63 const getAudioCtx = useCallback((sampleRate: number) => {
64 let ctx = audioCtxRef.current;
65 if (!ctx || ctx.state === 'closed') {
66 ctx = new AudioContext({ sampleRate });
67 audioCtxRef.current = ctx;
68 }
69 void ctx.resume();
70 return ctx;
71 }, []);
73 const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
74 const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
75 buf.copyToChannel(new Float32Array(r.y), 0);
76 return buf;
77 }, []);
79 const play = useCallback(() => {
80 if (!result) return;
81 const ctx = getAudioCtx(result.sampleRate);
82 const src = ctx.createBufferSource();
83 src.buffer = makeBuffer(ctx, result);
84 src.connect(ctx.destination);
85 src.start();
86 }, [result, getAudioCtx, makeBuffer]);
88 // Loop mode: repeat the note or line continuously, swapping in each new
89 // render as parameters change so edits are audible without pressing Play.
90 useEffect(() => {
91 if (!looping || !result) return;
92 const ctx = getAudioCtx(result.sampleRate);
93 const src = ctx.createBufferSource();
94 src.buffer = makeBuffer(ctx, result);
95 src.loop = true;
96 src.connect(ctx.destination);
97 src.start();
98 loopSrcRef.current = src;
99 return () => {
100 loopSrcRef.current = null;
101 try {
102 src.stop();
103 } catch {
104 // already stopped
105 }
106 src.disconnect();
107 };
108 }, [looping, result, getAudioCtx, makeBuffer]);
110 const download = useCallback(() => {
111 if (!result) return;
112 const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
113 const url = URL.createObjectURL(blob);
114 const a = document.createElement('a');
115 a.href = url;
116 a.download = result.quality === 'full' ? 'bass.wav' : 'bass_preview.wav';
117 a.click();
118 setTimeout(() => URL.revokeObjectURL(url), 5000);
119 }, [result, params.bit_depth]);
121 return (
122 <div className="app">
123 <div className="topbar">
124 <h1>Standup Bass Simulator</h1>
125 <button className="primary" onClick={renderFull} disabled={busy}>
126 Render
127 </button>
128 <button
129 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
130 onClick={play}
131 disabled={!result}
132 title={
133 result?.quality === 'preview'
134 ? 'Playing the fast preview render; press Render for full quality'
135 : 'Play the rendered note or walking line'
136 }
137 >
138 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
139 </button>
140 <button
141 className={`loop ${looping ? 'active' : ''}`}
142 onClick={() => setLooping((v) => !v)}
143 disabled={!result && !looping}
144 title="Repeat the render continuously so parameter changes are audible immediately"
145 >
146 ⟳ Loop{looping ? ' on' : ''}
147 </button>
148 <button
149 onClick={reseed}
150 disabled={params.note_count < 2}
151 title="Draw a new walking line from a new random seed (lines only)"
152 >
153 ⚄ New seed
154 </button>
155 <button onClick={download} disabled={!result}>
156 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
157 </button>
158 <button onClick={reset}>Reset to defaults</button>
159 <button
160 className={codeOpen ? 'code active' : 'code'}
161 onClick={() => setCodeOpen((v) => !v)}
162 title="View and edit the MATLAB source that numbl executes"
163 >
164 {'{ }'} Code{codeEdited ? ' •' : ''}
165 </button>
166 <div className="status">
167 {error && <span className="badge err">code error</span>}
168 {result && (
169 <span className={`badge ${result.quality}`}>
170 {result.quality === 'full' ? 'full quality' : 'preview quality — press Render'}
171 </span>
172 )}
173 {busy ? (
174 <>
175 <span>{stage}…</span>
176 <div className="progressbar">
177 <div style={{ width: `${Math.round(progress * 100)}%` }} />
178 </div>
179 </>
180 ) : (
181 <span>ready</span>
182 )}
183 </div>
184 </div>
185 <div className="main">
186 <div className="sidebar">
187 <ParamPanel params={params} onChange={onChange} />
188 </div>
189 <div className="content">
190 {error && (
191 <div className="card wide error-card">
192 <h2>MATLAB error</h2>
193 <div>
194 <strong>
195 {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
196 </strong>{' '}
197 {error.message}
198 </div>
199 <div className="code-hint">
200 Showing the last successful render. Open Code to fix the source, or revert it.
201 </div>
202 </div>
203 )}
204 <div className="card wide">
205 <h2>Geometry — bass, pluck, microphone</h2>
206 <Scene3D params={params} />
207 </div>
208 {result && (
209 <>
210 <div className="card">
211 <h2>Diagnostics</h2>
212 <DiagnosticsPanel result={result} />
213 </div>
214 <div className="card">
215 <h2>Notes</h2>
216 <NotesPlot
217 noteT={result.noteT}
218 noteMidi={result.noteMidi}
219 noteStr={result.noteStr}
220 noteForce={result.noteForce}
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.mode1F}
234 modeBodyFrac={result.mode1BodyFrac}
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>Bridge admittance</h2>
243 <AdmittancePlot
244 bodyF={result.bodyF}
245 bodyZeta={result.bodyZeta}
246 bodyA={result.bodyA}
247 bodyMono={result.bodyMono}
248 noteMidi={result.noteMidi}
249 tuningTrim={params.tuning_trim}
250 residualY={params.residual_y}
251 />
252 </div>
253 <div className="card wide">
254 <h2>Coupled modes</h2>
255 <ModeMap
256 modeF={result.mode1F}
257 modeZeta={result.mode1Zeta}
258 modeBodyFrac={result.mode1BodyFrac}
259 />
260 </div>
261 </>
262 )}
263 </div>
264 </div>
265 {codeOpen && (
266 <CodePanel
267 mfiles={mfiles}
268 onChangeFile={onChangeFile}
269 onResetFile={onResetFile}
270 onResetAll={onResetAllFiles}
271 onClose={() => setCodeOpen(false)}
272 onRenderFull={renderFull}
273 error={error}
274 busy={busy}
275 />
276 )}
277 </div>
278 );
279}