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. Between strikes every mode is a damped exponential, so th
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. Removing the bending correction sign flip in `triangle_fem.m`, for instance, decouples torsion from out-of-plane bending and audibly thins the cluster of partials.
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. Removing the bending correction sign flip in `triangle_fem.m`, for instance, decouples torsion from out-of-plane bending and audibly thins the cluster of partials.
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: rod geometry, material, damping, and discretization parameters invalidate the mode basis (the eigensolve); the strike and roll parameters, the beater, and the listener only re-run the strike process 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 eigensolve does 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: rod geometry, material, damping, and discretization parameters invalidate the mode basis (the eigensolve); the strike and roll parameters, the beater, and the listener only re-run the strike process 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 eigensolve does not depend on the synthesis rate, previews reuse the full-quality mode basis.
3131
32-The New seed button redraws the roll timing and velocities without touching any other parameter (a single strike 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/triangle_defaults.m` mirrors).
32+The New seed button redraws the roll timing and velocities without touching any other parameter (a single strike 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/triangle_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]);
@@ -61,6 +62,12 @@ export default function App() {
6162 const [looping, setLooping] = useState(false);
6263 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
6364
65+ // A failed render clears the result, so a loop left running has nothing to
66+ // play; drop out of loop mode rather than leaving the button lit.
67+ useEffect(() => {
68+ if (!result) setLooping(false);
69+ }, [result]);
70+
6471 const getAudioCtx = useCallback((sampleRate: number) => {
6572 let ctx = audioCtxRef.current;
6673 if (!ctx || ctx.state === 'closed') {
@@ -86,8 +93,8 @@ export default function App() {
8693 src.start();
8794 }, [result, getAudioCtx, makeBuffer]);
8895
89- // Loop mode: repeat the strike or roll continuously, swapping in each new
90- // render as parameters change so edits are audible without pressing Play.
96+ // Loop mode: repeat the last render continuously, swapping in each new
97+ // render as it completes.
9198 useEffect(() => {
9299 if (!looping || !result) return;
93100 const ctx = getAudioCtx(result.sampleRate);
@@ -123,16 +130,34 @@ export default function App() {
123130 <div className="app">
124131 <div className="topbar">
125132 <h1>Triangle Simulator</h1>
126- <button className="primary" onClick={renderFull} disabled={busy}>
127- Render
133+ <button
134+ className="primary"
135+ onClick={renderPreview}
136+ disabled={busy}
137+ title="No oversampling, everything else unchanged — the fast pass"
138+ >
139+ Render preview
140+ </button>
141+ <button
142+ className="primary"
143+ onClick={renderFull}
144+ disabled={busy}
145+ title="Full quality at the oversampling the parameters ask for"
146+ >
147+ Render full
128148 </button>
149+ {busy && (
150+ <button onClick={cancel} title="Stop the render">
151+ ✕ Cancel
152+ </button>
153+ )}
129154 <button
130155 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
131156 onClick={play}
132157 disabled={!result}
133158 title={
134159 result?.quality === 'preview'
135- ? 'Playing the fast preview render; press Render for full quality'
160+ ? 'Playing the preview render; press Render full for full quality'
136161 : 'Play the rendered strike or roll'
137162 }
138163 >
@@ -141,8 +166,8 @@ export default function App() {
141166 <button
142167 className={`loop ${looping ? 'active' : ''}`}
143168 onClick={() => setLooping((v) => !v)}
144- disabled={!result && !looping}
145- title="Repeat the render continuously so parameter changes are audible immediately"
169+ disabled={!result}
170+ title="Repeat the last render continuously"
146171 >
147172 ⟳ Loop{looping ? ' on' : ''}
148173 </button>
@@ -166,14 +191,16 @@ export default function App() {
166191 </button>
167192 <div className="status">
168193 {error && <span className="badge err">code error</span>}
169- {result && (
194+ {result && !busy && (
170195 <span className={`badge ${result.quality}`}>
171- {result.quality === 'full' ? 'full quality' : 'preview quality — press Render'}
196+ {result.quality === 'full' ? 'full quality' : 'preview quality'}
172197 </span>
173198 )}
174199 {busy ? (
175200 <>
176- <span>{stage}…</span>
201+ <span>
202+ {stage}… {elapsedS.toFixed(0)} s
203+ </span>
177204 <div className="progressbar">
178205 <div style={{ width: `${Math.round(progress * 100)}%` }} />
179206 </div>
@@ -198,7 +225,8 @@ export default function App() {
198225 {error.message}
199226 </div>
200227 <div className="code-hint">
201- Showing the last successful render. Open Code to fix the source, or revert it.
228+ Nothing is playable until a render succeeds. Open Code to fix the source, or
229+ revert it.
202230 </div>
203231 </div>
204232 )}
@@ -261,6 +289,14 @@ export default function App() {
261289 </div>
262290 </>
263291 )}
292+ {!result && !busy && !error && (
293+ <div className="card wide">
294+ <h2>No render yet</h2>
295+ <div className="code-hint">
296+ 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.
297+ </div>
298+ </div>
299+ )}
264300 </div>
265301 </div>
266302 {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 { TriangleParams } 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: TriangleParams;
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: TriangleParams, 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: TriangleParams, 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 }