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
@@ -21,15 +21,15 @@ Nothing is integrated. A pluck is an initial condition on the coupled modes, so
2121
2222 ## Reading and editing the code in the app
2323
24-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. Zeroing the mass-coupling term `gk` in `bass_assemble.m`, for instance, decouples the string from the body and leaves the note quiet, harmonic, and endless.
24+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. Zeroing the mass-coupling term `gk` in `bass_assemble.m`, for instance, decouples the string from the body and leaves the note quiet, harmonic, and endless.
2525
26-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.
26+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.
2727
2828 ## How rendering is tiered
2929
30-Changing any parameter triggers a debounced fast preview (oversample 1); the Render button runs the full-quality pass. While the current result is a preview, the Play and Download buttons and the status badge say so. The worker runs each stage as a separate numbl execution and keys its caches by tier: the strings, the body, the damping, and the gesture's pitches invalidate the coupled eigensolves; the pluck, the tempo, and the listener only re-run the projection, the synthesis, and the radiation; pre-delay, normalization, and bit depth only re-run the output stage. Intermediate tensors stay inside the worker as numbl RuntimeValues. Because the eigensolves do not depend on the synthesis rate, previews reuse the full-quality mode basis.
30+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. The worker runs each stage as a separate numbl execution and keys its caches by tier: the strings, the body, the damping, and the gesture's pitches invalidate the coupled eigensolves; the pluck, the tempo, and the listener only re-run the projection, the synthesis, and the radiation; pre-delay, normalization, and bit depth only re-run the output stage. Intermediate tensors stay inside the worker as numbl RuntimeValues. Because the eigensolves do not depend on the synthesis rate, previews reuse the full-quality mode basis.
3131
32-The New seed button redraws the walking line without touching any other parameter (a single note is deterministic, so the button is disabled there). 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/bass_defaults.m` mirrors).
32+The New seed button redraws the walking line without touching any other parameter (a single note is deterministic, so the button is disabled there). 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/bass_defaults.m` mirrors).
3333
3434 ## Layout
3535
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]);
@@ -60,6 +61,12 @@ export default function App() {
6061 const [looping, setLooping] = useState(false);
6162 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
6263
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]);
69+
6370 const getAudioCtx = useCallback((sampleRate: number) => {
6471 let ctx = audioCtxRef.current;
6572 if (!ctx || ctx.state === 'closed') {
@@ -85,8 +92,8 @@ export default function App() {
8592 src.start();
8693 }, [result, getAudioCtx, makeBuffer]);
8794
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.
95+ // Loop mode: repeat the last render continuously, swapping in each new
96+ // render as it completes.
9097 useEffect(() => {
9198 if (!looping || !result) return;
9299 const ctx = getAudioCtx(result.sampleRate);
@@ -122,16 +129,34 @@ export default function App() {
122129 <div className="app">
123130 <div className="topbar">
124131 <h1>Standup Bass Simulator</h1>
125- <button className="primary" onClick={renderFull} disabled={busy}>
126- Render
132+ <button
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
127147 </button>
148+ {busy && (
149+ <button onClick={cancel} title="Stop the render">
150+ ✕ Cancel
151+ </button>
152+ )}
128153 <button
129154 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
130155 onClick={play}
131156 disabled={!result}
132157 title={
133158 result?.quality === 'preview'
134- ? 'Playing the fast preview render; press Render for full quality'
159+ ? 'Playing the preview render; press Render full for full quality'
135160 : 'Play the rendered note or walking line'
136161 }
137162 >
@@ -140,8 +165,8 @@ export default function App() {
140165 <button
141166 className={`loop ${looping ? 'active' : ''}`}
142167 onClick={() => setLooping((v) => !v)}
143- disabled={!result && !looping}
144- title="Repeat the render continuously so parameter changes are audible immediately"
168+ disabled={!result}
169+ title="Repeat the last render continuously"
145170 >
146171 ⟳ Loop{looping ? ' on' : ''}
147172 </button>
@@ -165,14 +190,16 @@ export default function App() {
165190 </button>
166191 <div className="status">
167192 {error && <span className="badge err">code error</span>}
168- {result && (
193+ {result && !busy && (
169194 <span className={`badge ${result.quality}`}>
170- {result.quality === 'full' ? 'full quality' : 'preview quality — press Render'}
195+ {result.quality === 'full' ? 'full quality' : 'preview quality'}
171196 </span>
172197 )}
173198 {busy ? (
174199 <>
175- <span>{stage}…</span>
200+ <span>
201+ {stage}… {elapsedS.toFixed(0)} s
202+ </span>
176203 <div className="progressbar">
177204 <div style={{ width: `${Math.round(progress * 100)}%` }} />
178205 </div>
@@ -197,7 +224,8 @@ export default function App() {
197224 {error.message}
198225 </div>
199226 <div className="code-hint">
200- Showing the last successful render. Open Code to fix the source, or revert it.
227+ Nothing is playable until a render succeeds. Open Code to fix the source, or
228+ revert it.
201229 </div>
202230 </div>
203231 )}
@@ -260,6 +288,14 @@ export default function App() {
260288 </div>
261289 </>
262290 )}
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+ )}
263299 </div>
264300 </div>
265301 {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 { BassParams } 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: BassParams;
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: BassParams, 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: BassParams, 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 }