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 @@ The reference implementation integrates the whole strike with RK4. The MATLAB im
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. Editing `sqrt(12)` to `sqrt(48)` in the pitch prefactor, for instance, halves every partial.
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. Editing `sqrt(12)` to `sqrt(48)` in the pitch prefactor, for instance, halves every partial.
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 8, 24 radiating elements); the Render button runs the full-quality pass (oversample 16, 48 elements). 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: geometry, material, damping, mallet, and cavity parameters invalidate the solve; listener and slit positions and block height only re-run radiation; pre-delay, normalization, and bit depth only re-run the output stage. Intermediate tensors stay inside the worker as numbl RuntimeValues. Moving the microphone after a full render reuses the cached solve and returns a full-quality result.
30+Nothing renders on its own: the Render preview button runs a fast pass (oversample 8, 24 radiating elements), Render full runs the full-quality pass (oversample 16, 48 elements), 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: geometry, material, damping, mallet, and cavity parameters invalidate the solve; listener and slit positions and block height only re-run radiation; pre-delay, normalization, and bit depth only re-run the output stage. Intermediate tensors stay inside the worker as numbl RuntimeValues. Moving the microphone after a full render reuses the cached solve and returns a full-quality result.
3131
32-The Loop button repeats the strike continuously, swapping in each new render as parameters change, so edits are audible without pressing Play. Parameters persist in localStorage; "Reset to defaults" restores the values in `src/params.ts` (which `../matlab/woodblock_defaults.m` mirrors).
32+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/woodblock_defaults.m` mirrors).
3333
3434 ## Layout
3535
webapp/src/App.tsxmodified+48−12View file
@@ -19,7 +19,8 @@ export default function App() {
1919 // a reload restores the code the app shipped with.
2020 const [mfiles, setMfiles] = useState<MFile[]>(() => DEFAULT_MFILES.map((f) => ({ ...f })));
2121 const [codeOpen, setCodeOpen] = useState(false);
22- const { result, error, busy, stage, progress, renderFull } = useRenderer(params, mfiles);
22+ const { result, error, busy, stage, progress, elapsedS, renderPreview, renderFull, cancel } =
23+ useRenderer(params, mfiles);
2324 const audioCtxRef = useRef<AudioContext | null>(null);
2425
2526 useEffect(() => saveParams(params), [params]);
@@ -52,6 +53,12 @@ export default function App() {
5253 const [looping, setLooping] = useState(false);
5354 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
5455
56+ // A failed render clears the result, so a loop left running has nothing to
57+ // play; drop out of loop mode rather than leaving the button lit.
58+ useEffect(() => {
59+ if (!result) setLooping(false);
60+ }, [result]);
61+
5562 const getAudioCtx = useCallback((sampleRate: number) => {
5663 let ctx = audioCtxRef.current;
5764 if (!ctx || ctx.state === 'closed') {
@@ -77,8 +84,8 @@ export default function App() {
7784 src.start();
7885 }, [result, getAudioCtx, makeBuffer]);
7986
80- // Loop mode: repeat the strike continuously, swapping in each new render as
81- // parameters change so edits are audible without pressing Play.
87+ // Loop mode: repeat the last render continuously, swapping in each new
88+ // render as it completes.
8289 useEffect(() => {
8390 if (!looping || !result) return;
8491 const ctx = getAudioCtx(result.sampleRate);
@@ -114,16 +121,34 @@ export default function App() {
114121 <div className="app">
115122 <div className="topbar">
116123 <h1>Woodblock Simulator</h1>
117- <button className="primary" onClick={renderFull} disabled={busy}>
118- Render
124+ <button
125+ className="primary"
126+ onClick={renderPreview}
127+ disabled={busy}
128+ title="Oversampling capped at 8× and the radiating panel coarsened — the fast pass"
129+ >
130+ Render preview
131+ </button>
132+ <button
133+ className="primary"
134+ onClick={renderFull}
135+ disabled={busy}
136+ title="Full quality at the oversampling the parameters ask for"
137+ >
138+ Render full
119139 </button>
140+ {busy && (
141+ <button onClick={cancel} title="Stop the render">
142+ ✕ Cancel
143+ </button>
144+ )}
120145 <button
121146 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
122147 onClick={play}
123148 disabled={!result}
124149 title={
125150 result?.quality === 'preview'
126- ? 'Playing the fast preview render; press Render for full quality'
151+ ? 'Playing the preview render; press Render full for full quality'
127152 : 'Play the rendered strike'
128153 }
129154 >
@@ -132,8 +157,8 @@ export default function App() {
132157 <button
133158 className={`loop ${looping ? 'active' : ''}`}
134159 onClick={() => setLooping((v) => !v)}
135- disabled={!result && !looping}
136- title="Repeat the strike continuously so parameter changes are audible immediately"
160+ disabled={!result}
161+ title="Repeat the last render continuously"
137162 >
138163 ⟳ Loop{looping ? ' on' : ''}
139164 </button>
@@ -150,14 +175,16 @@ export default function App() {
150175 </button>
151176 <div className="status">
152177 {error && <span className="badge err">code error</span>}
153- {result && (
178+ {result && !busy && (
154179 <span className={`badge ${result.quality}`}>
155- {result.quality === 'full' ? 'full quality' : 'preview quality — press Render'}
180+ {result.quality === 'full' ? 'full quality' : 'preview quality'}
156181 </span>
157182 )}
158183 {busy ? (
159184 <>
160- <span>{stage}…</span>
185+ <span>
186+ {stage}… {elapsedS.toFixed(0)} s
187+ </span>
161188 <div className="progressbar">
162189 <div style={{ width: `${Math.round(progress * 100)}%` }} />
163190 </div>
@@ -182,7 +209,8 @@ export default function App() {
182209 {error.message}
183210 </div>
184211 <div className="code-hint">
185- Showing the last successful render. Open Code to fix the source, or revert it.
212+ Nothing is playable until a render succeeds. Open Code to fix the source, or
213+ revert it.
186214 </div>
187215 </div>
188216 )}
@@ -233,6 +261,14 @@ export default function App() {
233261 </div>
234262 </>
235263 )}
264+ {!result && !busy && !error && (
265+ <div className="card wide">
266+ <h2>No render yet</h2>
267+ <div className="code-hint">
268+ Press Render preview for a fast pass at reduced oversampling, or Render full for the real thing. Nothing renders on its own: change what you like, then render.
269+ </div>
270+ </div>
271+ )}
236272 </div>
237273 </div>
238274 {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 { WoodblockParams } 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: WoodblockParams;
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: WoodblockParams, 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: WoodblockParams, 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 }