Explicit preview and full renders, as in the cymbal2 app
The debounced auto-preview is gone: nothing renders until Render preview
or Render full is pressed, so moving a slider or editing a .m file no
longer starts work on its own. A render in flight can be cancelled, which
terminates the worker and builds a fresh one, and the status carries the
stage, a progress bar and the elapsed time. A failed render now clears the
result rather than leaving the previous one playable, so Play and Download
can never hand back audio that does not match what is on screen.
This makes the seven instrument apps behave the same way.
4 changed files+118−74
webapp/README.mdmodified+4−4View file
@@ -17,15 +17,15 @@ The self-test runs the closed-form and reproducibility checks, the emergent regi
1717
1818 ## Why rendering is tiered the way it is
1919
20-Unlike the sibling instruments, nothing here is closed-form between events: a recorder is a self-oscillator, so the jet loop is integrated sample by sample and a two-second note costs a few seconds. The worker therefore runs each stage as a separate numbl execution and keys its caches by tier: bore, hole, and window parameters invalidate the per-note basis (the transmission-line solve and modal fit); the breath, the jet, and the gesture re-run only the jet integration; the listener position and the direct gain re-run only the radiation, which is a matrix multiply; output conditioning is always re-run. Intermediate tensors stay inside the worker as numbl RuntimeValues. Changing any parameter triggers a debounced fast preview (oversample 1); the Render button runs the full-quality pass, and while the current result is a preview the Play and Download buttons and the status badge say so.
20+Unlike the sibling instruments, nothing here is closed-form between events: a recorder is a self-oscillator, so the jet loop is integrated sample by sample and a two-second note costs a few seconds. The worker therefore runs each stage as a separate numbl execution and keys its caches by tier: bore, hole, and window parameters invalidate the per-note basis (the transmission-line solve and modal fit); the breath, the jet, and the gesture re-run only the jet integration; the listener position and the direct gain re-run only the radiation, which is a matrix multiply; output conditioning is always re-run. Intermediate tensors stay inside the worker as numbl RuntimeValues. Nothing renders on its own: the Render preview button runs a fast pass (oversample 1), Render full runs the full-quality pass, and either can be cancelled while it runs. The status shows the stage, a progress bar and the elapsed time. While the current result is a preview, the Play and Download buttons and the status badge say so.
2121
22-The New seed button redraws the breath turbulence without touching any other parameter. The Loop button repeats the render continuously, swapping in each new render as parameters change. Parameters persist in localStorage; "Reset to defaults" restores the values in `src/params.ts` (which `../matlab/recorder_defaults.m` mirrors).
22+The New seed button redraws the breath turbulence without touching any other parameter. The Loop button repeats the last render continuously, swapping in each new render as it completes. Parameters persist in localStorage; "Reset to defaults" restores the values in `src/params.ts` (which `../matlab/recorder_defaults.m` mirrors).
2323
2424 ## Reading and editing the code in the app
2525
26-The Code button opens the MATLAB source in a CodeMirror editor with MATLAB syntax highlighting. Every `.m` file in `../matlab` is listed, tagged by whether the app executes it or it is a CLI entry point. The sources are editable: an edit re-renders after the usual debounce, so changing the physics in the editor is heard and plotted the same way moving a slider is. Doubling `jet_conv`'s effect by halving the transit delay in `recorder_jet.m`, for instance, audibly sharpens every note and then flips the register.
26+The Code button opens the MATLAB source in a CodeMirror editor with MATLAB syntax highlighting. Every `.m` file in `../matlab` is listed, tagged by whether the app executes it or it is a CLI entry point. The sources are editable: an edit takes effect on the next render, so changing the physics in the editor is heard and plotted the same way moving a slider is. Doubling `jet_conv`'s effect by halving the transit delay in `recorder_jet.m`, for instance, audibly sharpens every note and then flips the register.
2727
28-Edits live in memory only. They are deliberately not persisted, so a reload restores the code the app shipped with; "Revert file" and "Revert all" undo them sooner, and a dot marks the files that differ from the shipped source. A fault in edited code is reported with its file and line (numbl skips a file it cannot parse and then fails at the call site, so the worker captures the parse warning and reports that instead), the last successful render stays on screen, and reverting recovers.
28+Edits live in memory only. They are deliberately not persisted, so a reload restores the code the app shipped with; "Revert file" and "Revert all" undo them sooner, and a dot marks the files that differ from the shipped source. A fault in edited code is reported with its file and line (numbl skips a file it cannot parse and then fails at the call site, so the worker captures the parse warning and reports that instead), the failed render leaves nothing playable, so Play and Download go dead until a render succeeds, and reverting recovers.
2929
3030 ## Layout
3131
webapp/src/App.tsxmodified+48−12View file
@@ -20,7 +20,8 @@ export default function App() {
2020 // a reload restores the code the app shipped with.
2121 const [mfiles, setMfiles] = useState<MFile[]>(() => DEFAULT_MFILES.map((f) => ({ ...f })));
2222 const [codeOpen, setCodeOpen] = useState(false);
23- const { result, error, busy, stage, progress, renderFull } = useRenderer(params, mfiles);
23+ const { result, error, busy, stage, progress, elapsedS, renderPreview, renderFull, cancel } =
24+ useRenderer(params, mfiles);
2425 const audioCtxRef = useRef<AudioContext | null>(null);
2526
2627 useEffect(() => saveParams(params), [params]);
@@ -59,6 +60,12 @@ export default function App() {
5960 const [looping, setLooping] = useState(false);
6061 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
6162
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]);
68+
6269 const getAudioCtx = useCallback((sampleRate: number) => {
6370 let ctx = audioCtxRef.current;
6471 if (!ctx || ctx.state === 'closed') {
@@ -84,8 +91,8 @@ export default function App() {
8491 src.start();
8592 }, [result, getAudioCtx, makeBuffer]);
8693
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.
94+ // Loop mode: repeat the last render continuously, swapping in each new
95+ // render as it completes.
8996 useEffect(() => {
9097 if (!looping || !result) return;
9198 const ctx = getAudioCtx(result.sampleRate);
@@ -121,16 +128,34 @@ export default function App() {
121128 <div className="app">
122129 <div className="topbar">
123130 <h1>Recorder Simulator</h1>
124- <button className="primary" onClick={renderFull} disabled={busy}>
125- Render
131+ <button
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
126146 </button>
147+ {busy && (
148+ <button onClick={cancel} title="Stop the render">
149+ ✕ Cancel
150+ </button>
151+ )}
127152 <button
128153 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
129154 onClick={play}
130155 disabled={!result}
131156 title={
132157 result?.quality === 'preview'
133- ? 'Playing the fast preview render; press Render for full quality'
158+ ? 'Playing the preview render; press Render full for full quality'
134159 : 'Play the rendered note or figure'
135160 }
136161 >
@@ -139,8 +164,8 @@ export default function App() {
139164 <button
140165 className={`loop ${looping ? 'active' : ''}`}
141166 onClick={() => setLooping((v) => !v)}
142- disabled={!result && !looping}
143- title="Repeat the render continuously so parameter changes are audible immediately"
167+ disabled={!result}
168+ title="Repeat the last render continuously"
144169 >
145170 ⟳ Loop{looping ? ' on' : ''}
146171 </button>
@@ -160,14 +185,16 @@ export default function App() {
160185 </button>
161186 <div className="status">
162187 {error && <span className="badge err">code error</span>}
163- {result && (
188+ {result && !busy && (
164189 <span className={`badge ${result.quality}`}>
165- {result.quality === 'full' ? 'full quality' : 'preview quality — press Render'}
190+ {result.quality === 'full' ? 'full quality' : 'preview quality'}
166191 </span>
167192 )}
168193 {busy ? (
169194 <>
170- <span>{stage}…</span>
195+ <span>
196+ {stage}… {elapsedS.toFixed(0)} s
197+ </span>
171198 <div className="progressbar">
172199 <div style={{ width: `${Math.round(progress * 100)}%` }} />
173200 </div>
@@ -192,7 +219,8 @@ export default function App() {
192219 {error.message}
193220 </div>
194221 <div className="code-hint">
195- Showing the last successful render. Open Code to fix the source, or revert it.
222+ Nothing is playable until a render succeeds. Open Code to fix the source, or
223+ revert it.
196224 </div>
197225 </div>
198226 )}
@@ -252,6 +280,14 @@ export default function App() {
252280 </div>
253281 </>
254282 )}
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+ )}
255291 </div>
256292 </div>
257293 {codeOpen && (
webapp/src/components/CodePanel.tsxmodified+1−1View file
@@ -1,5 +1,5 @@
11 // Overlay for reading and editing the MATLAB implementation. Edits take
2-// effect on the next debounced render and live only as long as the page.
2+// effect on the next render and live only as long as the page.
33
44 import { useState } from 'react';
55 import { DEFAULT_MFILES, EXECUTED_BY_APP, type MFile } from '../mfiles';
webapp/src/hooks/useRenderer.tsmodified+65−57View file
@@ -1,6 +1,7 @@
1-// Hook owning the render worker: debounced auto-preview whenever the
2-// parameters or the .m sources change, explicit full-quality render, and a
3-// latest-wins request queue.
1+// Hook owning the render worker. Renders are explicit: nothing starts until
2+// Render preview or Render full is pressed, so changing a parameter or a .m
3+// source never kicks off work on its own. A request in flight can be
4+// cancelled, which tears the worker down and builds a fresh one.
45
56 import { useCallback, useEffect, useRef, useState } from 'react';
67 import type { RecorderParams } from '../params';
@@ -18,70 +19,39 @@ export interface RenderState {
1819 error: RenderError | null;
1920 busy: boolean;
2021 stage: string;
21- progress: number; // 0..1 of the current stage
22-}
23-
24-interface Request {
25- params: RecorderParams;
26- mfiles: MFile[];
27- quality: 'preview' | 'full';
22+ progress: number; // 0..1 through the render
23+ elapsedS: number;
2824 }
2925
3026 export function useRenderer(params: RecorderParams, mfiles: MFile[]) {
3127 const workerRef = useRef<Worker | null>(null);
3228 const nextId = useRef(1);
3329 const inFlight = useRef<number | null>(null);
34- const queued = useRef<Request | null>(null);
35- const sendRef = useRef<(r: Request) => void>(() => {});
30+ const startedAt = useRef(0);
3631 const [state, setState] = useState<RenderState>({
3732 result: null,
3833 error: null,
3934 busy: false,
4035 stage: '',
4136 progress: 0,
37+ elapsedS: 0,
4238 });
4339
44- const send = useCallback((req: Request) => {
45- const worker = workerRef.current;
46- if (!worker) return;
47- if (inFlight.current !== null) {
48- queued.current = req; // latest wins
49- return;
50- }
51- const id = nextId.current++;
52- inFlight.current = id;
53- setState((s) => ({ ...s, busy: true, stage: 'starting', progress: 0 }));
54- worker.postMessage({ type: 'render', id, ...req });
55- }, []);
56- sendRef.current = send;
57-
58- useEffect(() => {
59- const worker = new Worker(new URL('../worker/renderWorker.ts', import.meta.url), {
60- type: 'module',
61- });
62- workerRef.current = worker;
63-
64- const finish = (update: (s: RenderState) => RenderState) => {
65- inFlight.current = null;
66- const next = queued.current;
67- queued.current = null;
68- setState((s) => {
69- const done = update(s);
70- return next ? { ...done, busy: true, stage: 'queued', progress: 0 } : done;
71- });
72- if (next) sendRef.current(next);
73- };
74-
40+ const attachHandlers = useCallback((worker: Worker) => {
7541 worker.onmessage = (ev: MessageEvent<RenderResponse | ProgressMessage | ErrorMessage>) => {
7642 const msg = ev.data;
7743 if (msg.type === 'progress') {
7844 setState((s) => ({ ...s, stage: msg.stage, progress: msg.frac }));
7945 return;
8046 }
47+ inFlight.current = null;
8148 if (msg.type === 'error') {
82- // Keep the last good result on screen; the code panel shows the fault.
83- finish((s) => ({
49+ // A failed render leaves nothing playable: the previous result is
50+ // dropped, so Play and Download cannot hand back audio that no
51+ // longer corresponds to the code or parameters on screen.
52+ setState((s) => ({
8453 ...s,
54+ result: null,
8555 error: { message: msg.message, file: msg.file, line: msg.line },
8656 busy: false,
8757 stage: '',
@@ -89,26 +59,64 @@ export function useRenderer(params: RecorderParams, mfiles: MFile[]) {
8959 }));
9060 return;
9161 }
92- finish(() => ({ result: msg, error: null, busy: false, stage: '', progress: 0 }));
62+ setState((s) => ({ ...s, result: msg, error: null, busy: false, stage: '', progress: 0 }));
9363 };
64+ }, []);
65+
66+ const makeWorker = useCallback(() => {
67+ const worker = new Worker(new URL('../worker/renderWorker.ts', import.meta.url), {
68+ type: 'module',
69+ });
70+ attachHandlers(worker);
71+ workerRef.current = worker;
72+ return worker;
73+ }, [attachHandlers]);
74+
75+ useEffect(() => {
76+ const worker = makeWorker();
9477 return () => {
9578 worker.terminate();
9679 workerRef.current = null;
9780 inFlight.current = null;
98- queued.current = null;
9981 };
100- }, []);
82+ }, [makeWorker]);
83+
84+ // elapsed-time ticker while a render is in flight
85+ useEffect(() => {
86+ if (!state.busy) return;
87+ const t = setInterval(
88+ () => setState((s) => ({ ...s, elapsedS: (performance.now() - startedAt.current) / 1000 })),
89+ 250,
90+ );
91+ return () => clearInterval(t);
92+ }, [state.busy]);
10193
102- const renderFull = useCallback(
103- () => send({ params, mfiles, quality: 'full' }),
104- [params, mfiles, send],
94+ const render = useCallback(
95+ (quality: 'preview' | 'full') => {
96+ const worker = workerRef.current;
97+ if (!worker || inFlight.current !== null) return;
98+ const id = nextId.current++;
99+ inFlight.current = id;
100+ startedAt.current = performance.now();
101+ setState((s) => ({ ...s, busy: true, stage: 'starting', progress: 0, elapsedS: 0 }));
102+ worker.postMessage({ type: 'render', id, params, mfiles, quality });
103+ },
104+ [params, mfiles],
105105 );
106106
107- // Debounced auto-preview on any change to the parameters or the code.
108- useEffect(() => {
109- const t = setTimeout(() => send({ params, mfiles, quality: 'preview' }), 250);
110- return () => clearTimeout(t);
111- }, [params, mfiles, send]);
107+ // Cancelling terminates the worker outright: the .m interpreter runs a tight
108+ // loop that never yields, so there is nothing to poll a flag from. The
109+ // replacement worker starts with empty stage caches.
110+ const cancel = useCallback(() => {
111+ const worker = workerRef.current;
112+ if (worker) worker.terminate();
113+ inFlight.current = null;
114+ makeWorker();
115+ setState((s) => ({ ...s, busy: false, stage: '', progress: 0, elapsedS: 0 }));
116+ }, [makeWorker]);
117+
118+ const renderPreview = useCallback(() => render('preview'), [render]);
119+ const renderFull = useCallback(() => render('full'), [render]);
112120
113- return { ...state, renderFull };
121+ return { ...state, renderPreview, renderFull, cancel };
114122 }