1import { PRESETS } from "./config/presets.ts";
2import { defaultMallet, defaultStick, type Exciter, type SimulationConfig } from "./config/schema.ts";
3import { cloneConfig, configToYaml, parseConfigYaml, validateConfig } from "./config/yaml.ts";
4import { cacheClear, cacheGet, cacheKey, cachePut } from "./cache.ts";
5import { derive, gainState, storedGainState, type Derived } from "./derived.ts";
6import { DesignPanel, type Field, type Tier } from "./design.ts";
7import { encodeWav, exportYaml, type LoggedHit } from "./hits.ts";
8import { parseModes, type ModesData } from "./modes.ts";
9import { buildHitPatch, patchExtent } from "./patch.ts";
10import { structuralKey } from "./precompute/pipeline.ts";
11import type { PrecomputeReply, PrecomputeRequest } from "./precompute/worker.ts";
12import { gainArray, hitMessages, renderRecording } from "./render.ts";
13import { CymbalView, type Spot } from "./view.ts";
14import workletUrl from "./worklet.ts?worker&url";
16const CACHE_VERSION = 2;
17/** Preview lift for the mallet: in linear theory it is about 18 dB below the stick (measured),
18 * which makes it nearly inaudible here although it is the louder of the two once the nonlinear
19 * cascade is present. It is applied to the output gain only, never to the recorded hits. */
20const MALLET_PREVIEW_GAIN = 8;
21/** Roughly how long the eigensolve takes at each mesh size, for the invitation to compute. */
22const 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};
27// ---------------------------------------------------------------- DOM
28const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
29const readyAction = $<HTMLButtonElement>("ready-action"), readyText = $<HTMLDivElement>("ready-text");
30const progressBar = $<HTMLDivElement>("progress-bar");
31const presetSel = $<HTMLSelectElement>("preset"), yamlFile = $<HTMLInputElement>("yamlfile");
32const revertBtn = $<HTMLButtonElement>("revert"), designYamlBtn = $<HTMLButtonElement>("download-design-yaml");
33const designBox = $<HTMLDivElement>("design"), designStatus = $<HTMLDivElement>("design-status");
34const setupBox = $<HTMLDetailsElement>("setup");
35const esizeSel = $<HTMLSelectElement>("esize"), fmaxInput = $<HTMLInputElement>("fmax");
36const computeBtn = $<HTMLButtonElement>("compute"), downloadModesBtn = $<HTMLButtonElement>("download-modes");
37const modesFile = $<HTMLInputElement>("modesfile"), clearCacheBtn = $<HTMLButtonElement>("clear-cache");
38const statusEl = $<HTMLDivElement>("status");
39const exciterSel = $<HTMLSelectElement>("exciter"), velocityInput = $<HTMLInputElement>("velocity");
40const velocityValue = $<HTMLSpanElement>("velocity-value"), resetWeightsBtn = $<HTMLButtonElement>("reset-weights");
41const volumeInput = $<HTMLInputElement>("volume"), signalSel = $<HTMLSelectElement>("signal");
42const pointsSel = $<HTMLSelectElement>("points"), previewGainBox = $<HTMLInputElement>("preview-gain");
43const audioInfo = $<HTMLDivElement>("audio-info");
44const recordToggle = $<HTMLButtonElement>("record-toggle"), playHitsBtn = $<HTMLButtonElement>("play-hits");
45const stopHitsBtn = $<HTMLButtonElement>("stop-hits"), clearHitsBtn = $<HTMLButtonElement>("clear-hits");
46const downloadYamlBtn = $<HTMLButtonElement>("download-yaml"), downloadWavBtn = $<HTMLButtonElement>("download-wav");
47const leadinInput = $<HTMLInputElement>("leadin"), tailInput = $<HTMLInputElement>("tail"), prefixInput = $<HTMLInputElement>("prefix");
48const recordStatus = $<HTMLDivElement>("record-status");
49const hitsBody = document.querySelector<HTMLTableSectionElement>("#hits tbody")!;
50const keyhelp = $<HTMLDivElement>("keyhelp");
52// ---------------------------------------------------------------- state
53let presetYaml = PRESETS[0].yaml;
54let config: SimulationConfig = parseConfigYaml(presetYaml).config!;
55let modes: ModesData | null = null;
56let modesBuffer: ArrayBuffer | null = null;
57let modesStructural = "";
58let derived: Derived | null = null;
59let ctx: AudioContext | null = null;
60let node: AudioWorkletNode | null = null;
61let gainNode: GainNode | null = null;
62let worker: Worker | null = null;
63let workerHasModes = false;
64let token = 0;
65let computeToken = -1;
66let computing = false;
67let computeStart = 0;
68let gainToken = -1;
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. */
71let gainsFor = "";
72let gainsPending = "";
73let gainTimer = 0;
74let hits: LoggedHit[] = [];
75let nextId = 1;
76let recording = false;
77let recordStart = 0;
78let playing = false;
79let playTimer = 0;
80let rendering = false;
81let workletLoad = 0;
82const view = new CymbalView($<HTMLCanvasElement>("cymbal"));
84// ---------------------------------------------------------------- key map: the rows are radii
85const ROW_KEYS = [
86 ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
87 ["A", "S", "D", "F", "G", "H", "J", "K", "L", ";"],
88 ["Z", "X", "C", "V", "B", "N", "M", ",", ".", "/"],
89];
90const ROW_CODES = [
91 ["KeyQ", "KeyW", "KeyE", "KeyR", "KeyT", "KeyY", "KeyU", "KeyI", "KeyO", "KeyP"],
92 ["KeyA", "KeyS", "KeyD", "KeyF", "KeyG", "KeyH", "KeyJ", "KeyK", "KeyL", "Semicolon"],
93 ["KeyZ", "KeyX", "KeyC", "KeyV", "KeyB", "KeyN", "KeyM", "Comma", "Period", "Slash"],
94];
95const ROW_NAMES = ["inner bow (0.42 R), farthest from you", "mid bow (0.64 R)", "near the rim (0.86 R), closest to you"];
96const ROW_FRACTIONS = [0.42, 0.64, 0.86];
97const WEIGHT_STEP = 1.25, WEIGHT_MIN = 0.25, WEIGHT_MAX = 4;
98const WEIGHTS_KEY = "cymplay.weights";
100/** How hard each position is struck, relative to the master level. A position keeps its weight
101 * across sessions, so a kit of accents and ghost notes stays where you put it. */
102const weights = new Map<string, number>(loadWeights());
104function loadWeights(): [string, number][] {
105 try {
106 const raw = localStorage.getItem(WEIGHTS_KEY);
107 if (raw) return Object.entries(JSON.parse(raw) as Record<string, number>);
108 } catch {
109 /* a blocked or empty store just means every position starts at 1 */
110 }
111 return [];
112}
113function saveWeights() {
114 try {
115 localStorage.setItem(WEIGHTS_KEY, JSON.stringify(Object.fromEntries(weights)));
116 } catch {
117 /* not being able to remember the weights is not worth interrupting play */
118 }
119}
120function weightOf(code: string): number {
121 return weights.get(code) ?? 1;
122}
123function velocityFor(weight: number): number {
124 return Math.min(16, Math.max(0.3, weight * Number(velocityInput.value)));
125}
127/** The three key rows are three concentric arcs across the near half of the cymbal, laid out as
128 * the keyboard is: the bottom row is the edge closest to the player and the top row the inner
129 * bow, while a row reads left to right from the left of the cymbal, round the near side, to the
130 * right. Pressing Q, A and Z in turn therefore walks outwards along one ray. */
131function spots(): Spot[] {
132 const R = config.profile.radius;
133 const out: Spot[] = [];
134 ROW_CODES.forEach((codes, row) => {
135 const n = codes.length;
136 for (let k = 0; k < n; k++) {
137 const code = codes[k], weight = weightOf(code);
138 out.push({ code, label: ROW_KEYS[row][k], r: ROW_FRACTIONS[row] * R, phi: -Math.PI + (Math.PI * k) / (n - 1),
139 row, weight, velocity: velocityFor(weight) });
140 }
141 });
142 const bell = weightOf("Space");
143 out.push({ code: "Space", label: "sp", r: 0.6 * config.profile.bell_shoulder_radius, phi: -Math.PI / 2, row: 3,
144 weight: bell, velocity: velocityFor(bell) });
145 return out;
146}
147const spotByCode = new Map<string, Spot>();
149function refreshSpots() {
150 const s = spots();
151 spotByCode.clear();
152 for (const sp of s) spotByCode.set(sp.code, sp);
153 view.setProfile(config.profile, s);
154 keyhelp.textContent =
155 ROW_KEYS.map((keys, i) => `${keys.join(" ")} ${ROW_NAMES[i]}`).join("\n") +
156 "\nspace the bell" +
157 "\n1-9 set the master level, [ ] nudge it; each position shows the velocity it will be struck at" +
158 "\nscroll the wheel over a position to make it harder or softer; click the cymbal to strike anywhere";
159}
161function exciter(): Exciter {
162 const kind = exciterSel.value as "stick" | "mallet";
163 for (const h of config.hits) if (h.exciter.kind === kind) return structuredClone(h.exciter);
164 return kind === "stick" ? defaultStick() : defaultMallet();
165}
166function maxPointsFor(kind: "stick" | "mallet"): number {
167 const mode = pointsSel.value;
168 return mode === "full" ? 7 : kind === "mallet" ? 1 : mode === "cheap" ? 3 : 7;
169}
171// ---------------------------------------------------------------- status helpers
172function setStatus(text: string) {
173 statusEl.textContent = text;
174}
175function setProgress(frac: number) {
176 progressBar.style.width = `${Math.round(100 * Math.min(1, Math.max(0, frac)))}%`;
177}
178function download(blob: Blob, name: string) {
179 const a = document.createElement("a");
180 a.href = URL.createObjectURL(blob);
181 a.download = name;
182 a.click();
183 setTimeout(() => URL.revokeObjectURL(a.href), 10000);
184}
185function updateAudioInfo() {
186 if (!ctx) {
187 audioInfo.textContent = "audio not started";
188 return;
189 }
190 const out = (ctx as AudioContext & { outputLatency?: number }).outputLatency ?? 0;
191 audioInfo.textContent =
192 `${ctx.sampleRate} Hz, base latency ${(ctx.baseLatency * 1e3).toFixed(1)} ms, output latency ${(out * 1e3).toFixed(1)} ms, ` +
193 `worklet load ${(100 * workletLoad).toFixed(0)} %, state ${ctx.state}`;
194}
195function forgetModes() {
196 modes = null;
197 modesBuffer = null;
198 derived = null;
199 downloadModesBtn.disabled = true;
200 updateReady();
201}
203// ---------------------------------------------------------------- the guided bar
204function stale(): boolean {
205 return !!modes && modesStructural !== structuralString(config);
206}
208function 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);
244}
246// ---------------------------------------------------------------- modes
247function options() {
248 return { elementSize: Number(esizeSel.value), fmax: Number(fmaxInput.value) };
249}
250function structuralString(cfg: SimulationConfig) {
251 return JSON.stringify({ v: CACHE_VERSION, ...structuralKey(cfg, options()) });
252}
253async function currentKey() {
254 return cacheKey(structuralString(config));
255}
257function describeModes(label: string) {
258 if (!modes || !derived) return;
259 const issues = validateConfig(config);
260 setStatus(
261 `${label}: ${modes.nNodes} nodes, ${modes.nModes} modes, ` +
262 `${derived.freq[0].toFixed(1)} Hz to ${derived.freq[modes.nModes - 1].toFixed(0)} Hz, ` +
263 `playback mesh ${(modes.meta.playback_element_size * 1e3).toFixed(0)} mm` +
264 (stale() ? "\nthe shape has changed since these were computed" : ""),
265 );
266 const top = derived.freq[modes.nModes - 1];
267 designStatus.textContent =
268 `T60 ${derived.t60Low.toFixed(2)} s at ${derived.freq[0].toFixed(0)} Hz, ` +
269 `${(1e3 * derived.t60High).toFixed(0)} ms at ${(top / 1e3).toFixed(1)} kHz` +
270 (derived.overdamped ? `; ${derived.overdamped} modes clamped at critical damping` : "") +
271 // a softer or denser material lowers every frequency, so the band above the new top mode is
272 // empty until the modes are computed again with the new material
273 (top < 0.9 * modes.meta.fmax ? `\nthe material has pulled the top mode down to ${(top / 1e3).toFixed(1)} kHz; recompute to fill the band again` : "") +
274 (issues.length ? "\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n") : "");
275 designStatus.className = "status" + (issues.length ? " bad" : "");
276}
278function setModes(buffer: ArrayBuffer, label: string, structural: string) {
279 let parsed: ModesData;
280 try {
281 parsed = parseModes(buffer);
282 } catch (err) {
283 setStatus(`could not read the modes: ${(err as Error).message}`);
284 return;
285 }
286 modes = parsed;
287 modesBuffer = buffer;
288 modesStructural = structural;
289 downloadModesBtn.disabled = false;
290 derived = derive(parsed, config);
291 gainsFor = storedGainState(parsed);
292 sendInit();
293 describeModes(label);
294 if (gainsFor !== gainState(config)) requestGains(0);
295 updateReady();
296 updateRecordUi();
297}
299async function tryCache() {
300 if (modes) {
301 describeModes("modes");
302 updateReady();
303 return;
304 }
305 const buf = await cacheGet(await currentKey());
306 if (buf) setModes(buf, "cached modes", structuralString(config));
307 else setStatus("no modes for this cymbal yet");
308 updateReady();
309}
311function ensureWorker(): Worker {
312 if (!worker) {
313 worker = new Worker(new URL("./precompute/worker.ts", import.meta.url), { type: "module" });
314 worker.onmessage = (ev: MessageEvent<PrecomputeReply>) => void onWorkerMessage(ev.data);
315 worker.onerror = (e) => setStatus(`worker error: ${e.message}`);
316 workerHasModes = false;
317 }
318 return worker;
319}
321function compute() {
322 const issues = validateConfig(config);
323 if (issues.length) {
324 setStatus("the design has problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
325 return;
326 }
327 computing = true;
328 computeBtn.disabled = true;
329 computeStart = performance.now();
330 computeToken = ++token;
331 setProgress(0);
332 setStatus("computing the modes...");
333 setupBox.open = true;
334 updateReady();
335 const req: PrecomputeRequest = { type: "modes", token: computeToken, config: cloneConfig(config), options: options() };
336 ensureWorker().postMessage(req);
337}
339function requestGains(delayMs = 250) {
340 if (!modes || !derived || !modesBuffer) return;
341 clearTimeout(gainTimer);
342 gainTimer = window.setTimeout(() => {
343 if (!modes || !derived || !modesBuffer) return;
344 gainsPending = gainState(config);
345 if (gainsPending === gainsFor) return;
346 gainToken = ++token;
347 const req: PrecomputeRequest = {
348 type: "gains", token: gainToken, listener: structuredClone(config.listener),
349 freq: derived.freq, phiFactor: derived.phiFactor,
350 ...(workerHasModes ? {} : { buffer: modesBuffer }),
351 };
352 workerHasModes = true;
353 ensureWorker().postMessage(req);
354 }, delayMs);
355}
357async function onWorkerMessage(msg: PrecomputeReply) {
358 if (msg.type === "progress") {
359 if (msg.token !== computeToken) return;
360 setProgress(msg.frac);
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.`;
364 } else if (msg.type === "modes") {
365 if (msg.token !== computeToken) return;
366 computing = false;
367 computeBtn.disabled = false;
368 setProgress(1);
369 const structural = structuralString(config);
370 await cachePut(await cacheKey(structural), msg.buffer);
371 setModes(msg.buffer, `computed in ${msg.seconds.toFixed(1)} s`, structural);
372 workerHasModes = true; // the worker kept its own copy
373 } else if (msg.type === "gains") {
374 if (msg.token !== gainToken || !modes) return;
375 modes.gainFront = msg.front;
376 modes.gainBack = msg.back;
377 gainsFor = gainsPending;
378 sendGain();
379 } else {
380 computing = false;
381 computeBtn.disabled = false;
382 setStatus(`error: ${msg.message}`);
383 updateReady();
384 }
385}
387// ---------------------------------------------------------------- audio
388function sendInit() {
389 if (!node || !modes || !derived) return;
390 node.port.postMessage({ type: "init", freq: derived.freq, sigma: derived.sigma, gain: gainArray(modes, signalSel.value === "unbaffled") });
391}
392function sendParams() {
393 if (!node || !derived) return;
394 node.port.postMessage({ type: "params", freq: derived.freq, sigma: derived.sigma });
395}
396function sendGain() {
397 if (!node || !modes) return;
398 node.port.postMessage({ type: "gain", gain: gainArray(modes, signalSel.value === "unbaffled") });
399}
401async function startAudio() {
402 if (ctx) {
403 await ctx.resume();
404 updateAudioInfo();
405 updateReady();
406 return;
407 }
408 ctx = new AudioContext({ latencyHint: "interactive" });
409 await ctx.audioWorklet.addModule(workletUrl);
410 node = new AudioWorkletNode(ctx, "cymbal-modal", { numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1] });
411 gainNode = ctx.createGain();
412 setVolume();
413 node.connect(gainNode).connect(ctx.destination);
414 node.port.onmessage = (ev) => onWorkletMessage(ev.data);
415 node.onprocessorerror = () => setStatus("the audio processor stopped: reload the page (details in the browser console)");
416 await ctx.resume();
417 sendInit();
418 updateAudioInfo();
419 updateReady();
420 setInterval(updateAudioInfo, 1000);
421}
423function setVolume() {
424 if (!gainNode || !ctx) return;
425 const lift = exciterSel.value === "mallet" && previewGainBox.checked ? MALLET_PREVIEW_GAIN : 1;
426 gainNode.gain.setTargetAtTime(0.15 * Number(volumeInput.value) * lift, ctx.currentTime, 0.02);
427}
429interface WorkletMessage {
430 type: string;
431 id?: number;
432 time?: number;
433 peakForce?: number;
434 contactTime?: number;
435 rebound?: number;
436 mean?: number;
437}
439function onWorkletMessage(msg: WorkletMessage) {
440 if (msg.type === "hitStarted") {
441 const h = hits.find((x) => x.id === msg.id);
442 if (h) h.time = msg.time!;
443 renderHits();
444 } else if (msg.type === "hitDone") {
445 const h = hits.find((x) => x.id === msg.id);
446 if (h) {
447 h.peakForce = msg.peakForce;
448 h.contactTime = msg.contactTime;
449 h.rebound = msg.rebound;
450 }
451 renderHits();
452 } else if (msg.type === "load") {
453 workletLoad = msg.mean ?? 0;
454 }
455}
457// ---------------------------------------------------------------- hits
458function strike(r: number, phi: number, velocity: number, source: string) {
459 if (!modes || !derived) {
460 setStatus("compute the modes first");
461 return;
462 }
463 if (!node || !ctx) return;
464 const ex = exciter();
465 const R = config.profile.radius, es = modes.meta.playback_element_size;
466 if (r + patchExtent(ex) >= R - es) {
467 setStatus(`hit at r = ${r.toFixed(3)} m: the contact patch reaches the rim, ignored`);
468 return;
469 }
470 const patch = buildHitPatch(modes, ex, r, phi, velocity, maxPointsFor(ex.kind), derived.phiFactor);
471 if (!patch) {
472 setStatus("that point is outside the mesh, ignored");
473 return;
474 }
475 const logged = recording;
476 const id = nextId++;
477 node.port.postMessage(
478 {
479 type: "hit", id, phi: patch.phi, offsets: patch.offsets, kPoint: patch.kPoint, alpha: patch.alpha,
480 mass: patch.mass, gap: patch.gap, velocity, silent: !logged,
481 },
482 [patch.phi.buffer, patch.offsets.buffer],
483 );
484 if (logged) {
485 hits.push({ id, time: ctx.currentTime - recordStart, r, phi, velocity, exciter: ex, source });
486 renderHits();
487 updateRecordUi();
488 updateReady();
489 }
490 view.flash(r * Math.cos(phi), r * Math.sin(phi), velocity, ex.kind);
491}
493function renderHits() {
494 hitsBody.innerHTML = "";
495 const t0 = hits.length ? Math.min(...hits.map((h) => h.time)) : 0;
496 hits.forEach((h, i) => {
497 const tr = document.createElement("tr");
498 for (const c of [
499 String(i + 1), (h.time - t0).toFixed(3), h.source, h.exciter.kind, h.r.toFixed(3),
500 h.phi.toFixed(3), h.velocity.toFixed(2), h.peakForce !== undefined ? h.peakForce.toFixed(0) : "",
501 h.contactTime !== undefined ? (h.contactTime * 1e3).toFixed(2) : "",
502 ]) {
503 const td = document.createElement("td");
504 td.textContent = c;
505 tr.appendChild(td);
506 }
507 hitsBody.appendChild(tr);
508 });
509}
511function 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}
534function 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}
549function 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}
567function stopPlayback() {
568 clearTimeout(playTimer);
569 playing = false;
570 node?.port.postMessage({ type: "reset" });
571 updateRecordUi();
572 updateReady();
573}
575async 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 }
592}
594// ---------------------------------------------------------------- design panel
595const panel = new DesignPanel(designBox, () => config, onDesignChange);
597function onDesignChange(_field: Field, tier: Tier) {
598 refreshSpots();
599 if (modes && tier === "live") {
600 derived = derive(modes, config);
601 sendParams();
602 requestGains();
603 }
604 describeModes("modes");
605 updateReady();
606}
608function setConfig(cfg: SimulationConfig, label: string) {
609 config = cfg;
610 hits = [];
611 recording = false;
612 renderHits();
613 panel.refresh();
614 refreshSpots();
615 forgetModes();
616 updateRecordUi();
617 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}`);
618 void tryCache();
619}
621// ---------------------------------------------------------------- wiring
622for (const p of PRESETS) {
623 const o = document.createElement("option");
624 o.value = p.id;
625 o.textContent = p.name;
626 presetSel.appendChild(o);
627}
628const customOpt = document.createElement("option");
629customOpt.value = "custom";
630customOpt.textContent = "loaded YAML";
631customOpt.disabled = true;
632presetSel.appendChild(customOpt);
634readyAction.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});
648presetSel.addEventListener("change", () => {
649 const p = PRESETS.find((x) => x.id === presetSel.value);
650 if (p) {
651 presetYaml = p.yaml;
652 setConfig(parseConfigYaml(p.yaml).config!, p.name);
653 }
654});
655yamlFile.addEventListener("change", async () => {
656 const file = yamlFile.files?.[0];
657 if (!file) return;
658 const text = await file.text();
659 const { config: cfg, issues } = parseConfigYaml(text);
660 if (!cfg) {
661 setStatus("YAML problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
662 return;
663 }
664 presetYaml = text;
665 customOpt.disabled = false;
666 presetSel.value = "custom";
667 setConfig(cfg, file.name);
668});
669for (const el of [esizeSel, fmaxInput]) {
670 el.addEventListener("change", () => {
671 forgetModes();
672 void tryCache();
673 });
674}
675computeBtn.addEventListener("click", compute);
676downloadModesBtn.addEventListener("click", () => {
677 if (modesBuffer) download(new Blob([modesBuffer]), `cymplay_modes_${(Number(esizeSel.value) * 1e3).toFixed(0)}mm.bin`);
678});
679modesFile.addEventListener("change", async () => {
680 const file = modesFile.files?.[0];
681 if (!file) return;
682 const buf = await file.arrayBuffer();
683 let loaded: ModesData;
684 try {
685 loaded = parseModes(buf);
686 } catch (err) {
687 setStatus(`could not load the modes: ${(err as Error).message}`);
688 return;
689 }
690 const { config: cfg } = parseConfigYaml(JSON.stringify(loaded.meta.config));
691 if (cfg) {
692 customOpt.disabled = false;
693 presetSel.value = "custom";
694 config = cfg;
695 esizeSel.value = String(loaded.meta.playback_element_size);
696 fmaxInput.value = String(loaded.meta.fmax);
697 panel.refresh();
698 refreshSpots();
699 }
700 workerHasModes = false;
701 setModes(buf, file.name, structuralString(config));
702});
703clearCacheBtn.addEventListener("click", async () => {
704 await cacheClear();
705 setStatus("the cache is empty");
706});
707revertBtn.addEventListener("click", () => setConfig(parseConfigYaml(presetYaml).config!, "reverted"));
708designYamlBtn.addEventListener("click", () => {
709 const cfg = cloneConfig(config);
710 cfg.output.prefix = prefixInput.value || "cymplay";
711 download(new Blob([configToYaml(cfg)], { type: "text/yaml" }), `${cfg.output.prefix}_design.yaml`);
712});
713volumeInput.addEventListener("input", setVolume);
714previewGainBox.addEventListener("change", setVolume);
715exciterSel.addEventListener("change", setVolume);
716signalSel.addEventListener("change", sendGain);
717velocityInput.addEventListener("input", () => {
718 velocityValue.textContent = `${Number(velocityInput.value).toFixed(1)} m/s`;
719 refreshSpots();
720});
721resetWeightsBtn.addEventListener("click", () => {
722 weights.clear();
723 saveWeights();
724 refreshSpots();
725});
726recordToggle.addEventListener("click", toggleRecording);
727playHitsBtn.addEventListener("click", playRecording);
728stopHitsBtn.addEventListener("click", stopPlayback);
729clearHitsBtn.addEventListener("click", () => {
730 hits = [];
731 nextId = 1;
732 renderHits();
733 updateRecordUi();
734 updateReady();
735});
736downloadYamlBtn.addEventListener("click", () => {
737 const text = exportYaml(config, hits, { leadIn: Number(leadinInput.value), tail: Number(tailInput.value), prefix: prefixInput.value || "cymplay" });
738 download(new Blob([text], { type: "text/yaml" }), `${prefixInput.value || "cymplay"}.yaml`);
739});
740downloadWavBtn.addEventListener("click", () => void downloadWav());
742function setLevel(v: number) {
743 velocityInput.value = String(Math.min(16, Math.max(0.3, v)));
744 velocityInput.dispatchEvent(new Event("input"));
745}
747view.onStrike = (x, y) => strike(Math.hypot(x, y), Math.atan2(y, x), velocityFor(1), "click");
748view.onWeigh = (spot, steps) => {
749 const w = Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, weightOf(spot.code) * Math.pow(WEIGHT_STEP, steps)));
750 if (Math.abs(w - 1) < 1e-3) weights.delete(spot.code);
751 else weights.set(spot.code, w);
752 saveWeights();
753 refreshSpots();
754};
756window.addEventListener("keydown", (ev) => {
757 const t = ev.target as HTMLElement;
758 if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA")) return;
759 if (ev.repeat || ev.ctrlKey || ev.metaKey) return;
760 const digit = /^Digit([1-9])$/.exec(ev.code);
761 if (digit) {
762 setLevel(1.25 * Number(digit[1]));
763 return;
764 }
765 if (ev.code === "BracketLeft" || ev.code === "BracketRight") {
766 setLevel(Number(velocityInput.value) + (ev.code === "BracketRight" ? 0.5 : -0.5));
767 return;
768 }
769 const s = spotByCode.get(ev.code);
770 if (s) {
771 ev.preventDefault();
772 strike(s.r, s.phi, s.velocity, s.label);
773 }
774});
776panel.refresh();
777refreshSpots();
778velocityValue.textContent = `${Number(velocityInput.value).toFixed(1)} m/s`;
779updateRecordUi();
780updateReady();
781void tryCache();