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);
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 RecorderParams, value: number | boolean) => {
29 setParams((p) => ({ ...p, [key]: value }));
30 }, []);
32 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
34 // A new seed redraws the breath turbulence without touching anything else.
35 const reseed = useCallback(
36 () => setParams((p) => ({ ...p, seed: 1 + Math.floor(Math.random() * 9999) })),
37 [],
38 );
40 const onChangeFile = useCallback((name: string, source: string) => {
41 setMfiles((fs) => fs.map((f) => (f.name === name ? { ...f, source } : f)));
42 }, []);
44 const onResetFile = useCallback((name: string) => {
45 setMfiles((fs) =>
46 fs.map((f) =>
47 f.name === name
48 ? { ...f, source: DEFAULT_MFILES.find((d) => d.name === name)?.source ?? f.source }
49 : f,
50 ),
51 );
52 }, []);
54 const onResetAllFiles = useCallback(() => setMfiles(DEFAULT_MFILES.map((f) => ({ ...f }))), []);
55 const codeEdited = mfiles.some(
56 (f) => f.source !== DEFAULT_MFILES.find((d) => d.name === f.name)?.source,
57 );
59 const [looping, setLooping] = useState(false);
60 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
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 note or figure continuously, swapping in each new
88 // render as parameters change so edits are audible without pressing Play.
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' ? 'recorder.wav' : 'recorder_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>Recorder Simulator</h1>
124 <button className="primary" onClick={renderFull} disabled={busy}>
125 Render
126 </button>
127 <button
128 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
129 onClick={play}
130 disabled={!result}
131 title={
132 result?.quality === 'preview'
133 ? 'Playing the fast preview render; press Render for full quality'
134 : 'Play the rendered note or figure'
135 }
136 >
137 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
138 </button>
139 <button
140 className={`loop ${looping ? 'active' : ''}`}
141 onClick={() => setLooping((v) => !v)}
142 disabled={!result && !looping}
143 title="Repeat the render continuously so parameter changes are audible immediately"
144 >
145 ⟳ Loop{looping ? ' on' : ''}
146 </button>
147 <button onClick={reseed} title="Draw new breath turbulence from a new random seed">
148 ⚄ New seed
149 </button>
150 <button onClick={download} disabled={!result}>
151 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
152 </button>
153 <button onClick={reset}>Reset to defaults</button>
154 <button
155 className={codeOpen ? 'code active' : 'code'}
156 onClick={() => setCodeOpen((v) => !v)}
157 title="View and edit the MATLAB source that numbl executes"
158 >
159 {'{ }'} Code{codeEdited ? ' •' : ''}
160 </button>
161 <div className="status">
162 {error && <span className="badge err">code error</span>}
163 {result && (
164 <span className={`badge ${result.quality}`}>
165 {result.quality === 'full' ? 'full quality' : 'preview quality — press Render'}
166 </span>
167 )}
168 {busy ? (
169 <>
170 <span>{stage}…</span>
171 <div className="progressbar">
172 <div style={{ width: `${Math.round(progress * 100)}%` }} />
173 </div>
174 </>
175 ) : (
176 <span>ready</span>
177 )}
178 </div>
179 </div>
180 <div className="main">
181 <div className="sidebar">
182 <ParamPanel params={params} onChange={onChange} />
183 </div>
184 <div className="content">
185 {error && (
186 <div className="card wide error-card">
187 <h2>MATLAB error</h2>
188 <div>
189 <strong>
190 {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
191 </strong>{' '}
192 {error.message}
193 </div>
194 <div className="code-hint">
195 Showing the last successful render. Open Code to fix the source, or revert it.
196 </div>
197 </div>
198 )}
199 <div className="card wide">
200 <h2>Geometry — bore, tone holes, microphone</h2>
201 <Scene3D params={params} />
202 </div>
203 {result && (
204 <>
205 <div className="card">
206 <h2>Diagnostics</h2>
207 <DiagnosticsPanel result={result} />
208 </div>
209 <div className="card">
210 <h2>Jet flow</h2>
211 <BreathPlot
212 qPlot={result.qPlot}
213 qPlotRate={result.qPlotRate}
214 noteOn={result.noteOn}
215 fPlay={result.fPlay}
216 durationS={result.diag.durationS}
217 />
218 </div>
219 <div className="card">
220 <h2>Waveform</h2>
221 <WaveformPlot y={result.y} sampleRate={result.sampleRate} />
222 </div>
223 <div className="card">
224 <h2>Spectrum</h2>
225 <SpectrumPlot
226 y={result.y}
227 sampleRate={result.sampleRate}
228 modeF={result.modeF}
229 fPlay={result.fPlay}
230 />
231 </div>
232 <div className="card">
233 <h2>Spectrogram</h2>
234 <SpectrogramPlot y={result.y} sampleRate={result.sampleRate} />
235 </div>
236 <div className="card">
237 <h2>Bore admittance</h2>
238 <AdmittancePlot
239 gridF={result.gridF}
240 gridY={result.gridY}
241 modeF={result.modeF}
242 fPlay={result.fPlay.length > 0 ? result.fPlay[0] : 0}
243 />
244 </div>
245 <div className="card wide">
246 <h2>Standing waves</h2>
247 <BoreProfile
248 profX={result.profX}
249 profP={result.profP}
250 modeF={result.modeF}
251 />
252 </div>
253 </>
254 )}
255 </div>
256 </div>
257 {codeOpen && (
258 <CodePanel
259 mfiles={mfiles}
260 onChangeFile={onChangeFile}
261 onResetFile={onResetFile}
262 onResetAll={onResetAllFiles}
263 onClose={() => setCodeOpen(false)}
264 onRenderFull={renderFull}
265 error={error}
266 busy={busy}
267 />
268 )}
269 </div>
270 );
271}