5623b11Recorder simulator: MATLAB jet-drive model and interactive web appJeremy Magland 1import { useCallback, useEffect, useRef, useState } from 'react';
2import { DEFAULTS, loadParams, saveParams, type RecorderParams } 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 BreathPlot from './components/plots/BreathPlot';
14import AdmittancePlot from './components/plots/AdmittancePlot';
15import BoreProfile from './components/plots/BoreProfile';
17export default function App() {
18 const [params, setParams] = useState<RecorderParams>(() => 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);
3a84d42Explicit 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);
5623b11Recorder simulator: MATLAB jet-drive model and interactive web appJeremy Magland 25 const audioCtxRef = useRef<AudioContext | null>(null);
27 useEffect(() => saveParams(params), [params]);
29 const onChange = useCallback((key: keyof RecorderParams, value: number | boolean) => {
30 setParams((p) => ({ ...p, [key]: value }));
31 }, []);
33 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
35 // A new seed redraws the breath turbulence without touching anything else.
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);
3a84d42Explicit preview and full renders, as in the cymbal2 appJeremy Magland 63 // A failed render clears the result, so a loop left running has nothing to
64 // play; drop out of loop mode rather than leaving the button lit.
65 useEffect(() => {
66 if (!result) setLooping(false);
67 }, [result]);
5623b11Recorder simulator: MATLAB jet-drive model and interactive web appJeremy Magland 69 const getAudioCtx = useCallback((sampleRate: number) => {
70 let ctx = audioCtxRef.current;
71 if (!ctx || ctx.state === 'closed') {
72 ctx = new AudioContext({ sampleRate });
73 audioCtxRef.current = ctx;
74 }
75 void ctx.resume();
76 return ctx;
77 }, []);
79 const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
80 const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
81 buf.copyToChannel(new Float32Array(r.y), 0);
82 return buf;
83 }, []);
85 const play = useCallback(() => {
86 if (!result) return;
87 const ctx = getAudioCtx(result.sampleRate);
88 const src = ctx.createBufferSource();
89 src.buffer = makeBuffer(ctx, result);
90 src.connect(ctx.destination);
91 src.start();
92 }, [result, getAudioCtx, makeBuffer]);
3a84d42Explicit preview and full renders, as in the cymbal2 appJeremy Magland 94 // Loop mode: repeat the last render continuously, swapping in each new
95 // render as it completes.
5623b11Recorder simulator: MATLAB jet-drive model and interactive web appJeremy Magland 96 useEffect(() => {
97 if (!looping || !result) return;
98 const ctx = getAudioCtx(result.sampleRate);
99 const src = ctx.createBufferSource();
100 src.buffer = makeBuffer(ctx, result);
101 src.loop = true;
102 src.connect(ctx.destination);
103 src.start();
104 loopSrcRef.current = src;
105 return () => {
106 loopSrcRef.current = null;
107 try {
108 src.stop();
109 } catch {
110 // already stopped
111 }
112 src.disconnect();
113 };
114 }, [looping, result, getAudioCtx, makeBuffer]);
116 const download = useCallback(() => {
117 if (!result) return;
118 const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
119 const url = URL.createObjectURL(blob);
120 const a = document.createElement('a');
121 a.href = url;
122 a.download = result.quality === 'full' ? 'recorder.wav' : 'recorder_preview.wav';
123 a.click();
124 setTimeout(() => URL.revokeObjectURL(url), 5000);
125 }, [result, params.bit_depth]);
127 return (
128 <div className="app">
129 <div className="topbar">
130 <h1>Recorder Simulator</h1>
132 className="primary"
133 onClick={renderPreview}
134 disabled={busy}
135 title="No oversampling, everything else unchanged — the fast pass"
136 >
137 Render preview
138 </button>
139 <button
140 className="primary"
141 onClick={renderFull}
142 disabled={busy}
143 title="Full quality at the oversampling the parameters ask for"
144 >
145 Render full
5623b11Recorder simulator: MATLAB jet-drive model and interactive web appJeremy Magland 146 </button>
148 <button onClick={cancel} title="Stop the render">
149 ✕ Cancel
150 </button>
151 )}
153 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
154 onClick={play}
155 disabled={!result}
156 title={
157 result?.quality === 'preview'
3a84d42Explicit preview and full renders, as in the cymbal2 appJeremy Magland 158 ? 'Playing the preview render; press Render full for full quality'
5623b11Recorder simulator: MATLAB jet-drive model and interactive web appJeremy Magland 159 : 'Play the rendered note or figure'
160 }
161 >
162 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
163 </button>
164 <button
165 className={`loop ${looping ? 'active' : ''}`}
166 onClick={() => setLooping((v) => !v)}
3a84d42Explicit preview and full renders, as in the cymbal2 appJeremy Magland 167 disabled={!result}
168 title="Repeat the last render continuously"
170 ⟳ Loop{looping ? ' on' : ''}
171 </button>
172 <button onClick={reseed} title="Draw new breath turbulence from a new random seed">
173 ⚄ New seed
174 </button>
175 <button onClick={download} disabled={!result}>
176 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
177 </button>
178 <button onClick={reset}>Reset to defaults</button>
179 <button
180 className={codeOpen ? 'code active' : 'code'}
181 onClick={() => setCodeOpen((v) => !v)}
182 title="View and edit the MATLAB source that numbl executes"
183 >
184 {'{ }'} Code{codeEdited ? ' •' : ''}
185 </button>
186 <div className="status">
187 {error && <span className="badge err">code error</span>}
3a84d42Explicit preview and full renders, as in the cymbal2 appJeremy Magland 188 {result && !busy && (
5623b11Recorder simulator: MATLAB jet-drive model and interactive web appJeremy Magland 189 <span className={`badge ${result.quality}`}>
3a84d42Explicit preview and full renders, as in the cymbal2 appJeremy Magland 190 {result.quality === 'full' ? 'full quality' : 'preview quality'}
192 )}
193 {busy ? (
194 <>
196 {stage}… {elapsedS.toFixed(0)} s
197 </span>
5623b11Recorder simulator: MATLAB jet-drive model and interactive web appJeremy Magland 198 <div className="progressbar">
199 <div style={{ width: `${Math.round(progress * 100)}%` }} />
200 </div>
201 </>
202 ) : (
203 <span>ready</span>
204 )}
205 </div>
206 </div>
207 <div className="main">
208 <div className="sidebar">
209 <ParamPanel params={params} onChange={onChange} />
210 </div>
211 <div className="content">
212 {error && (
213 <div className="card wide error-card">
214 <h2>MATLAB error</h2>
215 <div>
216 <strong>
217 {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
218 </strong>{' '}
219 {error.message}
220 </div>
221 <div className="code-hint">
3a84d42Explicit preview and full renders, as in the cymbal2 appJeremy Magland 222 Nothing is playable until a render succeeds. Open Code to fix the source, or
223 revert it.
225 </div>
226 )}
227 <div className="card wide">
228 <h2>Geometry — bore, tone holes, microphone</h2>
229 <Scene3D params={params} />
230 </div>
231 {result && (
232 <>
233 <div className="card">
234 <h2>Diagnostics</h2>
235 <DiagnosticsPanel result={result} />
236 </div>
237 <div className="card">
238 <h2>Jet flow</h2>
239 <BreathPlot
240 qPlot={result.qPlot}
241 qPlotRate={result.qPlotRate}
242 noteOn={result.noteOn}
243 fPlay={result.fPlay}
244 durationS={result.diag.durationS}
245 />
246 </div>
247 <div className="card">
248 <h2>Waveform</h2>
249 <WaveformPlot y={result.y} sampleRate={result.sampleRate} />
250 </div>
251 <div className="card">
252 <h2>Spectrum</h2>
253 <SpectrumPlot
254 y={result.y}
255 sampleRate={result.sampleRate}
256 modeF={result.modeF}
257 fPlay={result.fPlay}
258 />
259 </div>
260 <div className="card">
261 <h2>Spectrogram</h2>
262 <SpectrogramPlot y={result.y} sampleRate={result.sampleRate} />
263 </div>
264 <div className="card">
265 <h2>Bore admittance</h2>
266 <AdmittancePlot
267 gridF={result.gridF}
268 gridY={result.gridY}
269 modeF={result.modeF}
270 fPlay={result.fPlay.length > 0 ? result.fPlay[0] : 0}
271 />
272 </div>
273 <div className="card wide">
274 <h2>Standing waves</h2>
275 <BoreProfile
276 profX={result.profX}
277 profP={result.profP}
278 modeF={result.modeF}
279 />
280 </div>
281 </>
282 )}
3a84d42Explicit preview and full renders, as in the cymbal2 appJeremy Magland 283 {!result && !busy && !error && (
284 <div className="card wide">
285 <h2>No render yet</h2>
286 <div className="code-hint">
287 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.
288 </div>
289 </div>
290 )}
292 </div>
293 {codeOpen && (
294 <CodePanel
295 mfiles={mfiles}
296 onChangeFile={onChangeFile}
297 onResetFile={onResetFile}
298 onResetAll={onResetAllFiles}
299 onClose={() => setCodeOpen(false)}
300 onRenderFull={renderFull}
301 error={error}
302 busy={busy}
303 />
304 )}
305 </div>
306 );
307}