Guide the first visit, and separate recording from playing
Three things were confusing. A visitor had no way to know that the modes
have to be computed before anything makes a sound; the cymbal itself was
specified after the modes that depend on it; and the capture button looked
like the thing that recorded hits, which were in fact being logged all along.
The controls are now numbered in the order they are used, the cymbal first
and the modes second, above a bar that always states the next step and
carries the one button that performs it. The first press computes the modes
and starts the sound together, since the click is also the gesture a browser
needs before it will make any. While the cymbal cannot be played the canvas
says why.
Recording is now explicit and separate from playing, which always sounds. A
take can be played back through the same engine, and the WAV is the take
rendered offline in an OfflineAudioContext rather than a capture of the
speakers, so it is exact, reproducible and rings out in full. To make that
possible a hit now carries the frame its exciter is released at, which one
processor serves for live play, for playback and for the offline render; a
hit whose frame has not arrived costs nothing until the block it falls in.
The live audio capture path is gone.
9 changed files+526−180
README.mdmodified+14−5View file
@@ -10,7 +10,7 @@ The real-time sound is an approximation: the *linear* response of the same shell
1010 npm run dev # development server
1111 npm run build # static site in dist/
1212
13-Press **Compute modes** (cached in the browser for that shape), then **Start audio**, then play.
13+The page walks you through it. The bar at the top always says what the next step is: it computes the cymbal's modes the first time (about twenty seconds at the default mesh, kept in the browser afterwards), starts the sound, and then gets out of the way. The four panels below it follow the same order: design the cymbal, compute its modes, set up the playing, record a performance.
1414
1515 ## Playing
1616
@@ -49,13 +49,19 @@ Checked against a full recompute at a different material and damping, the live p
4949
5050 **Download design YAML** saves the cymbal alone; **Download YAML** under *Recorded hits* saves it together with the hits.
5151
52-## Exporting
52+## Recording and exporting
5353
54-Every hit is logged with its time (from the audio clock), position, velocity and exciter, and, once the exciter has rebounded, its peak force and contact time. **Download YAML** writes the designed configuration with those hits, times shifted so the first falls at the lead-in and the duration set to the last hit plus the tail. Render it with
54+Playing always makes sound; recording is a separate thing you start when a take is worth keeping. **Record hits** logs each strike with its time, position, velocity and exciter, and, once the exciter has rebounded, its peak force and contact time. Starting a recording clears the previous one.
5555
56- cymsim simulate cymplay.yaml
56+Afterwards:
5757
58-**Start capture** and **Stop capture** record the real-time output and **Download WAV** saves it, for comparison with the full render.
58+- **Play the recording** schedules the same hits through the same engine, sample accurate, so you hear the take again.
59+- **Download WAV** renders the take offline, faster than real time, through that same engine in an `OfflineAudioContext`. It is not a capture of the speakers: it is exact, reproducible, and it includes the whole ring-out rather than whatever you happened to leave running. It is still the approximate linear sound.
60+- **Download YAML** writes the designed cymbal together with the hits, times shifted so the first falls at the lead-in and the duration set to the last hit plus the tail. Render it with
61+
62+ cymsim simulate cymplay.yaml
63+
64+ which is where the nonlinear cascade, and the real crash, come back.
5965
6066 ## The precompute
6167
@@ -77,6 +83,8 @@ Two deliberate differences from the Python package: the condensed model has no i
7783
7884 Each mode is a damped resonator with a complex state, advanced with its exact pole once per audio sample. An exciter is a rigid mass on power-law springs at its patch points, a line for a stick and a centre plus hexagon for a mallet, released just above the moving surface at its hit time. The contact force of a sample is found implicitly: the surface under the patch responds to that sample's forces through a small precomputed compliance matrix, so the end-of-sample penetration is affine in the forces and the contact law is solved by a few Newton iterations. That is unconditionally stable for the stiff stick contact, which an explicit coupling could only survive by sub-stepping. The number of springs used in real time is selectable; the exported configuration always keeps the full patches.
7985
86+Each hit carries the frame at which its exciter is released, and a hit whose frame has not arrived costs nothing until the block it falls in. One processor therefore serves live play (release at once), sample-accurate playback of a recording, and the offline render, where the whole performance is handed over in `processorOptions` before rendering begins, since a port message posted before `startRendering` is not guaranteed to arrive in time.
87+
8088 The listener pressure is the sum over modes of the gain times the modal acceleration, plus the direct term of the instantaneous force. Between hits the bank costs about 13 % of a core for 1500 modes at 44.1 kHz, and a contact block two to four times that. The first hit after the audio starts can still overrun its block once while the JavaScript engine optimises the contact path, despite a warm-up pass at start-up.
8189
8290 ## Layout
@@ -90,4 +98,5 @@ The listener pressure is the sum over modes of the gain times the modal accelera
9098 | `src/precompute/` | assembly and condensation, banded Cholesky, eigensolver, listener gains, worker |
9199 | `src/model/`, `src/config/` | mesh, profile, DKT element, shell, schema and YAML (shared with cymsimweb) |
92100 | `src/modes.ts`, `src/patch.ts` | the modes file format; contact patches on it |
101+| `src/render.ts` | scheduling a recording, and rendering it offline to a WAV |
93102 | `tools/`, `tests/` | Python cross-checks and node harnesses |
index.htmlmodified+57−37View file
@@ -17,11 +17,34 @@
1717 <div class="keys" id="keyhelp"></div>
1818 </section>
1919 <section class="right">
20- <details id="setup" open>
21- <summary>Modes</summary>
20+ <div id="ready" class="ready">
21+ <button id="ready-action" class="primary">Start</button>
22+ <div id="ready-text" class="ready-text"></div>
23+ <div class="progress"><div id="progress-bar"></div></div>
24+ </div>
25+
26+ <details id="cymbalbox" open>
27+ <summary>1. The cymbal</summary>
2228 <div class="row">
2329 <label>start from <select id="preset"></select></label>
2430 <label class="file">load YAML <input type="file" id="yamlfile" accept=".yaml,.yml" /></label>
31+ <button id="revert">Revert</button>
32+ <button id="download-design-yaml">Download YAML</button>
33+ </div>
34+ <div class="row small">
35+ Sliders marked <b>live</b> take effect while you play. The others change the shape of the
36+ shell, so the modes have to be computed again; the bar at the top says when they are stale.
37+ </div>
38+ <div id="design"></div>
39+ <div id="design-status" class="status"></div>
40+ </details>
41+
42+ <details id="setup">
43+ <summary>2. Modes</summary>
44+ <div class="row small">
45+ The modes are the cymbal's own vibration patterns. They are computed once per shape, here
46+ in the browser, and kept for next time. A finer mesh reaches higher in frequency and takes
47+ much longer.
2548 </div>
2649 <div class="row">
2750 <label>playback mesh <select id="esize">
@@ -39,37 +62,29 @@
3962 <label class="file">load modes <input type="file" id="modesfile" accept=".bin" /></label>
4063 <button id="clear-cache" title="forget every cached modes file">Clear cache</button>
4164 </div>
42- <div class="progress"><div id="progress-bar"></div></div>
4365 <div id="status" class="status">no modes yet</div>
4466 </details>
45- <details id="designbox" open>
46- <summary>Design the cymbal</summary>
47- <div class="row small">
48- Sliders marked live take effect at once. The others change the shape, so the modes must be
49- recomputed; the panel says when they are stale.
50- </div>
51- <div id="design"></div>
67+
68+ <details id="play" open>
69+ <summary>3. Play</summary>
5270 <div class="row">
53- <button id="revert">Revert to the preset</button>
54- <button id="download-design-yaml">Download design YAML</button>
71+ <label>exciter <select id="exciter">
72+ <option value="stick">stick (hard, bright)</option>
73+ <option value="mallet">mallet (soft, nearly silent without the cascade)</option>
74+ </select></label>
75+ <label>master level <input type="range" id="velocity" min="0.3" max="16" step="0.1" value="6" /> <span id="velocity-value">6.0 m/s</span></label>
76+ <button id="reset-weights" title="put every position back to the master level">Level the positions</button>
77+ </div>
78+ <div class="row small">
79+ Each position is struck at the velocity printed under its letter. Scroll the wheel over a
80+ position to make it harder or softer; the master level scales them all together.
5581 </div>
56- <div id="design-status" class="status"></div>
57- </details>
58- <details id="play" open>
59- <summary>Play</summary>
6082 <div class="row">
61- <button id="audio-start">Start audio</button>
6283 <label>volume <input type="range" id="volume" min="0" max="1" step="0.01" value="0.4" /></label>
6384 <label>output <select id="signal">
6485 <option value="unbaffled">unbaffled (front minus back)</option>
6586 <option value="front">baffled (front only)</option>
6687 </select></label>
67- </div>
68- <div class="row">
69- <label>exciter <select id="exciter">
70- <option value="stick">stick (hard, bright)</option>
71- <option value="mallet">mallet (soft, nearly silent without the cascade)</option>
72- </select></label>
7388 <label>contact springs <select id="points" title="springs per patch in real time; the exported YAML always keeps the full patches">
7489 <option value="cheap">fewest (mallet 1, stick 3)</option>
7590 <option value="mixed" selected>mallet 1, stick 7</option>
@@ -79,29 +94,34 @@
7994 <input type="checkbox" id="preview-gain" checked /> lift the mallet preview level
8095 </label>
8196 </div>
82- <div class="row">
83- <label>master level <input type="range" id="velocity" min="0.3" max="16" step="0.1" value="6" /> <span id="velocity-value">6.0 m/s</span></label>
84- <button id="reset-weights" title="put every position back to the master level">Level the positions</button>
85- </div>
86- <div class="row small">
87- Each position is struck at the velocity printed under its letter. Scroll the wheel over a
88- position to make it harder or softer; the master level scales them all together.
89- </div>
9097 <div class="row small" id="audio-info">audio not started</div>
9198 </details>
99+
92100 <details id="record" open>
93- <summary>Recorded hits</summary>
101+ <summary>4. Record a performance</summary>
102+ <div class="row small">
103+ Playing always makes sound. Recording is separate: start it when you want the hits kept,
104+ then play them back, save the sound, or download the configuration to render properly.
105+ </div>
94106 <div class="row">
95- <button id="clear-hits">Clear</button>
96- <button id="download-yaml" disabled>Download YAML</button>
97- <button id="capture">Start capture</button>
98- <button id="download-wav" disabled>Download WAV</button>
107+ <button id="record-toggle" class="primary">Record hits</button>
108+ <button id="play-hits" disabled>Play the recording</button>
109+ <button id="stop-hits" disabled>Stop</button>
110+ <button id="clear-hits" disabled>Clear</button>
99111 </div>
100112 <div class="row">
113+ <button id="download-yaml" disabled>Download YAML for cymsim</button>
114+ <button id="download-wav" disabled>Download WAV</button>
101115 <label>lead-in <input type="number" id="leadin" value="0.05" step="0.05" min="0" /> s</label>
102116 <label>tail <input type="number" id="tail" value="3" step="0.5" min="0.1" /> s</label>
103- <label>prefix <input type="text" id="prefix" value="cymplay" size="10" /></label>
117+ <label>name <input type="text" id="prefix" value="cymplay" size="10" /></label>
118+ </div>
119+ <div class="row small">
120+ The WAV is the recording played again through this same approximate engine, not a capture
121+ of the speakers, so it is exact and includes the whole ring-out. The YAML renders the same
122+ performance with the full nonlinear model: <code>cymsim simulate <file></code>.
104123 </div>
124+ <div id="record-status" class="status">not recording</div>
105125 <table id="hits">
106126 <thead><tr><th>#</th><th>time [s]</th><th>key</th><th>exciter</th><th>r [m]</th><th>phi [rad]</th><th>v [m/s]</th><th>F peak [N]</th><th>contact [ms]</th></tr></thead>
107127 <tbody></tbody>
src/main.tsmodified+220−72View file
@@ -9,6 +9,7 @@ import { parseModes, type ModesData } from "./modes.ts";
99 import { buildHitPatch, patchExtent } from "./patch.ts";
1010 import { structuralKey } from "./precompute/pipeline.ts";
1111 import type { PrecomputeReply, PrecomputeRequest } from "./precompute/worker.ts";
12+import { gainArray, hitMessages, renderRecording } from "./render.ts";
1213 import { CymbalView, type Spot } from "./view.ts";
1314 import workletUrl from "./worklet.ts?worker&url";
1415
@@ -17,24 +18,34 @@ const CACHE_VERSION = 2;
1718 * which makes it nearly inaudible here although it is the louder of the two once the nonlinear
1819 * cascade is present. It is applied to the output gain only, never to the recorded hits. */
1920 const MALLET_PREVIEW_GAIN = 8;
21+/** Roughly how long the eigensolve takes at each mesh size, for the invitation to compute. */
22+const COMPUTE_ESTIMATE: Record<string, string> = {
23+ "0.015": "about 5 seconds", "0.012": "about 10 seconds", "0.010": "about 20 seconds",
24+ "0.008": "a minute or two", "0.006": "a quarter of an hour",
25+};
2026
2127 // ---------------------------------------------------------------- DOM
2228 const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
29+const readyAction = $<HTMLButtonElement>("ready-action"), readyText = $<HTMLDivElement>("ready-text");
30+const progressBar = $<HTMLDivElement>("progress-bar");
2331 const presetSel = $<HTMLSelectElement>("preset"), yamlFile = $<HTMLInputElement>("yamlfile");
32+const revertBtn = $<HTMLButtonElement>("revert"), designYamlBtn = $<HTMLButtonElement>("download-design-yaml");
33+const designBox = $<HTMLDivElement>("design"), designStatus = $<HTMLDivElement>("design-status");
34+const setupBox = $<HTMLDetailsElement>("setup");
2435 const esizeSel = $<HTMLSelectElement>("esize"), fmaxInput = $<HTMLInputElement>("fmax");
2536 const computeBtn = $<HTMLButtonElement>("compute"), downloadModesBtn = $<HTMLButtonElement>("download-modes");
2637 const modesFile = $<HTMLInputElement>("modesfile"), clearCacheBtn = $<HTMLButtonElement>("clear-cache");
27-const progressBar = $<HTMLDivElement>("progress-bar"), statusEl = $<HTMLDivElement>("status");
28-const designBox = $<HTMLDivElement>("design"), designStatus = $<HTMLDivElement>("design-status");
29-const revertBtn = $<HTMLButtonElement>("revert"), designYamlBtn = $<HTMLButtonElement>("download-design-yaml");
30-const audioStartBtn = $<HTMLButtonElement>("audio-start"), volumeInput = $<HTMLInputElement>("volume");
31-const signalSel = $<HTMLSelectElement>("signal"), velocityInput = $<HTMLInputElement>("velocity");
32-const velocityValue = $<HTMLSpanElement>("velocity-value"), exciterSel = $<HTMLSelectElement>("exciter");
38+const statusEl = $<HTMLDivElement>("status");
39+const exciterSel = $<HTMLSelectElement>("exciter"), velocityInput = $<HTMLInputElement>("velocity");
40+const velocityValue = $<HTMLSpanElement>("velocity-value"), resetWeightsBtn = $<HTMLButtonElement>("reset-weights");
41+const volumeInput = $<HTMLInputElement>("volume"), signalSel = $<HTMLSelectElement>("signal");
3342 const pointsSel = $<HTMLSelectElement>("points"), previewGainBox = $<HTMLInputElement>("preview-gain");
34-const audioInfo = $<HTMLDivElement>("audio-info"), resetWeightsBtn = $<HTMLButtonElement>("reset-weights");
35-const clearHitsBtn = $<HTMLButtonElement>("clear-hits"), downloadYamlBtn = $<HTMLButtonElement>("download-yaml");
36-const captureBtn = $<HTMLButtonElement>("capture"), downloadWavBtn = $<HTMLButtonElement>("download-wav");
43+const audioInfo = $<HTMLDivElement>("audio-info");
44+const recordToggle = $<HTMLButtonElement>("record-toggle"), playHitsBtn = $<HTMLButtonElement>("play-hits");
45+const stopHitsBtn = $<HTMLButtonElement>("stop-hits"), clearHitsBtn = $<HTMLButtonElement>("clear-hits");
46+const downloadYamlBtn = $<HTMLButtonElement>("download-yaml"), downloadWavBtn = $<HTMLButtonElement>("download-wav");
3747 const leadinInput = $<HTMLInputElement>("leadin"), tailInput = $<HTMLInputElement>("tail"), prefixInput = $<HTMLInputElement>("prefix");
48+const recordStatus = $<HTMLDivElement>("record-status");
3849 const hitsBody = document.querySelector<HTMLTableSectionElement>("#hits tbody")!;
3950 const keyhelp = $<HTMLDivElement>("keyhelp");
4051
@@ -52,17 +63,21 @@ let worker: Worker | null = null;
5263 let workerHasModes = false;
5364 let token = 0;
5465 let computeToken = -1;
66+let computing = false;
5567 let computeStart = 0;
5668 let gainToken = -1;
57-/** The material and listener the gains currently in ``modes`` were computed for. The reference
58- * E0 and rho0 in the file header must never be touched: they are what ``freq`` is relative to. */
69+/** The material and listener the gains in ``modes`` were computed for. The reference E0 and rho0
70+ * in the file header must never be touched: they are what ``freq`` is relative to. */
5971 let gainsFor = "";
6072 let gainsPending = "";
6173 let gainTimer = 0;
6274 let hits: LoggedHit[] = [];
6375 let nextId = 1;
64-let capturing = false;
65-let captured: Float32Array | null = null;
76+let recording = false;
77+let recordStart = 0;
78+let playing = false;
79+let playTimer = 0;
80+let rendering = false;
6681 let workletLoad = 0;
6782 const view = new CymbalView($<HTMLCanvasElement>("cymbal"));
6883
@@ -148,6 +163,10 @@ function exciter(): Exciter {
148163 for (const h of config.hits) if (h.exciter.kind === kind) return structuredClone(h.exciter);
149164 return kind === "stick" ? defaultStick() : defaultMallet();
150165 }
166+function maxPointsFor(kind: "stick" | "mallet"): number {
167+ const mode = pointsSel.value;
168+ return mode === "full" ? 7 : kind === "mallet" ? 1 : mode === "cheap" ? 3 : 7;
169+}
151170
152171 // ---------------------------------------------------------------- status helpers
153172 function setStatus(text: string) {
@@ -178,6 +197,50 @@ function forgetModes() {
178197 modesBuffer = null;
179198 derived = null;
180199 downloadModesBtn.disabled = true;
200+ updateReady();
201+}
202+
203+// ---------------------------------------------------------------- the guided bar
204+function stale(): boolean {
205+ return !!modes && modesStructural !== structuralString(config);
206+}
207+
208+function updateReady() {
209+ const issues = validateConfig(config);
210+ readyAction.classList.toggle("recording", false);
211+ if (computing) {
212+ readyAction.disabled = true;
213+ readyAction.textContent = "Computing";
214+ return;
215+ }
216+ readyAction.disabled = false;
217+ if (issues.length) {
218+ readyAction.textContent = "Compute the modes";
219+ readyAction.disabled = true;
220+ readyText.textContent = "The cymbal cannot be built as specified:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n");
221+ view.setHint("fix the cymbal");
222+ return;
223+ }
224+ if (!modes || stale()) {
225+ readyAction.textContent = modes ? "Recompute the modes" : "Compute the modes and play";
226+ readyText.textContent = modes
227+ ? "The shape changed, so the modes no longer match this cymbal."
228+ : `This cymbal has not been worked out yet. Computing its modes takes ${COMPUTE_ESTIMATE[esizeSel.value] ?? "a while"}, once, in this browser.`;
229+ view.setHint(modes ? "recompute the modes" : "press the button to compute the modes");
230+ return;
231+ }
232+ if (!ctx || ctx.state !== "running") {
233+ readyAction.textContent = "Start the sound";
234+ readyText.textContent = "The modes are ready. Browsers only let sound start from a button.";
235+ view.setHint("start the sound");
236+ return;
237+ }
238+ readyAction.textContent = recording ? "Stop recording" : "Record hits";
239+ readyAction.classList.toggle("recording", recording);
240+ readyText.textContent = recording
241+ ? `Recording: ${hits.length} hit${hits.length === 1 ? "" : "s"}. Press a letter key or click the cymbal.`
242+ : "Ready. Press a letter key or click the cymbal. Recording is optional, and off.";
243+ view.setHint(null);
181244 }
182245
183246 // ---------------------------------------------------------------- modes
@@ -194,12 +257,11 @@ async function currentKey() {
194257 function describeModes(label: string) {
195258 if (!modes || !derived) return;
196259 const issues = validateConfig(config);
197- const stale = modesStructural !== structuralString(config);
198260 setStatus(
199261 `${label}: ${modes.nNodes} nodes, ${modes.nModes} modes, ` +
200262 `${derived.freq[0].toFixed(1)} Hz to ${derived.freq[modes.nModes - 1].toFixed(0)} Hz, ` +
201263 `playback mesh ${(modes.meta.playback_element_size * 1e3).toFixed(0)} mm` +
202- (stale ? "\nthe shape has changed since these modes were computed: press Compute modes" : ""),
264+ (stale() ? "\nthe shape has changed since these were computed" : ""),
203265 );
204266 const top = derived.freq[modes.nModes - 1];
205267 designStatus.textContent =
@@ -230,16 +292,20 @@ function setModes(buffer: ArrayBuffer, label: string, structural: string) {
230292 sendInit();
231293 describeModes(label);
232294 if (gainsFor !== gainState(config)) requestGains(0);
295+ updateReady();
296+ updateRecordUi();
233297 }
234298
235299 async function tryCache() {
236300 if (modes) {
237301 describeModes("modes");
302+ updateReady();
238303 return;
239304 }
240305 const buf = await cacheGet(await currentKey());
241306 if (buf) setModes(buf, "cached modes", structuralString(config));
242- else setStatus("no modes for this cymbal yet: press Compute modes");
307+ else setStatus("no modes for this cymbal yet");
308+ updateReady();
243309 }
244310
245311 function ensureWorker(): Worker {
@@ -258,11 +324,14 @@ function compute() {
258324 setStatus("the design has problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
259325 return;
260326 }
327+ computing = true;
261328 computeBtn.disabled = true;
262329 computeStart = performance.now();
263330 computeToken = ++token;
264331 setProgress(0);
265332 setStatus("computing the modes...");
333+ setupBox.open = true;
334+ updateReady();
266335 const req: PrecomputeRequest = { type: "modes", token: computeToken, config: cloneConfig(config), options: options() };
267336 ensureWorker().postMessage(req);
268337 }
@@ -289,15 +358,18 @@ async function onWorkerMessage(msg: PrecomputeReply) {
289358 if (msg.type === "progress") {
290359 if (msg.token !== computeToken) return;
291360 setProgress(msg.frac);
292- setStatus(`${msg.phase} ${(100 * msg.frac).toFixed(0)} % [${((performance.now() - computeStart) / 1000).toFixed(0)} s]`);
361+ const secs = (performance.now() - computeStart) / 1000;
362+ setStatus(`${msg.phase} ${(100 * msg.frac).toFixed(0)} % [${secs.toFixed(0)} s]`);
363+ readyText.textContent = `Working out the modes: ${msg.phase}, ${(100 * msg.frac).toFixed(0)} % after ${secs.toFixed(0)} s.`;
293364 } else if (msg.type === "modes") {
294365 if (msg.token !== computeToken) return;
366+ computing = false;
295367 computeBtn.disabled = false;
296368 setProgress(1);
297369 const structural = structuralString(config);
298370 await cachePut(await cacheKey(structural), msg.buffer);
299371 setModes(msg.buffer, `computed in ${msg.seconds.toFixed(1)} s`, structural);
300- workerHasModes = true; // the worker kept its own copy
372+ workerHasModes = true; // the worker kept its own copy
301373 } else if (msg.type === "gains") {
302374 if (msg.token !== gainToken || !modes) return;
303375 modes.gainFront = msg.front;
@@ -305,22 +377,17 @@ async function onWorkerMessage(msg: PrecomputeReply) {
305377 gainsFor = gainsPending;
306378 sendGain();
307379 } else {
380+ computing = false;
308381 computeBtn.disabled = false;
309382 setStatus(`error: ${msg.message}`);
383+ updateReady();
310384 }
311385 }
312386
313387 // ---------------------------------------------------------------- audio
314-function combinedGain(): Float32Array {
315- if (!modes) return new Float32Array(0);
316- const g = new Float32Array(2 * modes.nModes);
317- const back = signalSel.value === "unbaffled";
318- for (let i = 0; i < g.length; i++) g[i] = modes.gainFront[i] - (back ? modes.gainBack[i] : 0);
319- return g;
320-}
321388 function sendInit() {
322389 if (!node || !modes || !derived) return;
323- node.port.postMessage({ type: "init", freq: derived.freq, sigma: derived.sigma, gain: combinedGain() });
390+ node.port.postMessage({ type: "init", freq: derived.freq, sigma: derived.sigma, gain: gainArray(modes, signalSel.value === "unbaffled") });
324391 }
325392 function sendParams() {
326393 if (!node || !derived) return;
@@ -328,13 +395,14 @@ function sendParams() {
328395 }
329396 function sendGain() {
330397 if (!node || !modes) return;
331- node.port.postMessage({ type: "gain", gain: combinedGain() });
398+ node.port.postMessage({ type: "gain", gain: gainArray(modes, signalSel.value === "unbaffled") });
332399 }
333400
334401 async function startAudio() {
335402 if (ctx) {
336403 await ctx.resume();
337404 updateAudioInfo();
405+ updateReady();
338406 return;
339407 }
340408 ctx = new AudioContext({ latencyHint: "interactive" });
@@ -346,9 +414,9 @@ async function startAudio() {
346414 node.port.onmessage = (ev) => onWorkletMessage(ev.data);
347415 node.onprocessorerror = () => setStatus("the audio processor stopped: reload the page (details in the browser console)");
348416 await ctx.resume();
349- audioStartBtn.textContent = "Audio running";
350417 sendInit();
351418 updateAudioInfo();
419+ updateReady();
352420 setInterval(updateAudioInfo, 1000);
353421 }
354422
@@ -366,7 +434,6 @@ interface WorkletMessage {
366434 contactTime?: number;
367435 rebound?: number;
368436 mean?: number;
369- chunks?: Float32Array[];
370437 }
371438
372439 function onWorkletMessage(msg: WorkletMessage) {
@@ -384,64 +451,52 @@ function onWorkletMessage(msg: WorkletMessage) {
384451 renderHits();
385452 } else if (msg.type === "load") {
386453 workletLoad = msg.mean ?? 0;
387- } else if (msg.type === "captured") {
388- const chunks = msg.chunks ?? [];
389- const n = chunks.reduce((s, c) => s + c.length, 0);
390- captured = new Float32Array(n);
391- let pos = 0;
392- for (const c of chunks) {
393- captured.set(c, pos);
394- pos += c.length;
395- }
396- downloadWavBtn.disabled = n === 0;
397- captureBtn.textContent = "Start capture";
398- setStatus(`captured ${(n / (ctx?.sampleRate ?? 48000)).toFixed(2)} s`);
399454 }
400455 }
401456
402457 // ---------------------------------------------------------------- hits
403458 function strike(r: number, phi: number, velocity: number, source: string) {
404- if (!modes) {
405- setStatus("compute or load the modes first");
406- return;
407- }
408- if (!node || !ctx) {
409- setStatus("press Start audio first");
459+ if (!modes || !derived) {
460+ setStatus("compute the modes first");
410461 return;
411462 }
463+ if (!node || !ctx) return;
412464 const ex = exciter();
413465 const R = config.profile.radius, es = modes.meta.playback_element_size;
414466 if (r + patchExtent(ex) >= R - es) {
415467 setStatus(`hit at r = ${r.toFixed(3)} m: the contact patch reaches the rim, ignored`);
416468 return;
417469 }
418- const mode = pointsSel.value;
419- const maxPoints = mode === "full" ? 7 : ex.kind === "mallet" ? 1 : mode === "cheap" ? 3 : 7;
420- const patch = buildHitPatch(modes, ex, r, phi, velocity, maxPoints, derived?.phiFactor ?? 1);
470+ const patch = buildHitPatch(modes, ex, r, phi, velocity, maxPointsFor(ex.kind), derived.phiFactor);
421471 if (!patch) {
422472 setStatus("that point is outside the mesh, ignored");
423473 return;
424474 }
475+ const logged = recording;
425476 const id = nextId++;
426477 node.port.postMessage(
427478 {
428479 type: "hit", id, phi: patch.phi, offsets: patch.offsets, kPoint: patch.kPoint, alpha: patch.alpha,
429- mass: patch.mass, gap: patch.gap, velocity,
480+ mass: patch.mass, gap: patch.gap, velocity, silent: !logged,
430481 },
431482 [patch.phi.buffer, patch.offsets.buffer],
432483 );
433- hits.push({ id, time: -1, r, phi, velocity, exciter: ex, source });
484+ if (logged) {
485+ hits.push({ id, time: ctx.currentTime - recordStart, r, phi, velocity, exciter: ex, source });
486+ renderHits();
487+ updateRecordUi();
488+ updateReady();
489+ }
434490 view.flash(r * Math.cos(phi), r * Math.sin(phi), velocity, ex.kind);
435- renderHits();
436491 }
437492
438493 function renderHits() {
439494 hitsBody.innerHTML = "";
440- const t0 = hits.find((h) => h.time >= 0)?.time ?? 0;
495+ const t0 = hits.length ? Math.min(...hits.map((h) => h.time)) : 0;
441496 hits.forEach((h, i) => {
442497 const tr = document.createElement("tr");
443498 for (const c of [
444- String(i + 1), h.time >= 0 ? (h.time - t0).toFixed(3) : "...", h.source, h.exciter.kind, h.r.toFixed(3),
499+ String(i + 1), (h.time - t0).toFixed(3), h.source, h.exciter.kind, h.r.toFixed(3),
445500 h.phi.toFixed(3), h.velocity.toFixed(2), h.peakForce !== undefined ? h.peakForce.toFixed(0) : "",
446501 h.contactTime !== undefined ? (h.contactTime * 1e3).toFixed(2) : "",
447502 ]) {
@@ -451,7 +506,89 @@ function renderHits() {
451506 }
452507 hitsBody.appendChild(tr);
453508 });
454- downloadYamlBtn.disabled = hits.length === 0;
509+}
510+
511+function updateRecordUi() {
512+ const has = hits.length > 0;
513+ const ready = !!modes && !!node;
514+ recordToggle.textContent = recording ? "Stop recording" : "Record hits";
515+ recordToggle.classList.toggle("recording", recording);
516+ recordToggle.disabled = !ready || playing;
517+ playHitsBtn.disabled = !has || !ready || recording || playing;
518+ stopHitsBtn.disabled = !playing;
519+ clearHitsBtn.disabled = !has || recording || playing;
520+ downloadYamlBtn.disabled = !has;
521+ downloadWavBtn.disabled = !has || !modes || rendering;
522+ const span = has ? Math.max(...hits.map((h) => h.time)) - Math.min(...hits.map((h) => h.time)) : 0;
523+ recordStatus.textContent = rendering
524+ ? "rendering the WAV..."
525+ : playing
526+ ? "playing the recording back"
527+ : recording
528+ ? `recording: ${hits.length} hit${hits.length === 1 ? "" : "s"} over ${span.toFixed(1)} s`
529+ : has
530+ ? `${hits.length} hit${hits.length === 1 ? "" : "s"} over ${span.toFixed(1)} s`
531+ : "not recording; play freely, and press Record hits when you want a take kept";
532+}
533+
534+function toggleRecording() {
535+ if (!ctx) return;
536+ if (recording) {
537+ recording = false;
538+ } else {
539+ hits = [];
540+ nextId = 1;
541+ recording = true;
542+ recordStart = ctx.currentTime;
543+ renderHits();
544+ }
545+ updateRecordUi();
546+ updateReady();
547+}
548+
549+function playRecording() {
550+ if (!modes || !derived || !node || !ctx || !hits.length) return;
551+ node.port.postMessage({ type: "reset" });
552+ const fs = ctx.sampleRate;
553+ const base = Math.round(ctx.currentTime * fs) + Math.round(0.15 * fs);
554+ const { messages, span } = hitMessages(modes, derived, hits, fs, base, (h) => maxPointsFor(h.exciter.kind));
555+ for (const m of messages) node.port.postMessage(m, [(m.phi as Float32Array).buffer, (m.offsets as Float32Array).buffer]);
556+ playing = true;
557+ updateRecordUi();
558+ updateReady();
559+ clearTimeout(playTimer);
560+ playTimer = window.setTimeout(() => {
561+ playing = false;
562+ updateRecordUi();
563+ updateReady();
564+ }, 1000 * (0.15 + span) + 500);
565+}
566+
567+function stopPlayback() {
568+ clearTimeout(playTimer);
569+ playing = false;
570+ node?.port.postMessage({ type: "reset" });
571+ updateRecordUi();
572+ updateReady();
573+}
574+
575+async function downloadWav() {
576+ if (!modes || !derived || !hits.length) return;
577+ rendering = true;
578+ updateRecordUi();
579+ try {
580+ const fs = ctx?.sampleRate ?? 44100;
581+ const samples = await renderRecording(workletUrl, modes, config, derived, hits, {
582+ sampleRate: fs, leadIn: Number(leadinInput.value), tail: Number(tailInput.value),
583+ maxPoints: maxPointsFor(exciterSel.value as "stick" | "mallet"), unbaffled: signalSel.value === "unbaffled",
584+ });
585+ download(encodeWav(samples, fs), `${prefixInput.value || "cymplay"}_linear.wav`);
586+ } catch (err) {
587+ setStatus(`could not render the WAV: ${(err as Error).message}`);
588+ } finally {
589+ rendering = false;
590+ updateRecordUi();
591+ }
455592 }
456593
457594 // ---------------------------------------------------------------- design panel
@@ -459,22 +596,24 @@ const panel = new DesignPanel(designBox, () => config, onDesignChange);
459596
460597 function onDesignChange(_field: Field, tier: Tier) {
461598 refreshSpots();
462- if (!modes) return;
463- if (tier === "live") {
599+ if (modes && tier === "live") {
464600 derived = derive(modes, config);
465601 sendParams();
466602 requestGains();
467603 }
468604 describeModes("modes");
605+ updateReady();
469606 }
470607
471608 function setConfig(cfg: SimulationConfig, label: string) {
472609 config = cfg;
473610 hits = [];
611+ recording = false;
474612 renderHits();
475613 panel.refresh();
476614 refreshSpots();
477615 forgetModes();
616+ updateRecordUi();
478617 setStatus(`${label}: ${Math.round(cfg.profile.radius * 2 * 39.3701)} inch, ${(cfg.profile.h_rim * 1e3).toFixed(2)} to ${(cfg.profile.h_bell * 1e3).toFixed(2)} mm thick, seed ${cfg.imperfections.seed}`);
479618 void tryCache();
480619 }
@@ -492,6 +631,20 @@ customOpt.textContent = "loaded YAML";
492631 customOpt.disabled = true;
493632 presetSel.appendChild(customOpt);
494633
634+readyAction.addEventListener("click", async () => {
635+ if (computing) return;
636+ if (!modes || stale()) {
637+ await startAudio(); // this click is the gesture a browser needs to allow sound
638+ compute();
639+ return;
640+ }
641+ if (!ctx || ctx.state !== "running") {
642+ await startAudio();
643+ return;
644+ }
645+ toggleRecording();
646+});
647+
495648 presetSel.addEventListener("change", () => {
496649 const p = PRESETS.find((x) => x.id === presetSel.value);
497650 if (p) {
@@ -557,7 +710,6 @@ designYamlBtn.addEventListener("click", () => {
557710 cfg.output.prefix = prefixInput.value || "cymplay";
558711 download(new Blob([configToYaml(cfg)], { type: "text/yaml" }), `${cfg.output.prefix}_design.yaml`);
559712 });
560-audioStartBtn.addEventListener("click", () => void startAudio());
561713 volumeInput.addEventListener("input", setVolume);
562714 previewGainBox.addEventListener("change", setVolume);
563715 exciterSel.addEventListener("change", setVolume);
@@ -571,27 +723,21 @@ resetWeightsBtn.addEventListener("click", () => {
571723 saveWeights();
572724 refreshSpots();
573725 });
726+recordToggle.addEventListener("click", toggleRecording);
727+playHitsBtn.addEventListener("click", playRecording);
728+stopHitsBtn.addEventListener("click", stopPlayback);
574729 clearHitsBtn.addEventListener("click", () => {
575730 hits = [];
731+ nextId = 1;
576732 renderHits();
577- node?.port.postMessage({ type: "reset" });
733+ updateRecordUi();
734+ updateReady();
578735 });
579736 downloadYamlBtn.addEventListener("click", () => {
580737 const text = exportYaml(config, hits, { leadIn: Number(leadinInput.value), tail: Number(tailInput.value), prefix: prefixInput.value || "cymplay" });
581738 download(new Blob([text], { type: "text/yaml" }), `${prefixInput.value || "cymplay"}.yaml`);
582739 });
583-captureBtn.addEventListener("click", () => {
584- if (!node) {
585- setStatus("press Start audio first");
586- return;
587- }
588- capturing = !capturing;
589- node.port.postMessage({ type: "capture", on: capturing });
590- captureBtn.textContent = capturing ? "Stop capture" : "Start capture";
591-});
592-downloadWavBtn.addEventListener("click", () => {
593- if (captured && ctx) download(encodeWav(captured, ctx.sampleRate), `${prefixInput.value || "cymplay"}_linear.wav`);
594-});
740+downloadWavBtn.addEventListener("click", () => void downloadWav());
595741
596742 function setLevel(v: number) {
597743 velocityInput.value = String(Math.min(16, Math.max(0.3, v)));
@@ -630,4 +776,6 @@ window.addEventListener("keydown", (ev) => {
630776 panel.refresh();
631777 refreshSpots();
632778 velocityValue.textContent = `${Number(velocityInput.value).toFixed(1)} m/s`;
779+updateRecordUi();
780+updateReady();
633781 void tryCache();
src/render.tsadded+67−0View file
@@ -0,0 +1,67 @@
1+// Rendering a recorded performance offline.
2+//
3+// The WAV you can download is not a recording of what came out of the speakers: it is the same
4+// hits played again through the same AudioWorklet in an OfflineAudioContext, which runs as fast
5+// as the machine allows and is exactly reproducible. The whole performance is handed to the
6+// processor in processorOptions, because a port message posted before startRendering is not
7+// guaranteed to arrive before the first block.
8+import type { SimulationConfig } from "./config/schema.ts";
9+import type { Derived } from "./derived.ts";
10+import type { LoggedHit } from "./hits.ts";
11+import type { ModesData } from "./modes.ts";
12+import { buildHitPatch } from "./patch.ts";
13+
14+export interface RenderOptions {
15+ sampleRate: number;
16+ leadIn: number; // silence before the first hit [s]
17+ tail: number; // ring-out after the last hit [s]
18+ maxPoints: number;
19+ unbaffled: boolean;
20+}
21+
22+/** The hits as worklet messages, with release frames relative to ``baseFrame``. */
23+export function hitMessages(modes: ModesData, derived: Derived, hits: LoggedHit[], sampleRate: number,
24+ baseFrame: number, maxPointsOf: (h: LoggedHit) => number) {
25+ const sorted = hits.filter((h) => h.time >= 0).slice().sort((a, b) => a.time - b.time);
26+ const t0 = sorted.length ? sorted[0].time : 0;
27+ const out: Record<string, unknown>[] = [];
28+ for (const h of sorted) {
29+ const patch = buildHitPatch(modes, h.exciter, h.r, h.phi, h.velocity, maxPointsOf(h), derived.phiFactor);
30+ if (!patch) continue;
31+ out.push({
32+ type: "hit", id: h.id, phi: patch.phi, offsets: patch.offsets, kPoint: patch.kPoint, alpha: patch.alpha,
33+ mass: patch.mass, gap: patch.gap, velocity: h.velocity,
34+ atFrame: baseFrame + Math.round((h.time - t0) * sampleRate), silent: true,
35+ });
36+ }
37+ return { messages: out, span: sorted.length ? sorted[sorted.length - 1].time - t0 : 0 };
38+}
39+
40+export function gainArray(modes: ModesData, unbaffled: boolean): Float32Array {
41+ const g = new Float32Array(2 * modes.nModes);
42+ for (let i = 0; i < g.length; i++) g[i] = modes.gainFront[i] - (unbaffled ? modes.gainBack[i] : 0);
43+ return g;
44+}
45+
46+export async function renderRecording(workletUrl: string, modes: ModesData, _config: SimulationConfig,
47+ derived: Derived, hits: LoggedHit[], opts: RenderOptions): Promise<Float32Array> {
48+ const fs = opts.sampleRate;
49+ const lead = Math.round(opts.leadIn * fs);
50+ const { messages, span } = hitMessages(modes, derived, hits, fs, lead, () => opts.maxPoints);
51+ if (!messages.length) throw new Error("there are no hits to render");
52+ const frames = lead + Math.ceil(span * fs) + Math.ceil(opts.tail * fs);
53+ const ctx = new OfflineAudioContext({ numberOfChannels: 1, length: frames, sampleRate: fs });
54+ await ctx.audioWorklet.addModule(workletUrl);
55+ const node = new AudioWorkletNode(ctx, "cymbal-modal", {
56+ numberOfInputs: 0,
57+ numberOfOutputs: 1,
58+ outputChannelCount: [1],
59+ processorOptions: {
60+ init: { type: "init", freq: derived.freq, sigma: derived.sigma, gain: gainArray(modes, opts.unbaffled) },
61+ hits: messages,
62+ },
63+ });
64+ node.connect(ctx.destination);
65+ const buffer = await ctx.startRendering();
66+ return buffer.getChannelData(0).slice();
67+}
src/style.cssmodified+9−0View file
@@ -49,3 +49,12 @@ details.group .note { font-size: 11px; opacity: 0.65; margin: 2px 0 6px; }
4949 .field.live > label::after { content: " live"; font-size: 9px; letter-spacing: 0.04em; opacity: 0.55; text-transform: uppercase; }
5050 .status.bad { color: #b03030; }
5151 @media (prefers-color-scheme: dark) { .status.bad { color: #ff8b8b; } }
52+
53+/* the guided bar at the top of the controls */
54+.ready { background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: 6px; padding: 8px 10px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
55+.ready-text { flex: 1; min-width: 180px; font-size: 12.5px; white-space: pre-wrap; }
56+.ready .progress { flex-basis: 100%; margin: 0; }
57+button.primary { font-weight: 600; padding: 6px 14px; background: var(--accent); color: #fff; border: 1px solid transparent; border-radius: 4px; cursor: pointer; }
58+button.primary:disabled { opacity: 0.5; cursor: default; }
59+button.recording { background: #b03030; }
60+code { font-size: 11.5px; background: rgba(128,128,128,0.15); padding: 0 3px; border-radius: 3px; }
src/view.tsmodified+19−0View file
@@ -26,6 +26,7 @@ export class CymbalView {
2626 private flashes: Flash[] = [];
2727 private spots: Spot[] = [];
2828 private profile: Profile | null = null;
29+ private hint: string | null = null;
2930 private raf = 0;
3031 onStrike: ((x: number, y: number) => void) | null = null;
3132 /** Wheel over a position: ``steps`` is positive away from the user (harder). */
@@ -67,6 +68,13 @@ export class CymbalView {
6768 this.draw();
6869 }
6970
71+ /** A short instruction drawn across the cymbal while it cannot be played. */
72+ setHint(hint: string | null) {
73+ if (hint === this.hint) return;
74+ this.hint = hint;
75+ this.draw();
76+ }
77+
7078 setProfile(profile: Profile, spots: Spot[]) {
7179 this.profile = profile;
7280 this.spots = spots;
@@ -197,6 +205,17 @@ export class CymbalView {
197205 ctx.fillStyle = `rgba(255,255,255,${1 - a})`;
198206 ctx.fill();
199207 }
208+ if (this.hint) {
209+ ctx.fillStyle = "rgba(20,18,14,0.55)";
210+ ctx.beginPath();
211+ ctx.arc(cx, cy, p.radius * scale, 0, 2 * Math.PI);
212+ ctx.fill();
213+ ctx.fillStyle = "#fff";
214+ ctx.font = `${fs * 1.05}px system-ui, sans-serif`;
215+ ctx.textAlign = "center";
216+ ctx.textBaseline = "middle";
217+ ctx.fillText(this.hint, cx, cy);
218+ }
200219 // axes hint
201220 ctx.fillStyle = "rgba(0,0,0,0.5)";
202221 ctx.font = `${fs * 0.8}px system-ui, sans-serif`;
src/worklet-globals.d.tsmodified+7−2View file
@@ -3,10 +3,15 @@ declare const sampleRate: number;
33 declare const currentFrame: number;
44 declare const currentTime: number;
55
6+interface WorkletProcessorOptions {
7+ processorOptions?: unknown;
8+}
9+
610 declare class AudioWorkletProcessor {
711 readonly port: MessagePort;
8- constructor();
12+ constructor(options?: WorkletProcessorOptions);
913 process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record<string, Float32Array>): boolean;
1014 }
1115
12-declare function registerProcessor(name: string, ctor: new () => AudioWorkletProcessor): void;
16+// eslint-disable-next-line @typescript-eslint/no-explicit-any
17+declare function registerProcessor(name: string, ctor: new (options?: any) => AudioWorkletProcessor): void;
src/worklet.tsmodified+86−64View file
@@ -17,6 +17,11 @@
1717 * end-of-step penetration is affine in F and the small nonlinear system is solved by Newton
1818 * iteration. This keeps the bank at one step per sample for any contact stiffness, where an
1919 * explicit coupling would need sub-steps. Between hits the bank runs mode-outer over the block.
20+ *
21+ * A hit carries the frame at which its exciter is released, so one processor serves live play
22+ * (release at once), timed playback of a recording, and an offline render, where the whole
23+ * performance is handed over in processorOptions before rendering begins. A hit whose frame has
24+ * not arrived costs nothing until the block it falls in.
2025 */
2126
2227 interface InitMessage {
@@ -49,9 +54,11 @@ interface HitMessage {
4954 mass: number;
5055 gap: number;
5156 velocity: number; // approach speed, positive
57+ atFrame?: number; // release frame; absent means as soon as possible
58+ silent?: boolean; // do not report this hit back (playback and offline renders)
5259 }
5360
54-type InMessage = InitMessage | ParamsMessage | GainMessage | HitMessage | { type: "reset" } | { type: "capture"; on: boolean };
61+type InMessage = InitMessage | ParamsMessage | GainMessage | HitMessage | { type: "reset" };
5562
5663 interface Exciter {
5764 id: number;
@@ -63,6 +70,8 @@ interface Exciter {
6370 mass: number;
6471 gap: number;
6572 velocity: number;
73+ atFrame: number;
74+ silent: boolean;
6675 released: boolean;
6776 zs: number;
6877 vs: number;
@@ -71,7 +80,6 @@ interface Exciter {
7180 contactSamples: number;
7281 newtonIters: number;
7382 touched: boolean;
74- silent: boolean; // warm-up exciter: no messages
7583 wp: Float64Array; // free end-of-step surface height under the points (with offsets)
7684 Fp: Float64Array; // current forces
7785 c: Float64Array; // (P, P) one-step compliance of the surface
@@ -82,7 +90,7 @@ interface Exciter {
8290
8391 const HOLD = 1e3;
8492 const now: () => number = typeof performance !== "undefined" ? () => performance.now() : () => Date.now();
85-const RELEASE_CLEARANCE = 2e-3; // exciter is dropped once it has rebounded this far above the surface
93+const RELEASE_CLEARANCE = 2e-3; // an exciter is dropped once it has rebounded this far clear
8694 const NEWTON_MAX = 12;
8795
8896 class CymbalModalProcessor extends AudioWorkletProcessor {
@@ -99,17 +107,21 @@ class CymbalModalProcessor extends AudioWorkletProcessor {
99107 private ci = new Float64Array(0);
100108 private dr = new Float64Array(0); // direct term Re(G)
101109 private f = new Float64Array(0);
102- private exciters: Exciter[] = [];
103- private capture = false;
104- private captureBuf: Float32Array[] = [];
110+ private lastGain: Float32Array<ArrayBufferLike> = new Float32Array(0);
111+ private exciters: Exciter[] = []; // due in this block, or already sounding
112+ private pending: Exciter[] = []; // waiting for a later frame
105113 private loadAcc = 0;
106114 private loadBlocks = 0;
107115 private lastLoadReport = 0;
108- private lastGain: Float32Array<ArrayBufferLike> = new Float32Array(0);
109116
110- constructor() {
117+ constructor(options?: { processorOptions?: { init?: InitMessage; hits?: HitMessage[] } }) {
111118 super();
112119 this.port.onmessage = (ev: MessageEvent<InMessage>) => this.onMessage(ev.data);
120+ // An offline render hands the whole performance over here, because a port message posted
121+ // before startRendering is not guaranteed to arrive before the first block.
122+ const opts = options?.processorOptions;
123+ if (opts?.init) this.init(opts.init);
124+ if (opts?.hits) for (const h of opts.hits) this.addHit(h);
113125 }
114126
115127 private onMessage(msg: InMessage) {
@@ -130,13 +142,7 @@ class CymbalModalProcessor extends AudioWorkletProcessor {
130142 this.zr.fill(0);
131143 this.zi.fill(0);
132144 this.exciters = [];
133- break;
134- case "capture":
135- this.capture = msg.on;
136- if (!msg.on) {
137- this.port.postMessage({ type: "captured", chunks: this.captureBuf });
138- this.captureBuf = [];
139- }
145+ this.pending = [];
140146 break;
141147 }
142148 }
@@ -159,9 +165,36 @@ class CymbalModalProcessor extends AudioWorkletProcessor {
159165 this.lastGain = msg.gain;
160166 this.setParams(msg.freq, msg.sigma);
161167 this.exciters = [];
168+ this.pending = [];
162169 this.warmUp();
163170 }
164171
172+ /** Run realistic fake hits through the contact code path so that the JIT has compiled and
173+ * optimised it before the first real one; the first contact blocks were otherwise several
174+ * times over the deadline. */
175+ private warmUp() {
176+ const Nm = this.nModes;
177+ const scratch = new Float32Array(128);
178+ const fake = (P: number, kPoint: number, alpha: number, mass: number, velocity: number) => {
179+ const phi = new Float32Array(P * Nm), offsets = new Float32Array(P);
180+ for (let p = 0; p < P; p++) {
181+ for (let m = 0; m < Nm; m++) phi[p * Nm + m] = 0.7 * Math.sin(0.37 * m + 1.3 * p) * Math.cos(0.11 * m);
182+ offsets[p] = 1e-4 * (p - 0.5 * (P - 1));
183+ }
184+ this.addHit({ type: "hit", id: -1, phi, offsets, kPoint, alpha, mass, gap: 1e-4, velocity, silent: true });
185+ };
186+ fake(1, 1.8e8, 2.5, 0.3, 8);
187+ fake(7, 5e8 / 7, 1.5, 0.03, 7);
188+ for (let k = 0; k < 80; k++) {
189+ this.renderBlock(scratch, 0);
190+ if (k === 40) fake(7, 5e8 / 7, 1.5, 0.03, 5);
191+ }
192+ this.exciters = [];
193+ this.pending = [];
194+ this.zr.fill(0);
195+ this.zi.fill(0);
196+ }
197+
165198 /** Poles and forcing coefficients of every mode; the ringing state is left alone. */
166199 private setParams(freq: Float64Array, sigma: Float64Array) {
167200 const Nm = this.nModes;
@@ -187,33 +220,6 @@ class CymbalModalProcessor extends AudioWorkletProcessor {
187220 this.setGain(this.lastGain);
188221 }
189222
190- /** Run realistic fake hits through the contact code path so that the JIT has compiled and
191- * optimised it before the first real hit (the first contact blocks were otherwise several
192- * times over the deadline). Two hits (a one-point soft one and a seven-point stiff one with
193- * staggered offsets) contact, rebound and are dropped, then the bank rings freely. */
194- private warmUp() {
195- const Nm = this.nModes;
196- const scratch = new Float32Array(128);
197- const fake = (P: number, kPoint: number, alpha: number, mass: number, velocity: number) => {
198- const phi = new Float32Array(P * Nm), offsets = new Float32Array(P);
199- for (let p = 0; p < P; p++) {
200- for (let m = 0; m < Nm; m++) phi[p * Nm + m] = 0.7 * Math.sin(0.37 * m + 1.3 * p) * Math.cos(0.11 * m);
201- offsets[p] = 1e-4 * (p - 0.5 * (P - 1));
202- }
203- this.addHit({ type: "hit", id: -1, phi, offsets, kPoint, alpha, mass, gap: 1e-4, velocity });
204- this.exciters[this.exciters.length - 1].silent = true;
205- };
206- fake(1, 1.8e8, 2.5, 0.3, 8);
207- fake(7, 5e8 / 7, 1.5, 0.03, 7);
208- for (let k = 0; k < 80; k++) {
209- this.renderBlock(scratch);
210- if (k === 40) fake(7, 5e8 / 7, 1.5, 0.03, 5);
211- }
212- this.exciters = [];
213- this.zr.fill(0);
214- this.zi.fill(0);
215- }
216-
217223 private setGain(gain: Float32Array) {
218224 const Nm = this.nModes;
219225 this.lastGain = gain;
@@ -230,7 +236,7 @@ class CymbalModalProcessor extends AudioWorkletProcessor {
230236
231237 private addHit(msg: HitMessage) {
232238 const P = msg.offsets.length, Nm = this.nModes;
233- if (msg.phi.length !== P * Nm) return;
239+ if (Nm === 0 || msg.phi.length !== P * Nm) return;
234240 // one-step compliance c_pq = sum_m phi_m(p) phi_m(q) Im(b_m)
235241 const c = new Float64Array(P * P);
236242 const phi = msg.phi, bi = this.bi;
@@ -243,12 +249,15 @@ class CymbalModalProcessor extends AudioWorkletProcessor {
243249 c[q * P + p] = s;
244250 }
245251 }
246- this.exciters.push({
252+ const ex: Exciter = {
247253 id: msg.id, phi, offsets: msg.offsets, nPoints: P, kPoint: msg.kPoint, alpha: msg.alpha, mass: msg.mass,
248- gap: msg.gap, velocity: msg.velocity, released: false, zs: HOLD, vs: 0, startFrame: 0, peakForce: 0,
249- contactSamples: 0, newtonIters: 0, touched: false, silent: false, wp: new Float64Array(P), Fp: new Float64Array(P), c,
254+ gap: msg.gap, velocity: msg.velocity, atFrame: msg.atFrame ?? 0, silent: msg.silent ?? false,
255+ released: false, zs: HOLD, vs: 0, startFrame: 0, peakForce: 0, contactSamples: 0, newtonIters: 0,
256+ touched: false, wp: new Float64Array(P), Fp: new Float64Array(P), c,
250257 J: new Float64Array(P * P), g: new Float64Array(P), dF: new Float64Array(P),
251- });
258+ };
259+ if (ex.atFrame > currentFrame + 128) this.pending.push(ex);
260+ else this.exciters.push(ex);
252261 }
253262
254263 /** Surface height (with offsets) under the patch points from the current state; returns the max. */
@@ -269,10 +278,9 @@ class CymbalModalProcessor extends AudioWorkletProcessor {
269278 /** Implicit contact forces for this sample given the free end-of-step surface heights ex.wp. */
270279 private solveContact(ex: Exciter, dt: number): number {
271280 const P = ex.nPoints, c = ex.c, F = ex.Fp, J = ex.J, g = ex.g, dF = ex.dF;
272- const base = ex.zs + dt * ex.vs; // exciter face at the end of the step without contact
281+ const base = ex.zs + dt * ex.vs; // the exciter face at the end of the step, without contact
273282 const em = dt * dt / ex.mass;
274283 const k = ex.kPoint, a = ex.alpha;
275- // quick exit: no point can touch even with zero force
276284 let touch = false;
277285 for (let p = 0; p < P; p++) if (ex.wp[p] > base) touch = true;
278286 if (!touch) {
@@ -315,8 +323,7 @@ class CymbalModalProcessor extends AudioWorkletProcessor {
315323 const out = outputs[0][0];
316324 if (!out || this.nModes === 0) return true;
317325 const t0 = now();
318- this.renderBlock(out);
319- if (this.capture) this.captureBuf.push(Float32Array.from(out));
326+ this.renderBlock(out, currentFrame);
320327 const cost = now() - t0;
321328 this.loadAcc += cost / (1000 * out.length / sampleRate);
322329 this.loadBlocks++;
@@ -329,21 +336,21 @@ class CymbalModalProcessor extends AudioWorkletProcessor {
329336 return true;
330337 }
331338
332- private renderBlock(out: Float32Array): void {
339+ private renderBlock(out: Float32Array, frame0: number): void {
333340 const B = out.length;
334341 const Nm = this.nModes;
335342 const zr = this.zr, zi = this.zi, cr = this.cr, ci = this.ci, pr = this.pr, pi = this.pi;
336343 const dt = 1 / sampleRate;
337344
338- // release any exciter that is waiting: place it just above the current surface
339- for (const ex of this.exciters) {
340- if (!ex.released) {
341- ex.zs = this.surface(ex) + ex.gap;
342- ex.vs = -ex.velocity;
343- ex.released = true;
344- ex.startFrame = currentFrame;
345- if (!ex.silent) this.port.postMessage({ type: "hitStarted", id: ex.id, frame: currentFrame, time: currentFrame / sampleRate });
345+ // anything due in this block joins the working set; the rest costs nothing yet
346+ if (this.pending.length) {
347+ const end = frame0 + B;
348+ const still: Exciter[] = [];
349+ for (const ex of this.pending) {
350+ if (ex.atFrame < end) this.exciters.push(ex);
351+ else still.push(ex);
346352 }
353+ this.pending = still;
347354 }
348355
349356 if (this.exciters.length === 0) {
@@ -371,10 +378,22 @@ class CymbalModalProcessor extends AudioWorkletProcessor {
371378 zr[m] = pr[m] * r - pi[m] * i;
372379 zi[m] = pr[m] * i + pi[m] * r;
373380 }
374- // free end-of-step surface under every patch, implicit contact forces, exciter update
381+ // release whatever is due at this sample, just above the surface as it is now
382+ const frame = frame0 + n;
383+ for (let e = 0; e < exs.length; e++) {
384+ const ex = exs[e];
385+ if (ex.released || frame < ex.atFrame) continue;
386+ ex.zs = this.surface(ex) + ex.gap;
387+ ex.vs = -ex.velocity;
388+ ex.released = true;
389+ ex.startFrame = frame;
390+ if (!ex.silent) this.port.postMessage({ type: "hitStarted", id: ex.id, frame, time: frame / sampleRate });
391+ }
392+ // implicit contact forces, then the exciter update
375393 let any = false;
376394 for (let e = 0; e < exs.length; e++) {
377395 const ex = exs[e];
396+ if (!ex.released) continue;
378397 this.surface(ex);
379398 const F = this.solveContact(ex, dt);
380399 if (F > 0) {
@@ -392,6 +411,7 @@ class CymbalModalProcessor extends AudioWorkletProcessor {
392411 f.fill(0);
393412 for (let e = 0; e < exs.length; e++) {
394413 const ex = exs[e], P = ex.nPoints, phi = ex.phi, F = ex.Fp;
414+ if (!ex.released) continue;
395415 for (let p = 0; p < P; p++) {
396416 const Fp = F[p];
397417 if (Fp === 0) continue;
@@ -412,14 +432,16 @@ class CymbalModalProcessor extends AudioWorkletProcessor {
412432 out[n] = y;
413433 }
414434 // drop exciters that have rebounded clear of the surface
435+ const frameEnd = frame0 + B;
415436 this.exciters = exs.filter((ex) => {
437+ if (!ex.released) return true;
416438 const top = this.surface(ex);
417439 const done = ex.touched && ex.vs > 0 && ex.zs - top > RELEASE_CLEARANCE;
418- const lost = !ex.touched && currentFrame - ex.startFrame > sampleRate * 0.1;
440+ const lost = !ex.touched && frameEnd - ex.startFrame > sampleRate * 0.1;
419441 if (done || lost) {
420442 if (!ex.silent) this.port.postMessage({
421- type: "hitDone", id: ex.id, peakForce: ex.peakForce, contactTime: ex.contactSamples * dt, rebound: ex.vs,
422- newtonIters: ex.newtonIters, contactSamples: ex.contactSamples,
443+ type: "hitDone", id: ex.id, peakForce: ex.peakForce, contactTime: ex.contactSamples * dt,
444+ rebound: ex.vs, newtonIters: ex.newtonIters, contactSamples: ex.contactSamples,
423445 });
424446 return false;
425447 }
tests/smoke.tsmodified+47−0View file
@@ -10,6 +10,7 @@ import { ALL_FIELDS, getValue, setValue } from "../src/design.ts";
1010 import { exportYaml } from "../src/hits.ts";
1111 import { parseModes, serializeModes, type ModesData } from "../src/modes.ts";
1212 import { buildHitPatch } from "../src/patch.ts";
13+import { gainArray, hitMessages } from "../src/render.ts";
1314 import { computeModes } from "../src/precompute/pipeline.ts";
1415
1516 const base = parseConfigYaml(PRESETS[0].yaml).config!;
@@ -127,3 +128,49 @@ test("the exported configuration is valid cymsim input", () => {
127128 assert.ok(Math.abs(back.numerics.duration - 3.45) < 1e-6, "the duration covers the tail");
128129 assert.equal(back.output.prefix, "smoke");
129130 });
131+
132+test("a recording schedules its hits at the right frames", async () => {
133+ const g = globalThis as unknown as Record<string, unknown>;
134+ const sent: Record<string, unknown>[] = [];
135+ g.sampleRate = 44100;
136+ g.currentFrame = 0;
137+ let Proc: new (o?: unknown) => { port: { onmessage: ((ev: { data: unknown }) => void) | null }; process(i: Float32Array[][], o: Float32Array[][]): boolean };
138+ g.AudioWorkletProcessor = class {
139+ port = { onmessage: null, postMessage: (m: Record<string, unknown>) => sent.push(m) };
140+ };
141+ g.registerProcessor = (_n: string, ctor: typeof Proc) => { Proc = ctor; };
142+ const mod = await import("../src/worklet.ts?scheduled");
143+ void mod;
144+
145+ const der = derive(modes, config);
146+ const ex = { kind: "stick" as const, mass: 0.03, k: 5e8, alpha: 1.5, patch_length: 0.03, patch_points: 7, patch_angle: 0.6, gap: 1e-4 };
147+ const recorded = [
148+ { id: 1, time: 4.00, r: 0.17, phi: 0.3, velocity: 7, exciter: ex, source: "Q" },
149+ { id: 2, time: 4.25, r: 0.12, phi: 2.0, velocity: 5, exciter: ex, source: "A" },
150+ ];
151+ // the release frames are relative to the first hit, offset by the base
152+ const base = 1000;
153+ const { messages, span } = hitMessages(modes, der, recorded, 44100, base, () => 7);
154+ assert.equal(messages.length, 2);
155+ assert.equal(messages[0].atFrame, base);
156+ assert.equal(messages[1].atFrame, base + Math.round(0.25 * 44100));
157+ assert.ok(Math.abs(span - 0.25) < 1e-9);
158+
159+ // played through the processor, the exciters are released at those frames, not before
160+ const proc = new Proc!();
161+ proc.port.onmessage!({ data: { type: "init", freq: der.freq, sigma: der.sigma, gain: gainArray(modes, true) } });
162+ for (const m of messages) proc.port.onmessage!({ data: { ...m, silent: false } });
163+ const B = 128;
164+ for (let b = 0; b < Math.ceil(0.4 * 44100 / B); b++) {
165+ const out = [[new Float32Array(B)]];
166+ g.currentFrame = b * B;
167+ proc.process([], out);
168+ for (const x of out[0][0]) assert.ok(Number.isFinite(x));
169+ }
170+ const started = sent.filter((m) => m.type === "hitStarted") as { id: number; frame: number }[];
171+ assert.equal(started.length, 2, "both hits should be released");
172+ for (const s of started) {
173+ const want = messages.find((m) => m.id === s.id)!.atFrame as number;
174+ assert.ok(Math.abs(s.frame - want) <= 1, `hit ${s.id} released at ${s.frame}, wanted ${want}`);
175+ }
176+});