import { PRESETS } from "./config/presets.ts"; import { defaultMallet, defaultStick, type Exciter, type SimulationConfig } from "./config/schema.ts"; import { cloneConfig, configToYaml, parseConfigYaml, validateConfig } from "./config/yaml.ts"; import { cacheClear, cacheGet, cacheKey, cachePut } from "./cache.ts"; import { derive, gainState, storedGainState, type Derived } from "./derived.ts"; import { DesignPanel, type Field, type Tier } from "./design.ts"; import { encodeWav, exportYaml, type LoggedHit } from "./hits.ts"; import { parseModes, type ModesData } from "./modes.ts"; import { buildHitPatch, patchExtent } from "./patch.ts"; import { structuralKey } from "./precompute/pipeline.ts"; import type { PrecomputeReply, PrecomputeRequest } from "./precompute/worker.ts"; import { gainArray, hitMessages, renderRecording } from "./render.ts"; import { CymbalView, type Spot } from "./view.ts"; import workletUrl from "./worklet.ts?worker&url"; const CACHE_VERSION = 2; /** Preview lift for the mallet: in linear theory it is about 18 dB below the stick (measured), * which makes it nearly inaudible here although it is the louder of the two once the nonlinear * cascade is present. It is applied to the output gain only, never to the recorded hits. */ const MALLET_PREVIEW_GAIN = 8; /** Roughly how long the eigensolve takes at each mesh size, for the invitation to compute. */ const COMPUTE_ESTIMATE: Record = { "0.015": "about 5 seconds", "0.012": "about 10 seconds", "0.010": "about 20 seconds", "0.008": "a minute or two", "0.006": "a quarter of an hour", }; // ---------------------------------------------------------------- DOM const $ = (id: string) => document.getElementById(id) as T; const readyAction = $("ready-action"), readyText = $("ready-text"); const progressBar = $("progress-bar"); const presetSel = $("preset"), yamlFile = $("yamlfile"); const revertBtn = $("revert"), designYamlBtn = $("download-design-yaml"); const designBox = $("design"), designStatus = $("design-status"); const setupBox = $("setup"); const esizeSel = $("esize"), fmaxInput = $("fmax"); const computeBtn = $("compute"), downloadModesBtn = $("download-modes"); const modesFile = $("modesfile"), clearCacheBtn = $("clear-cache"); const statusEl = $("status"); const exciterSel = $("exciter"), velocityInput = $("velocity"); const velocityValue = $("velocity-value"), resetWeightsBtn = $("reset-weights"); const volumeInput = $("volume"), signalSel = $("signal"); const pointsSel = $("points"), previewGainBox = $("preview-gain"); const audioInfo = $("audio-info"); const recordToggle = $("record-toggle"), playHitsBtn = $("play-hits"); const stopHitsBtn = $("stop-hits"), clearHitsBtn = $("clear-hits"); const downloadYamlBtn = $("download-yaml"), downloadWavBtn = $("download-wav"); const leadinInput = $("leadin"), tailInput = $("tail"), prefixInput = $("prefix"); const recordStatus = $("record-status"); const hitsBody = document.querySelector("#hits tbody")!; const keyhelp = $("keyhelp"); // ---------------------------------------------------------------- state let presetYaml = PRESETS[0].yaml; let config: SimulationConfig = parseConfigYaml(presetYaml).config!; let modes: ModesData | null = null; let modesBuffer: ArrayBuffer | null = null; let modesStructural = ""; let derived: Derived | null = null; let ctx: AudioContext | null = null; let node: AudioWorkletNode | null = null; let gainNode: GainNode | null = null; let worker: Worker | null = null; let workerHasModes = false; let token = 0; let computeToken = -1; let computing = false; let computeStart = 0; let gainToken = -1; /** The material and listener the gains in ``modes`` were computed for. The reference E0 and rho0 * in the file header must never be touched: they are what ``freq`` is relative to. */ let gainsFor = ""; let gainsPending = ""; let gainTimer = 0; let hits: LoggedHit[] = []; let nextId = 1; let recording = false; let recordStart = 0; let playing = false; let playTimer = 0; let rendering = false; let workletLoad = 0; const view = new CymbalView($("cymbal")); // ---------------------------------------------------------------- key map: the rows are radii const ROW_KEYS = [ ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"], ["A", "S", "D", "F", "G", "H", "J", "K", "L", ";"], ["Z", "X", "C", "V", "B", "N", "M", ",", ".", "/"], ]; const ROW_CODES = [ ["KeyQ", "KeyW", "KeyE", "KeyR", "KeyT", "KeyY", "KeyU", "KeyI", "KeyO", "KeyP"], ["KeyA", "KeyS", "KeyD", "KeyF", "KeyG", "KeyH", "KeyJ", "KeyK", "KeyL", "Semicolon"], ["KeyZ", "KeyX", "KeyC", "KeyV", "KeyB", "KeyN", "KeyM", "Comma", "Period", "Slash"], ]; const ROW_NAMES = ["inner bow (0.42 R), farthest from you", "mid bow (0.64 R)", "near the rim (0.86 R), closest to you"]; const ROW_FRACTIONS = [0.42, 0.64, 0.86]; const WEIGHT_STEP = 1.25, WEIGHT_MIN = 0.25, WEIGHT_MAX = 4; const WEIGHTS_KEY = "cymplay.weights"; /** How hard each position is struck, relative to the master level. A position keeps its weight * across sessions, so a kit of accents and ghost notes stays where you put it. */ const weights = new Map(loadWeights()); function loadWeights(): [string, number][] { try { const raw = localStorage.getItem(WEIGHTS_KEY); if (raw) return Object.entries(JSON.parse(raw) as Record); } catch { /* a blocked or empty store just means every position starts at 1 */ } return []; } function saveWeights() { try { localStorage.setItem(WEIGHTS_KEY, JSON.stringify(Object.fromEntries(weights))); } catch { /* not being able to remember the weights is not worth interrupting play */ } } function weightOf(code: string): number { return weights.get(code) ?? 1; } function velocityFor(weight: number): number { return Math.min(16, Math.max(0.3, weight * Number(velocityInput.value))); } /** The three key rows are three concentric arcs across the near half of the cymbal, laid out as * the keyboard is: the bottom row is the edge closest to the player and the top row the inner * bow, while a row reads left to right from the left of the cymbal, round the near side, to the * right. Pressing Q, A and Z in turn therefore walks outwards along one ray. */ function spots(): Spot[] { const R = config.profile.radius; const out: Spot[] = []; ROW_CODES.forEach((codes, row) => { const n = codes.length; for (let k = 0; k < n; k++) { const code = codes[k], weight = weightOf(code); out.push({ code, label: ROW_KEYS[row][k], r: ROW_FRACTIONS[row] * R, phi: -Math.PI + (Math.PI * k) / (n - 1), row, weight, velocity: velocityFor(weight) }); } }); const bell = weightOf("Space"); out.push({ code: "Space", label: "sp", r: 0.6 * config.profile.bell_shoulder_radius, phi: -Math.PI / 2, row: 3, weight: bell, velocity: velocityFor(bell) }); return out; } const spotByCode = new Map(); function refreshSpots() { const s = spots(); spotByCode.clear(); for (const sp of s) spotByCode.set(sp.code, sp); view.setProfile(config.profile, s); keyhelp.textContent = ROW_KEYS.map((keys, i) => `${keys.join(" ")} ${ROW_NAMES[i]}`).join("\n") + "\nspace the bell" + "\n1-9 set the master level, [ ] nudge it; each position shows the velocity it will be struck at" + "\nscroll the wheel over a position to make it harder or softer; click the cymbal to strike anywhere"; } function exciter(): Exciter { const kind = exciterSel.value as "stick" | "mallet"; for (const h of config.hits) if (h.exciter.kind === kind) return structuredClone(h.exciter); return kind === "stick" ? defaultStick() : defaultMallet(); } function maxPointsFor(kind: "stick" | "mallet"): number { const mode = pointsSel.value; return mode === "full" ? 7 : kind === "mallet" ? 1 : mode === "cheap" ? 3 : 7; } // ---------------------------------------------------------------- status helpers function setStatus(text: string) { statusEl.textContent = text; } function setProgress(frac: number) { progressBar.style.width = `${Math.round(100 * Math.min(1, Math.max(0, frac)))}%`; } function download(blob: Blob, name: string) { const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 10000); } function updateAudioInfo() { if (!ctx) { audioInfo.textContent = "audio not started"; return; } const out = (ctx as AudioContext & { outputLatency?: number }).outputLatency ?? 0; audioInfo.textContent = `${ctx.sampleRate} Hz, base latency ${(ctx.baseLatency * 1e3).toFixed(1)} ms, output latency ${(out * 1e3).toFixed(1)} ms, ` + `worklet load ${(100 * workletLoad).toFixed(0)} %, state ${ctx.state}`; } function forgetModes() { modes = null; modesBuffer = null; derived = null; downloadModesBtn.disabled = true; updateReady(); } // ---------------------------------------------------------------- the guided bar function stale(): boolean { return !!modes && modesStructural !== structuralString(config); } function updateReady() { const issues = validateConfig(config); readyAction.classList.toggle("recording", false); if (computing) { readyAction.disabled = true; readyAction.textContent = "Computing"; return; } readyAction.disabled = false; if (issues.length) { readyAction.textContent = "Compute the modes"; readyAction.disabled = true; readyText.textContent = "The cymbal cannot be built as specified:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n"); view.setHint("fix the cymbal"); return; } if (!modes || stale()) { readyAction.textContent = modes ? "Recompute the modes" : "Compute the modes and play"; readyText.textContent = modes ? "The shape changed, so the modes no longer match this cymbal." : `This cymbal has not been worked out yet. Computing its modes takes ${COMPUTE_ESTIMATE[esizeSel.value] ?? "a while"}, once, in this browser.`; view.setHint(modes ? "recompute the modes" : "press the button to compute the modes"); return; } if (!ctx || ctx.state !== "running") { readyAction.textContent = "Start the sound"; readyText.textContent = "The modes are ready. Browsers only let sound start from a button."; view.setHint("start the sound"); return; } readyAction.textContent = recording ? "Stop recording" : "Record hits"; readyAction.classList.toggle("recording", recording); readyText.textContent = recording ? `Recording: ${hits.length} hit${hits.length === 1 ? "" : "s"}. Press a letter key or click the cymbal.` : "Ready. Press a letter key or click the cymbal. Recording is optional, and off."; view.setHint(null); } // ---------------------------------------------------------------- modes function options() { return { elementSize: Number(esizeSel.value), fmax: Number(fmaxInput.value) }; } function structuralString(cfg: SimulationConfig) { return JSON.stringify({ v: CACHE_VERSION, ...structuralKey(cfg, options()) }); } async function currentKey() { return cacheKey(structuralString(config)); } function describeModes(label: string) { if (!modes || !derived) return; const issues = validateConfig(config); setStatus( `${label}: ${modes.nNodes} nodes, ${modes.nModes} modes, ` + `${derived.freq[0].toFixed(1)} Hz to ${derived.freq[modes.nModes - 1].toFixed(0)} Hz, ` + `playback mesh ${(modes.meta.playback_element_size * 1e3).toFixed(0)} mm` + (stale() ? "\nthe shape has changed since these were computed" : ""), ); const top = derived.freq[modes.nModes - 1]; designStatus.textContent = `T60 ${derived.t60Low.toFixed(2)} s at ${derived.freq[0].toFixed(0)} Hz, ` + `${(1e3 * derived.t60High).toFixed(0)} ms at ${(top / 1e3).toFixed(1)} kHz` + (derived.overdamped ? `; ${derived.overdamped} modes clamped at critical damping` : "") + // a softer or denser material lowers every frequency, so the band above the new top mode is // empty until the modes are computed again with the new material (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` : "") + (issues.length ? "\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n") : ""); designStatus.className = "status" + (issues.length ? " bad" : ""); } function setModes(buffer: ArrayBuffer, label: string, structural: string) { let parsed: ModesData; try { parsed = parseModes(buffer); } catch (err) { setStatus(`could not read the modes: ${(err as Error).message}`); return; } modes = parsed; modesBuffer = buffer; modesStructural = structural; downloadModesBtn.disabled = false; derived = derive(parsed, config); gainsFor = storedGainState(parsed); sendInit(); describeModes(label); if (gainsFor !== gainState(config)) requestGains(0); updateReady(); updateRecordUi(); } async function tryCache() { if (modes) { describeModes("modes"); updateReady(); return; } const buf = await cacheGet(await currentKey()); if (buf) setModes(buf, "cached modes", structuralString(config)); else setStatus("no modes for this cymbal yet"); updateReady(); } function ensureWorker(): Worker { if (!worker) { worker = new Worker(new URL("./precompute/worker.ts", import.meta.url), { type: "module" }); worker.onmessage = (ev: MessageEvent) => void onWorkerMessage(ev.data); worker.onerror = (e) => setStatus(`worker error: ${e.message}`); workerHasModes = false; } return worker; } function compute() { const issues = validateConfig(config); if (issues.length) { setStatus("the design has problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n")); return; } computing = true; computeBtn.disabled = true; computeStart = performance.now(); computeToken = ++token; setProgress(0); setStatus("computing the modes..."); setupBox.open = true; updateReady(); const req: PrecomputeRequest = { type: "modes", token: computeToken, config: cloneConfig(config), options: options() }; ensureWorker().postMessage(req); } function requestGains(delayMs = 250) { if (!modes || !derived || !modesBuffer) return; clearTimeout(gainTimer); gainTimer = window.setTimeout(() => { if (!modes || !derived || !modesBuffer) return; gainsPending = gainState(config); if (gainsPending === gainsFor) return; gainToken = ++token; const req: PrecomputeRequest = { type: "gains", token: gainToken, listener: structuredClone(config.listener), freq: derived.freq, phiFactor: derived.phiFactor, ...(workerHasModes ? {} : { buffer: modesBuffer }), }; workerHasModes = true; ensureWorker().postMessage(req); }, delayMs); } async function onWorkerMessage(msg: PrecomputeReply) { if (msg.type === "progress") { if (msg.token !== computeToken) return; setProgress(msg.frac); const secs = (performance.now() - computeStart) / 1000; setStatus(`${msg.phase} ${(100 * msg.frac).toFixed(0)} % [${secs.toFixed(0)} s]`); readyText.textContent = `Working out the modes: ${msg.phase}, ${(100 * msg.frac).toFixed(0)} % after ${secs.toFixed(0)} s.`; } else if (msg.type === "modes") { if (msg.token !== computeToken) return; computing = false; computeBtn.disabled = false; setProgress(1); const structural = structuralString(config); await cachePut(await cacheKey(structural), msg.buffer); setModes(msg.buffer, `computed in ${msg.seconds.toFixed(1)} s`, structural); workerHasModes = true; // the worker kept its own copy } else if (msg.type === "gains") { if (msg.token !== gainToken || !modes) return; modes.gainFront = msg.front; modes.gainBack = msg.back; gainsFor = gainsPending; sendGain(); } else { computing = false; computeBtn.disabled = false; setStatus(`error: ${msg.message}`); updateReady(); } } // ---------------------------------------------------------------- audio function sendInit() { if (!node || !modes || !derived) return; node.port.postMessage({ type: "init", freq: derived.freq, sigma: derived.sigma, gain: gainArray(modes, signalSel.value === "unbaffled") }); } function sendParams() { if (!node || !derived) return; node.port.postMessage({ type: "params", freq: derived.freq, sigma: derived.sigma }); } function sendGain() { if (!node || !modes) return; node.port.postMessage({ type: "gain", gain: gainArray(modes, signalSel.value === "unbaffled") }); } async function startAudio() { if (ctx) { await ctx.resume(); updateAudioInfo(); updateReady(); return; } ctx = new AudioContext({ latencyHint: "interactive" }); await ctx.audioWorklet.addModule(workletUrl); node = new AudioWorkletNode(ctx, "cymbal-modal", { numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1] }); gainNode = ctx.createGain(); setVolume(); node.connect(gainNode).connect(ctx.destination); node.port.onmessage = (ev) => onWorkletMessage(ev.data); node.onprocessorerror = () => setStatus("the audio processor stopped: reload the page (details in the browser console)"); await ctx.resume(); sendInit(); updateAudioInfo(); updateReady(); setInterval(updateAudioInfo, 1000); } function setVolume() { if (!gainNode || !ctx) return; const lift = exciterSel.value === "mallet" && previewGainBox.checked ? MALLET_PREVIEW_GAIN : 1; gainNode.gain.setTargetAtTime(0.15 * Number(volumeInput.value) * lift, ctx.currentTime, 0.02); } interface WorkletMessage { type: string; id?: number; time?: number; peakForce?: number; contactTime?: number; rebound?: number; mean?: number; } function onWorkletMessage(msg: WorkletMessage) { if (msg.type === "hitStarted") { const h = hits.find((x) => x.id === msg.id); if (h) h.time = msg.time!; renderHits(); } else if (msg.type === "hitDone") { const h = hits.find((x) => x.id === msg.id); if (h) { h.peakForce = msg.peakForce; h.contactTime = msg.contactTime; h.rebound = msg.rebound; } renderHits(); } else if (msg.type === "load") { workletLoad = msg.mean ?? 0; } } // ---------------------------------------------------------------- hits function strike(r: number, phi: number, velocity: number, source: string) { if (!modes || !derived) { setStatus("compute the modes first"); return; } if (!node || !ctx) return; const ex = exciter(); const R = config.profile.radius, es = modes.meta.playback_element_size; if (r + patchExtent(ex) >= R - es) { setStatus(`hit at r = ${r.toFixed(3)} m: the contact patch reaches the rim, ignored`); return; } const patch = buildHitPatch(modes, ex, r, phi, velocity, maxPointsFor(ex.kind), derived.phiFactor); if (!patch) { setStatus("that point is outside the mesh, ignored"); return; } const logged = recording; const id = nextId++; node.port.postMessage( { type: "hit", id, phi: patch.phi, offsets: patch.offsets, kPoint: patch.kPoint, alpha: patch.alpha, mass: patch.mass, gap: patch.gap, velocity, silent: !logged, }, [patch.phi.buffer, patch.offsets.buffer], ); if (logged) { hits.push({ id, time: ctx.currentTime - recordStart, r, phi, velocity, exciter: ex, source }); renderHits(); updateRecordUi(); updateReady(); } view.flash(r * Math.cos(phi), r * Math.sin(phi), velocity, ex.kind); } function renderHits() { hitsBody.innerHTML = ""; const t0 = hits.length ? Math.min(...hits.map((h) => h.time)) : 0; hits.forEach((h, i) => { const tr = document.createElement("tr"); for (const c of [ String(i + 1), (h.time - t0).toFixed(3), h.source, h.exciter.kind, h.r.toFixed(3), h.phi.toFixed(3), h.velocity.toFixed(2), h.peakForce !== undefined ? h.peakForce.toFixed(0) : "", h.contactTime !== undefined ? (h.contactTime * 1e3).toFixed(2) : "", ]) { const td = document.createElement("td"); td.textContent = c; tr.appendChild(td); } hitsBody.appendChild(tr); }); } function updateRecordUi() { const has = hits.length > 0; const ready = !!modes && !!node; recordToggle.textContent = recording ? "Stop recording" : "Record hits"; recordToggle.classList.toggle("recording", recording); recordToggle.disabled = !ready || playing; playHitsBtn.disabled = !has || !ready || recording || playing; stopHitsBtn.disabled = !playing; clearHitsBtn.disabled = !has || recording || playing; downloadYamlBtn.disabled = !has; downloadWavBtn.disabled = !has || !modes || rendering; const span = has ? Math.max(...hits.map((h) => h.time)) - Math.min(...hits.map((h) => h.time)) : 0; recordStatus.textContent = rendering ? "rendering the WAV..." : playing ? "playing the recording back" : recording ? `recording: ${hits.length} hit${hits.length === 1 ? "" : "s"} over ${span.toFixed(1)} s` : has ? `${hits.length} hit${hits.length === 1 ? "" : "s"} over ${span.toFixed(1)} s` : "not recording; play freely, and press Record hits when you want a take kept"; } function toggleRecording() { if (!ctx) return; if (recording) { recording = false; } else { hits = []; nextId = 1; recording = true; recordStart = ctx.currentTime; renderHits(); } updateRecordUi(); updateReady(); } function playRecording() { if (!modes || !derived || !node || !ctx || !hits.length) return; node.port.postMessage({ type: "reset" }); const fs = ctx.sampleRate; const base = Math.round(ctx.currentTime * fs) + Math.round(0.15 * fs); const { messages, span } = hitMessages(modes, derived, hits, fs, base, (h) => maxPointsFor(h.exciter.kind)); for (const m of messages) node.port.postMessage(m, [(m.phi as Float32Array).buffer, (m.offsets as Float32Array).buffer]); playing = true; updateRecordUi(); updateReady(); clearTimeout(playTimer); playTimer = window.setTimeout(() => { playing = false; updateRecordUi(); updateReady(); }, 1000 * (0.15 + span) + 500); } function stopPlayback() { clearTimeout(playTimer); playing = false; node?.port.postMessage({ type: "reset" }); updateRecordUi(); updateReady(); } async function downloadWav() { if (!modes || !derived || !hits.length) return; rendering = true; updateRecordUi(); try { const fs = ctx?.sampleRate ?? 44100; const samples = await renderRecording(workletUrl, modes, config, derived, hits, { sampleRate: fs, leadIn: Number(leadinInput.value), tail: Number(tailInput.value), maxPoints: maxPointsFor(exciterSel.value as "stick" | "mallet"), unbaffled: signalSel.value === "unbaffled", }); download(encodeWav(samples, fs), `${prefixInput.value || "cymplay"}_linear.wav`); } catch (err) { setStatus(`could not render the WAV: ${(err as Error).message}`); } finally { rendering = false; updateRecordUi(); } } // ---------------------------------------------------------------- design panel const panel = new DesignPanel(designBox, () => config, onDesignChange); function onDesignChange(_field: Field, tier: Tier) { refreshSpots(); if (modes && tier === "live") { derived = derive(modes, config); sendParams(); requestGains(); } describeModes("modes"); updateReady(); } function setConfig(cfg: SimulationConfig, label: string) { config = cfg; hits = []; recording = false; renderHits(); panel.refresh(); refreshSpots(); forgetModes(); updateRecordUi(); 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}`); void tryCache(); } // ---------------------------------------------------------------- wiring for (const p of PRESETS) { const o = document.createElement("option"); o.value = p.id; o.textContent = p.name; presetSel.appendChild(o); } const customOpt = document.createElement("option"); customOpt.value = "custom"; customOpt.textContent = "loaded YAML"; customOpt.disabled = true; presetSel.appendChild(customOpt); readyAction.addEventListener("click", async () => { if (computing) return; if (!modes || stale()) { await startAudio(); // this click is the gesture a browser needs to allow sound compute(); return; } if (!ctx || ctx.state !== "running") { await startAudio(); return; } toggleRecording(); }); presetSel.addEventListener("change", () => { const p = PRESETS.find((x) => x.id === presetSel.value); if (p) { presetYaml = p.yaml; setConfig(parseConfigYaml(p.yaml).config!, p.name); } }); yamlFile.addEventListener("change", async () => { const file = yamlFile.files?.[0]; if (!file) return; const text = await file.text(); const { config: cfg, issues } = parseConfigYaml(text); if (!cfg) { setStatus("YAML problems:\n" + issues.map((i) => `${i.path}: ${i.message}`).join("\n")); return; } presetYaml = text; customOpt.disabled = false; presetSel.value = "custom"; setConfig(cfg, file.name); }); for (const el of [esizeSel, fmaxInput]) { el.addEventListener("change", () => { forgetModes(); void tryCache(); }); } computeBtn.addEventListener("click", compute); downloadModesBtn.addEventListener("click", () => { if (modesBuffer) download(new Blob([modesBuffer]), `cymplay_modes_${(Number(esizeSel.value) * 1e3).toFixed(0)}mm.bin`); }); modesFile.addEventListener("change", async () => { const file = modesFile.files?.[0]; if (!file) return; const buf = await file.arrayBuffer(); let loaded: ModesData; try { loaded = parseModes(buf); } catch (err) { setStatus(`could not load the modes: ${(err as Error).message}`); return; } const { config: cfg } = parseConfigYaml(JSON.stringify(loaded.meta.config)); if (cfg) { customOpt.disabled = false; presetSel.value = "custom"; config = cfg; esizeSel.value = String(loaded.meta.playback_element_size); fmaxInput.value = String(loaded.meta.fmax); panel.refresh(); refreshSpots(); } workerHasModes = false; setModes(buf, file.name, structuralString(config)); }); clearCacheBtn.addEventListener("click", async () => { await cacheClear(); setStatus("the cache is empty"); }); revertBtn.addEventListener("click", () => setConfig(parseConfigYaml(presetYaml).config!, "reverted")); designYamlBtn.addEventListener("click", () => { const cfg = cloneConfig(config); cfg.output.prefix = prefixInput.value || "cymplay"; download(new Blob([configToYaml(cfg)], { type: "text/yaml" }), `${cfg.output.prefix}_design.yaml`); }); volumeInput.addEventListener("input", setVolume); previewGainBox.addEventListener("change", setVolume); exciterSel.addEventListener("change", setVolume); signalSel.addEventListener("change", sendGain); velocityInput.addEventListener("input", () => { velocityValue.textContent = `${Number(velocityInput.value).toFixed(1)} m/s`; refreshSpots(); }); resetWeightsBtn.addEventListener("click", () => { weights.clear(); saveWeights(); refreshSpots(); }); recordToggle.addEventListener("click", toggleRecording); playHitsBtn.addEventListener("click", playRecording); stopHitsBtn.addEventListener("click", stopPlayback); clearHitsBtn.addEventListener("click", () => { hits = []; nextId = 1; renderHits(); updateRecordUi(); updateReady(); }); downloadYamlBtn.addEventListener("click", () => { const text = exportYaml(config, hits, { leadIn: Number(leadinInput.value), tail: Number(tailInput.value), prefix: prefixInput.value || "cymplay" }); download(new Blob([text], { type: "text/yaml" }), `${prefixInput.value || "cymplay"}.yaml`); }); downloadWavBtn.addEventListener("click", () => void downloadWav()); function setLevel(v: number) { velocityInput.value = String(Math.min(16, Math.max(0.3, v))); velocityInput.dispatchEvent(new Event("input")); } view.onStrike = (x, y) => strike(Math.hypot(x, y), Math.atan2(y, x), velocityFor(1), "click"); view.onWeigh = (spot, steps) => { const w = Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, weightOf(spot.code) * Math.pow(WEIGHT_STEP, steps))); if (Math.abs(w - 1) < 1e-3) weights.delete(spot.code); else weights.set(spot.code, w); saveWeights(); refreshSpots(); }; window.addEventListener("keydown", (ev) => { const t = ev.target as HTMLElement; if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA")) return; if (ev.repeat || ev.ctrlKey || ev.metaKey) return; const digit = /^Digit([1-9])$/.exec(ev.code); if (digit) { setLevel(1.25 * Number(digit[1])); return; } if (ev.code === "BracketLeft" || ev.code === "BracketRight") { setLevel(Number(velocityInput.value) + (ev.code === "BracketRight" ? 0.5 : -0.5)); return; } const s = spotByCode.get(ev.code); if (s) { ev.preventDefault(); strike(s.r, s.phi, s.velocity, s.label); } }); panel.refresh(); refreshSpots(); velocityValue.textContent = `${Number(velocityInput.value).toFixed(1)} m/s`; updateRecordUi(); updateReady(); void tryCache();