/ proof-of-concept / cymplay
Sign in
proof-of-concept / cymplay
cymplay / src / main.ts
633 lines · 24.2 KBBlameHistoryRaw
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 { CymbalView, type Spot } from "./view.ts";
13import workletUrl from "./worklet.ts?worker&url";
15const CACHE_VERSION = 2;
16/** Preview lift for the mallet: in linear theory it is about 18 dB below the stick (measured),
17 * which makes it nearly inaudible here although it is the louder of the two once the nonlinear
18 * cascade is present. It is applied to the output gain only, never to the recorded hits. */
19const MALLET_PREVIEW_GAIN = 8;
21// ---------------------------------------------------------------- DOM
22const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
23const presetSel = $<HTMLSelectElement>("preset"), yamlFile = $<HTMLInputElement>("yamlfile");
24const esizeSel = $<HTMLSelectElement>("esize"), fmaxInput = $<HTMLInputElement>("fmax");
25const computeBtn = $<HTMLButtonElement>("compute"), downloadModesBtn = $<HTMLButtonElement>("download-modes");
26const modesFile = $<HTMLInputElement>("modesfile"), clearCacheBtn = $<HTMLButtonElement>("clear-cache");
27const progressBar = $<HTMLDivElement>("progress-bar"), statusEl = $<HTMLDivElement>("status");
28const designBox = $<HTMLDivElement>("design"), designStatus = $<HTMLDivElement>("design-status");
29const revertBtn = $<HTMLButtonElement>("revert"), designYamlBtn = $<HTMLButtonElement>("download-design-yaml");
30const audioStartBtn = $<HTMLButtonElement>("audio-start"), volumeInput = $<HTMLInputElement>("volume");
31const signalSel = $<HTMLSelectElement>("signal"), velocityInput = $<HTMLInputElement>("velocity");
32const velocityValue = $<HTMLSpanElement>("velocity-value"), exciterSel = $<HTMLSelectElement>("exciter");
33const pointsSel = $<HTMLSelectElement>("points"), previewGainBox = $<HTMLInputElement>("preview-gain");
34const audioInfo = $<HTMLDivElement>("audio-info"), resetWeightsBtn = $<HTMLButtonElement>("reset-weights");
35const clearHitsBtn = $<HTMLButtonElement>("clear-hits"), downloadYamlBtn = $<HTMLButtonElement>("download-yaml");
36const captureBtn = $<HTMLButtonElement>("capture"), downloadWavBtn = $<HTMLButtonElement>("download-wav");
37const leadinInput = $<HTMLInputElement>("leadin"), tailInput = $<HTMLInputElement>("tail"), prefixInput = $<HTMLInputElement>("prefix");
38const hitsBody = document.querySelector<HTMLTableSectionElement>("#hits tbody")!;
39const keyhelp = $<HTMLDivElement>("keyhelp");
41// ---------------------------------------------------------------- state
42let presetYaml = PRESETS[0].yaml;
43let config: SimulationConfig = parseConfigYaml(presetYaml).config!;
44let modes: ModesData | null = null;
45let modesBuffer: ArrayBuffer | null = null;
46let modesStructural = "";
47let derived: Derived | null = null;
48let ctx: AudioContext | null = null;
49let node: AudioWorkletNode | null = null;
50let gainNode: GainNode | null = null;
51let worker: Worker | null = null;
52let workerHasModes = false;
53let token = 0;
54let computeToken = -1;
55let computeStart = 0;
56let 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. */
59let gainsFor = "";
60let gainsPending = "";
61let gainTimer = 0;
62let hits: LoggedHit[] = [];
63let nextId = 1;
64let capturing = false;
65let captured: Float32Array | null = null;
66let workletLoad = 0;
67const view = new CymbalView($<HTMLCanvasElement>("cymbal"));
69// ---------------------------------------------------------------- key map: the rows are radii
70const ROW_KEYS = [
71 ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
72 ["A", "S", "D", "F", "G", "H", "J", "K", "L", ";"],
73 ["Z", "X", "C", "V", "B", "N", "M", ",", ".", "/"],
74];
75const ROW_CODES = [
76 ["KeyQ", "KeyW", "KeyE", "KeyR", "KeyT", "KeyY", "KeyU", "KeyI", "KeyO", "KeyP"],
77 ["KeyA", "KeyS", "KeyD", "KeyF", "KeyG", "KeyH", "KeyJ", "KeyK", "KeyL", "Semicolon"],
78 ["KeyZ", "KeyX", "KeyC", "KeyV", "KeyB", "KeyN", "KeyM", "Comma", "Period", "Slash"],
79];
80const ROW_NAMES = ["inner bow (0.42 R), farthest from you", "mid bow (0.64 R)", "near the rim (0.86 R), closest to you"];
81const ROW_FRACTIONS = [0.42, 0.64, 0.86];
82const WEIGHT_STEP = 1.25, WEIGHT_MIN = 0.25, WEIGHT_MAX = 4;
83const WEIGHTS_KEY = "cymplay.weights";
85/** How hard each position is struck, relative to the master level. A position keeps its weight
86 * across sessions, so a kit of accents and ghost notes stays where you put it. */
87const weights = new Map<string, number>(loadWeights());
89function loadWeights(): [string, number][] {
90 try {
91 const raw = localStorage.getItem(WEIGHTS_KEY);
92 if (raw) return Object.entries(JSON.parse(raw) as Record<string, number>);
93 } catch {
94 /* a blocked or empty store just means every position starts at 1 */
95 }
96 return [];
98function saveWeights() {
99 try {
100 localStorage.setItem(WEIGHTS_KEY, JSON.stringify(Object.fromEntries(weights)));
101 } catch {
102 /* not being able to remember the weights is not worth interrupting play */
103 }
105function weightOf(code: string): number {
106 return weights.get(code) ?? 1;
108function velocityFor(weight: number): number {
109 return Math.min(16, Math.max(0.3, weight * Number(velocityInput.value)));
112/** The three key rows are three concentric arcs across the near half of the cymbal, laid out as
113 * the keyboard is: the bottom row is the edge closest to the player and the top row the inner
114 * bow, while a row reads left to right from the left of the cymbal, round the near side, to the
115 * right. Pressing Q, A and Z in turn therefore walks outwards along one ray. */
116function spots(): Spot[] {
117 const R = config.profile.radius;
118 const out: Spot[] = [];
119 ROW_CODES.forEach((codes, row) => {
120 const n = codes.length;
121 for (let k = 0; k < n; k++) {
122 const code = codes[k], weight = weightOf(code);
123 out.push({ code, label: ROW_KEYS[row][k], r: ROW_FRACTIONS[row] * R, phi: -Math.PI + (Math.PI * k) / (n - 1),
124 row, weight, velocity: velocityFor(weight) });
125 }
126 });
127 const bell = weightOf("Space");
128 out.push({ code: "Space", label: "sp", r: 0.6 * config.profile.bell_shoulder_radius, phi: -Math.PI / 2, row: 3,
129 weight: bell, velocity: velocityFor(bell) });
130 return out;
132const spotByCode = new Map<string, Spot>();
134function refreshSpots() {
135 const s = spots();
136 spotByCode.clear();
137 for (const sp of s) spotByCode.set(sp.code, sp);
138 view.setProfile(config.profile, s);
139 keyhelp.textContent =
140 ROW_KEYS.map((keys, i) => `${keys.join(" ")} ${ROW_NAMES[i]}`).join("\n") +
141 "\nspace the bell" +
142 "\n1-9 set the master level, [ ] nudge it; each position shows the velocity it will be struck at" +
143 "\nscroll the wheel over a position to make it harder or softer; click the cymbal to strike anywhere";
146function exciter(): Exciter {
147 const kind = exciterSel.value as "stick" | "mallet";
148 for (const h of config.hits) if (h.exciter.kind === kind) return structuredClone(h.exciter);
149 return kind === "stick" ? defaultStick() : defaultMallet();
152// ---------------------------------------------------------------- status helpers
153function setStatus(text: string) {
154 statusEl.textContent = text;
156function setProgress(frac: number) {
157 progressBar.style.width = `${Math.round(100 * Math.min(1, Math.max(0, frac)))}%`;
159function download(blob: Blob, name: string) {
160 const a = document.createElement("a");
161 a.href = URL.createObjectURL(blob);
162 a.download = name;
163 a.click();
164 setTimeout(() => URL.revokeObjectURL(a.href), 10000);
166function updateAudioInfo() {
167 if (!ctx) {
168 audioInfo.textContent = "audio not started";
169 return;
170 }
171 const out = (ctx as AudioContext & { outputLatency?: number }).outputLatency ?? 0;
172 audioInfo.textContent =
173 `${ctx.sampleRate} Hz, base latency ${(ctx.baseLatency * 1e3).toFixed(1)} ms, output latency ${(out * 1e3).toFixed(1)} ms, ` +
174 `worklet load ${(100 * workletLoad).toFixed(0)} %, state ${ctx.state}`;
176function forgetModes() {
177 modes = null;
178 modesBuffer = null;
179 derived = null;
180 downloadModesBtn.disabled = true;
183// ---------------------------------------------------------------- modes
184function options() {
185 return { elementSize: Number(esizeSel.value), fmax: Number(fmaxInput.value) };
187function structuralString(cfg: SimulationConfig) {
188 return JSON.stringify({ v: CACHE_VERSION, ...structuralKey(cfg, options()) });
190async function currentKey() {
191 return cacheKey(structuralString(config));
194function describeModes(label: string) {
195 if (!modes || !derived) return;
196 const issues = validateConfig(config);
197 const stale = modesStructural !== structuralString(config);
198 setStatus(
199 `${label}: ${modes.nNodes} nodes, ${modes.nModes} modes, ` +
200 `${derived.freq[0].toFixed(1)} Hz to ${derived.freq[modes.nModes - 1].toFixed(0)} Hz, ` +
201 `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" : ""),
203 );
204 const top = derived.freq[modes.nModes - 1];
205 designStatus.textContent =
206 `T60 ${derived.t60Low.toFixed(2)} s at ${derived.freq[0].toFixed(0)} Hz, ` +
207 `${(1e3 * derived.t60High).toFixed(0)} ms at ${(top / 1e3).toFixed(1)} kHz` +
208 (derived.overdamped ? `; ${derived.overdamped} modes clamped at critical damping` : "") +
209 // a softer or denser material lowers every frequency, so the band above the new top mode is
210 // empty until the modes are computed again with the new material
211 (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` : "") +
212 (issues.length ? "\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n") : "");
213 designStatus.className = "status" + (issues.length ? " bad" : "");
216function setModes(buffer: ArrayBuffer, label: string, structural: string) {
217 let parsed: ModesData;
218 try {
219 parsed = parseModes(buffer);
220 } catch (err) {
221 setStatus(`could not read the modes: ${(err as Error).message}`);
222 return;
223 }
224 modes = parsed;
225 modesBuffer = buffer;
226 modesStructural = structural;
227 downloadModesBtn.disabled = false;
228 derived = derive(parsed, config);
229 gainsFor = storedGainState(parsed);
230 sendInit();
231 describeModes(label);
232 if (gainsFor !== gainState(config)) requestGains(0);
235async function tryCache() {
236 if (modes) {
237 describeModes("modes");
238 return;
239 }
240 const buf = await cacheGet(await currentKey());
241 if (buf) setModes(buf, "cached modes", structuralString(config));
242 else setStatus("no modes for this cymbal yet: press Compute modes");
245function ensureWorker(): Worker {
246 if (!worker) {
247 worker = new Worker(new URL("./precompute/worker.ts", import.meta.url), { type: "module" });
248 worker.onmessage = (ev: MessageEvent<PrecomputeReply>) => void onWorkerMessage(ev.data);
249 worker.onerror = (e) => setStatus(`worker error: ${e.message}`);
250 workerHasModes = false;
251 }
252 return worker;
255function compute() {
256 const issues = validateConfig(config);
257 if (issues.length) {
258 setStatus("the design has problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
259 return;
260 }
261 computeBtn.disabled = true;
262 computeStart = performance.now();
263 computeToken = ++token;
264 setProgress(0);
265 setStatus("computing the modes...");
266 const req: PrecomputeRequest = { type: "modes", token: computeToken, config: cloneConfig(config), options: options() };
267 ensureWorker().postMessage(req);
270function requestGains(delayMs = 250) {
271 if (!modes || !derived || !modesBuffer) return;
272 clearTimeout(gainTimer);
273 gainTimer = window.setTimeout(() => {
274 if (!modes || !derived || !modesBuffer) return;
275 gainsPending = gainState(config);
276 if (gainsPending === gainsFor) return;
277 gainToken = ++token;
278 const req: PrecomputeRequest = {
279 type: "gains", token: gainToken, listener: structuredClone(config.listener),
280 freq: derived.freq, phiFactor: derived.phiFactor,
281 ...(workerHasModes ? {} : { buffer: modesBuffer }),
282 };
283 workerHasModes = true;
284 ensureWorker().postMessage(req);
285 }, delayMs);
288async function onWorkerMessage(msg: PrecomputeReply) {
289 if (msg.type === "progress") {
290 if (msg.token !== computeToken) return;
291 setProgress(msg.frac);
292 setStatus(`${msg.phase} ${(100 * msg.frac).toFixed(0)} % [${((performance.now() - computeStart) / 1000).toFixed(0)} s]`);
293 } else if (msg.type === "modes") {
294 if (msg.token !== computeToken) return;
295 computeBtn.disabled = false;
296 setProgress(1);
297 const structural = structuralString(config);
298 await cachePut(await cacheKey(structural), msg.buffer);
299 setModes(msg.buffer, `computed in ${msg.seconds.toFixed(1)} s`, structural);
300 workerHasModes = true; // the worker kept its own copy
301 } else if (msg.type === "gains") {
302 if (msg.token !== gainToken || !modes) return;
303 modes.gainFront = msg.front;
304 modes.gainBack = msg.back;
305 gainsFor = gainsPending;
306 sendGain();
307 } else {
308 computeBtn.disabled = false;
309 setStatus(`error: ${msg.message}`);
310 }
313// ---------------------------------------------------------------- audio
314function 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;
321function sendInit() {
322 if (!node || !modes || !derived) return;
323 node.port.postMessage({ type: "init", freq: derived.freq, sigma: derived.sigma, gain: combinedGain() });
325function sendParams() {
326 if (!node || !derived) return;
327 node.port.postMessage({ type: "params", freq: derived.freq, sigma: derived.sigma });
329function sendGain() {
330 if (!node || !modes) return;
331 node.port.postMessage({ type: "gain", gain: combinedGain() });
334async function startAudio() {
335 if (ctx) {
336 await ctx.resume();
337 updateAudioInfo();
338 return;
339 }
340 ctx = new AudioContext({ latencyHint: "interactive" });
341 await ctx.audioWorklet.addModule(workletUrl);
342 node = new AudioWorkletNode(ctx, "cymbal-modal", { numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1] });
343 gainNode = ctx.createGain();
344 setVolume();
345 node.connect(gainNode).connect(ctx.destination);
346 node.port.onmessage = (ev) => onWorkletMessage(ev.data);
347 node.onprocessorerror = () => setStatus("the audio processor stopped: reload the page (details in the browser console)");
348 await ctx.resume();
349 audioStartBtn.textContent = "Audio running";
350 sendInit();
351 updateAudioInfo();
352 setInterval(updateAudioInfo, 1000);
355function setVolume() {
356 if (!gainNode || !ctx) return;
357 const lift = exciterSel.value === "mallet" && previewGainBox.checked ? MALLET_PREVIEW_GAIN : 1;
358 gainNode.gain.setTargetAtTime(0.15 * Number(volumeInput.value) * lift, ctx.currentTime, 0.02);
361interface WorkletMessage {
362 type: string;
363 id?: number;
364 time?: number;
365 peakForce?: number;
366 contactTime?: number;
367 rebound?: number;
368 mean?: number;
369 chunks?: Float32Array[];
372function onWorkletMessage(msg: WorkletMessage) {
373 if (msg.type === "hitStarted") {
374 const h = hits.find((x) => x.id === msg.id);
375 if (h) h.time = msg.time!;
376 renderHits();
377 } else if (msg.type === "hitDone") {
378 const h = hits.find((x) => x.id === msg.id);
379 if (h) {
380 h.peakForce = msg.peakForce;
381 h.contactTime = msg.contactTime;
382 h.rebound = msg.rebound;
383 }
384 renderHits();
385 } else if (msg.type === "load") {
386 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`);
399 }
402// ---------------------------------------------------------------- hits
403function 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");
410 return;
411 }
412 const ex = exciter();
413 const R = config.profile.radius, es = modes.meta.playback_element_size;
414 if (r + patchExtent(ex) >= R - es) {
415 setStatus(`hit at r = ${r.toFixed(3)} m: the contact patch reaches the rim, ignored`);
416 return;
417 }
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);
421 if (!patch) {
422 setStatus("that point is outside the mesh, ignored");
423 return;
424 }
425 const id = nextId++;
426 node.port.postMessage(
427 {
428 type: "hit", id, phi: patch.phi, offsets: patch.offsets, kPoint: patch.kPoint, alpha: patch.alpha,
429 mass: patch.mass, gap: patch.gap, velocity,
430 },
431 [patch.phi.buffer, patch.offsets.buffer],
432 );
433 hits.push({ id, time: -1, r, phi, velocity, exciter: ex, source });
434 view.flash(r * Math.cos(phi), r * Math.sin(phi), velocity, ex.kind);
435 renderHits();
438function renderHits() {
439 hitsBody.innerHTML = "";
440 const t0 = hits.find((h) => h.time >= 0)?.time ?? 0;
441 hits.forEach((h, i) => {
442 const tr = document.createElement("tr");
443 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),
445 h.phi.toFixed(3), h.velocity.toFixed(2), h.peakForce !== undefined ? h.peakForce.toFixed(0) : "",
446 h.contactTime !== undefined ? (h.contactTime * 1e3).toFixed(2) : "",
447 ]) {
448 const td = document.createElement("td");
449 td.textContent = c;
450 tr.appendChild(td);
451 }
452 hitsBody.appendChild(tr);
453 });
454 downloadYamlBtn.disabled = hits.length === 0;
457// ---------------------------------------------------------------- design panel
458const panel = new DesignPanel(designBox, () => config, onDesignChange);
460function onDesignChange(_field: Field, tier: Tier) {
461 refreshSpots();
462 if (!modes) return;
463 if (tier === "live") {
464 derived = derive(modes, config);
465 sendParams();
466 requestGains();
467 }
468 describeModes("modes");
471function setConfig(cfg: SimulationConfig, label: string) {
472 config = cfg;
473 hits = [];
474 renderHits();
475 panel.refresh();
476 refreshSpots();
477 forgetModes();
478 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}`);
479 void tryCache();
482// ---------------------------------------------------------------- wiring
483for (const p of PRESETS) {
484 const o = document.createElement("option");
485 o.value = p.id;
486 o.textContent = p.name;
487 presetSel.appendChild(o);
489const customOpt = document.createElement("option");
490customOpt.value = "custom";
491customOpt.textContent = "loaded YAML";
492customOpt.disabled = true;
493presetSel.appendChild(customOpt);
495presetSel.addEventListener("change", () => {
496 const p = PRESETS.find((x) => x.id === presetSel.value);
497 if (p) {
498 presetYaml = p.yaml;
499 setConfig(parseConfigYaml(p.yaml).config!, p.name);
500 }
501});
502yamlFile.addEventListener("change", async () => {
503 const file = yamlFile.files?.[0];
504 if (!file) return;
505 const text = await file.text();
506 const { config: cfg, issues } = parseConfigYaml(text);
507 if (!cfg) {
508 setStatus("YAML problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
509 return;
510 }
511 presetYaml = text;
512 customOpt.disabled = false;
513 presetSel.value = "custom";
514 setConfig(cfg, file.name);
515});
516for (const el of [esizeSel, fmaxInput]) {
517 el.addEventListener("change", () => {
518 forgetModes();
519 void tryCache();
520 });
522computeBtn.addEventListener("click", compute);
523downloadModesBtn.addEventListener("click", () => {
524 if (modesBuffer) download(new Blob([modesBuffer]), `cymplay_modes_${(Number(esizeSel.value) * 1e3).toFixed(0)}mm.bin`);
525});
526modesFile.addEventListener("change", async () => {
527 const file = modesFile.files?.[0];
528 if (!file) return;
529 const buf = await file.arrayBuffer();
530 let loaded: ModesData;
531 try {
532 loaded = parseModes(buf);
533 } catch (err) {
534 setStatus(`could not load the modes: ${(err as Error).message}`);
535 return;
536 }
537 const { config: cfg } = parseConfigYaml(JSON.stringify(loaded.meta.config));
538 if (cfg) {
539 customOpt.disabled = false;
540 presetSel.value = "custom";
541 config = cfg;
542 esizeSel.value = String(loaded.meta.playback_element_size);
543 fmaxInput.value = String(loaded.meta.fmax);
544 panel.refresh();
545 refreshSpots();
546 }
547 workerHasModes = false;
548 setModes(buf, file.name, structuralString(config));
549});
550clearCacheBtn.addEventListener("click", async () => {
551 await cacheClear();
552 setStatus("the cache is empty");
553});
554revertBtn.addEventListener("click", () => setConfig(parseConfigYaml(presetYaml).config!, "reverted"));
555designYamlBtn.addEventListener("click", () => {
556 const cfg = cloneConfig(config);
557 cfg.output.prefix = prefixInput.value || "cymplay";
558 download(new Blob([configToYaml(cfg)], { type: "text/yaml" }), `${cfg.output.prefix}_design.yaml`);
559});
560audioStartBtn.addEventListener("click", () => void startAudio());
561volumeInput.addEventListener("input", setVolume);
562previewGainBox.addEventListener("change", setVolume);
563exciterSel.addEventListener("change", setVolume);
564signalSel.addEventListener("change", sendGain);
565velocityInput.addEventListener("input", () => {
566 velocityValue.textContent = `${Number(velocityInput.value).toFixed(1)} m/s`;
567 refreshSpots();
568});
569resetWeightsBtn.addEventListener("click", () => {
570 weights.clear();
571 saveWeights();
572 refreshSpots();
573});
574clearHitsBtn.addEventListener("click", () => {
575 hits = [];
576 renderHits();
577 node?.port.postMessage({ type: "reset" });
578});
579downloadYamlBtn.addEventListener("click", () => {
580 const text = exportYaml(config, hits, { leadIn: Number(leadinInput.value), tail: Number(tailInput.value), prefix: prefixInput.value || "cymplay" });
581 download(new Blob([text], { type: "text/yaml" }), `${prefixInput.value || "cymplay"}.yaml`);
582});
583captureBtn.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});
592downloadWavBtn.addEventListener("click", () => {
593 if (captured && ctx) download(encodeWav(captured, ctx.sampleRate), `${prefixInput.value || "cymplay"}_linear.wav`);
594});
596function setLevel(v: number) {
597 velocityInput.value = String(Math.min(16, Math.max(0.3, v)));
598 velocityInput.dispatchEvent(new Event("input"));
601view.onStrike = (x, y) => strike(Math.hypot(x, y), Math.atan2(y, x), velocityFor(1), "click");
602view.onWeigh = (spot, steps) => {
603 const w = Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, weightOf(spot.code) * Math.pow(WEIGHT_STEP, steps)));
604 if (Math.abs(w - 1) < 1e-3) weights.delete(spot.code);
605 else weights.set(spot.code, w);
606 saveWeights();
607 refreshSpots();
608};
610window.addEventListener("keydown", (ev) => {
611 const t = ev.target as HTMLElement;
612 if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA")) return;
613 if (ev.repeat || ev.ctrlKey || ev.metaKey) return;
614 const digit = /^Digit([1-9])$/.exec(ev.code);
615 if (digit) {
616 setLevel(1.25 * Number(digit[1]));
617 return;
618 }
619 if (ev.code === "BracketLeft" || ev.code === "BracketRight") {
620 setLevel(Number(velocityInput.value) + (ev.code === "BracketRight" ? 0.5 : -0.5));
621 return;
622 }
623 const s = spotByCode.get(ev.code);
624 if (s) {
625 ev.preventDefault();
626 strike(s.r, s.phi, s.velocity, s.label);
627 }
628});
630panel.refresh();
631refreshSpots();
632velocityValue.textContent = `${Number(velocityInput.value).toFixed(1)} m/s`;
633void tryCache();
↑↓ move↵openescclose