/ proof-of-concept / cymbalsim
Sign in
proof-of-concept / cymbalsim
Modes on demand with a cache, progress bar in the head, play rewinds from live or the end, exaggeration 1
Jeremy Magland <jeremy.magland@gmail.com> committed commit 9cfbfd1f568b parent 5778036 Browse files
2 changed files+100−36
src/main.tsmodified+94−33View file
@@ -65,8 +65,13 @@ class App {
6565 sys: LinearSystem | null = null;
6666 modes: ModeSet | null = null;
6767 model: ModalModel | null = null;
68- modesGeneration = 0;
6968 modesBusy: Promise<void> = Promise.resolve();
69+ /** Modes of recent designs, keyed by the script, the spacing and the cutoff; the newest entry is last. */
70+ modesCache = new Map<string, { sys: LinearSystem; modes: ModeSet; model: ModalModel }>();
71+ headProgress!: HTMLDivElement;
72+ /** The cache key of the modes the pool currently holds. */
73+ currentModesKey: string | null = null;
74+ headProgressBar!: HTMLDivElement;
7075 synthBusy = false;
7176 synthPending = false;
7277 synthesized = false;
@@ -90,7 +95,7 @@ class App {
9095 levelMode: LevelMode = "absolute";
9196 gainDb = -40;
9297 showTicks = true;
93- viewOpts = { mode: "3d" as ViewMode, color: "height" as ColorMode, showProfile: true, exaggeration: 20, range: 0 };
98+ viewOpts = { mode: "3d" as ViewMode, color: "height" as ColorMode, showProfile: true, exaggeration: 1, range: 0 };
9499 // Controls
95100 runModeSel!: HTMLSelectElement;
96101 durationInput!: HTMLInputElement;
@@ -198,9 +203,21 @@ class App {
198203 this.stopBtn = this.button("Stop", () => this.stopRun());
199204 this.continueBtn = this.button("Continue", () => this.continueRun());
200205 c.append(this.runModeSel, durLabel, this.startBtn, this.pauseBtn, this.stopBtn, this.continueBtn);
206+ this.headProgress = document.createElement("div");
207+ this.headProgress.className = "progress";
208+ this.headProgress.hidden = true;
209+ this.headProgressBar = document.createElement("div");
210+ this.headProgress.append(this.headProgressBar);
201211 this.runStatus = document.createElement("div");
202212 this.runStatus.className = "status";
203- $("sidebarHead").append(this.runStatus);
213+ $("sidebarHead").append(this.headProgress, this.runStatus);
214+ }
215+
216+ /** The progress bar under the run controls (fraction 0..1, or null to hide) with a status message. */
217+ private setProgress(fraction: number | null, message?: string): void {
218+ this.headProgress.hidden = fraction === null;
219+ if (fraction !== null) this.headProgressBar.style.width = `${Math.round(100 * fraction)}%`;
220+ if (message !== undefined) this.runStatus.textContent = message;
204221 }
205222
206223 private button(label: string, onClick: () => void, cls = ""): HTMLButtonElement {
@@ -394,10 +411,42 @@ class App {
394411 this.updateDesignInfo();
395412 this.updateNotes();
396413 this.updateButtons();
397- if (this.runMode === "linear") this.computeModes();
414+ if (this.runMode === "linear") this.useCachedModes();
398415 else this.modeBrowser.clear("Switch to the linear model to compute the modes.");
399416 }
400417
418+ /** The cache key of the modes of the current design and numerics. */
419+ private modesKey(): string | null {
420+ if (!this.design || !this.plate) return null;
421+ const p = this.form.get();
422+ return `${this.plateSpacing}|${Math.min(p.modeMaxFreq, p.fs / 2)}|${this.design.source}`;
423+ }
424+
425+ /** Installs cached modes for the current design if there are any; otherwise leaves the browser empty. */
426+ private useCachedModes(): void {
427+ const key = this.modesKey();
428+ const hit = key ? this.modesCache.get(key) : undefined;
429+ if (hit && this.plate) {
430+ this.installModes(hit.sys, hit.modes, hit.model, null);
431+ if (this.pool) void this.pool.setModel(hit.model);
432+ } else this.modeBrowser.clear("Press Synthesize to compute the modes of this design.");
433+ }
434+
435+ /** Shows a mode set in the browser, the notes and the spectrogram ticks. */
436+ private installModes(sys: LinearSystem, modes: ModeSet, model: ModalModel, secs: number | null): void {
437+ const pl = this.plate!;
438+ const p = this.form.get();
439+ const fMax = Math.min(p.modeMaxFreq, p.fs / 2);
440+ this.sys = sys; this.modes = modes; this.model = model;
441+ const rows = Array.from(modes.freqs).map((f, m) => ({ freq: f, t60: LN1000 / Math.min(decayRate(pl.loss, f) + model.padSigma[m], 0.99 * 2 * Math.PI * f) }));
442+ const kind = sys.fields === 1 ? "bending modes" : "modes (bending coupled to stretching)";
443+ const when = secs === null ? "cached" : `in ${secs.toFixed(1)} s`;
444+ this.modeBrowser.setModes(rows, `${modes.count} ${kind} up to ${(fMax / 1e3).toFixed(1)} kHz, ${when} (${sys.n} unknowns${modes.missing ? `, ${modes.missing} missing` : ""}). Click a mode to see its shape.`);
445+ this.updateNotes();
446+ this.updateTicks();
447+ this.updateButtons();
448+ }
449+
401450 private updateDesignInfo(): void {
402451 const pl = this.plate;
403452 if (!pl) return;
@@ -465,13 +514,19 @@ class App {
465514
466515 // ---------- Linear model ----------
467516
468- private computeModes(): void {
469- const pl = this.plate, gen = this.designGeneration;
470- if (!pl) return;
517+ /** Computes the modes of the current design unless they are cached; resolves when they are installed (or with an error). */
518+ private computeModes(): Promise<void> {
519+ const pl = this.plate, gen = this.designGeneration, key = this.modesKey();
520+ if (!pl || !key) return Promise.resolve();
521+ const hit = this.modesCache.get(key);
522+ if (hit) {
523+ if (this.model !== hit.model) { this.installModes(hit.sys, hit.modes, hit.model, null); if (this.pool) return this.pool.setModel(hit.model); }
524+ return Promise.resolve();
525+ }
471526 const p = this.form.get();
472527 const fMax = Math.min(p.modeMaxFreq, p.fs / 2);
473- this.modeBrowser.clear("");
474- this.modeBrowser.setProgress(0, "Computing the modes...");
528+ this.modeBrowser.clear("Computing the modes...");
529+ this.setProgress(0, "Computing the modes...");
475530 this.updateButtons();
476531 this.modesBusy = this.modesBusy.then(async () => {
477532 if (gen !== this.designGeneration) return;
@@ -480,35 +535,29 @@ class App {
480535 try {
481536 sys = assemble(pl);
482537 } catch (e) {
483- this.modeBrowser.setProgress(null, `Assembly failed: ${e instanceof Error ? e.message : String(e)}`);
484- return;
538+ this.setProgress(null, `Assembly failed: ${e instanceof Error ? e.message : String(e)}`);
539+ throw e;
485540 }
486541 let modes: ModeSet;
487542 try {
488- if (this.pool) modes = await this.pool.modes(sys, fMax, (d, t) => { if (gen === this.designGeneration) this.modeBrowser.setProgress(t ? d / t : 0, `Computing the modes: slice ${d} of ${t}`); });
543+ if (this.pool) modes = await this.pool.modes(sys, fMax, (d, t) => { if (gen === this.designGeneration) this.setProgress(t ? d / t : 0, `Computing the modes: slice ${d} of ${t}`); });
489544 else {
490545 const { computeModes, localRunner } = await import("./modal/modes.ts");
491546 modes = await computeModes(sys, fMax, localRunner(sys));
492547 }
493548 } catch (e) {
494- this.modeBrowser.setProgress(null, `Mode computation failed: ${e instanceof Error ? e.message : String(e)}`);
495- return;
549+ this.setProgress(null, `Mode computation failed: ${e instanceof Error ? e.message : String(e)}`);
550+ throw e;
496551 }
497- if (gen !== this.designGeneration) return;
498- this.sys = sys;
499- this.modes = modes;
500- this.model = modalModel(pl, sys, modes);
501- if (this.pool) await this.pool.setModel(this.model);
502- const secs = (performance.now() - t0) / 1000;
503- const rows = Array.from(modes.freqs).map((f, m) => ({ freq: f, t60: LN1000 / Math.min(decayRate(pl.loss, f) + this.model!.padSigma[m], 0.99 * 2 * Math.PI * f) }));
504- const kind = sys.fields === 1 ? "bending modes" : "modes (bending coupled to stretching)";
505- const info = `${modes.count} ${kind} up to ${(fMax / 1e3).toFixed(1)} kHz in ${secs.toFixed(1)} s (${sys.n} unknowns${modes.missing ? `, ${modes.missing} missing` : ""}). Click a mode to see its shape.`;
506- this.modeBrowser.setProgress(null);
507- this.modeBrowser.setModes(rows, info);
508- this.updateNotes();
509- this.updateTicks();
510- this.updateButtons();
552+ const model = modalModel(pl, sys, modes);
553+ this.modesCache.set(key, { sys, modes, model });
554+ while (this.modesCache.size > 4) this.modesCache.delete(this.modesCache.keys().next().value!);
555+ if (gen !== this.designGeneration) { this.setProgress(null); return; }
556+ if (this.pool) await this.pool.setModel(model);
557+ this.setProgress(null, "");
558+ this.installModes(sys, modes, model, (performance.now() - t0) / 1000);
511559 });
560+ return this.modesBusy;
512561 }
513562
514563 private showMode(m: number | null): void {
@@ -532,9 +581,18 @@ class App {
532581
533582 /** Synthesizes the strike with the current parameters (linear model). */
534583 async runLinear(): Promise<void> {
584+ if (!this.plate) return;
585+ if (this.synthBusy) { this.synthPending = true; return; }
586+ if (!this.model || this.modesKey() !== this.currentModesKey) {
587+ try {
588+ await this.computeModes();
589+ } catch {
590+ return;
591+ }
592+ this.currentModesKey = this.modesKey();
593+ }
535594 const pl = this.plate, model = this.model;
536595 if (!pl || !model) return;
537- if (this.synthBusy) { this.synthPending = true; return; }
538596 this.synthBusy = true;
539597 const p = this.form.get();
540598 const gen = this.designGeneration;
@@ -643,7 +701,7 @@ class App {
643701 if (mode === "full" && !this.device) mode = "linear";
644702 this.runMode = mode;
645703 this.runModeSel.value = mode;
646- if (mode === "linear" && this.plate && !this.modes) this.computeModes();
704+ if (mode === "linear" && this.plate && !this.modes) this.useCachedModes();
647705 if (mode === "full") this.modeBrowser.clear("Switch to the linear model to compute the modes.");
648706 this.updateButtons();
649707 }
@@ -683,7 +741,7 @@ class App {
683741
684742 private updateButtons(): void {
685743 const linear = this.runMode === "linear";
686- const canLinear = linear && !!this.model && !this.synthBusy;
744+ const canLinear = linear && !!this.plate && !this.synthBusy;
687745 const canFull = !linear && !!this.plate && !!this.device && !this.running;
688746 this.startBtn.disabled = !(canLinear || canFull);
689747 this.startBtn.textContent = linear ? "Synthesize" : "Start";
@@ -703,7 +761,7 @@ class App {
703761 this.spec.setSignal(audio);
704762 this.scrub.max = String(Math.max(0, frames.length - 1));
705763 if (frames.length > 0) {
706- this.frameIndex = Math.min(this.frameIndex, frames.length - 1);
764+ this.frameIndex = 0;
707765 if (this.view !== "mode") { this.setView("movie"); this.showFrame(); }
708766 }
709767 this.updatePeak();
@@ -749,7 +807,10 @@ class App {
749807 if (this.player.playing) { this.player.stop(); this.playBtn.textContent = "Play"; this.spec.setCursor(null); return; }
750808 const s = this.playable(), rec = this.rec;
751809 if (!s || !rec) return;
752- const start = rec.frames.length > 1 ? this.frameIndex / rec.fps : 0;
810+ // Play from the scrubbed position only when the user has scrubbed to one; from the live
811+ // view, or from the end of the recording, start over.
812+ const atEnd = rec.frames.length > 1 && this.frameIndex >= rec.frames.length - 1;
813+ const start = this.view === "movie" && rec.frames.length > 1 && !atEnd ? this.frameIndex / rec.fps : 0;
753814 this.playBtn.textContent = "Stop";
754815 const prevView = this.view;
755816 this.player.play(s, rec.fs, start, () => { this.playBtn.textContent = "Play"; this.spec.setCursor(null); if (prevView === "live") this.setView("live"); });
tests/gpu.test.tsmodified+6−3View file
@@ -67,14 +67,18 @@ test("the WGSL step agrees with the float64 reference on several presets", async
6767
6868 test("the linear model computes the modes and synthesizes a strike", async () => {
6969 await withPage("preset=flat-disc&duration=1", async (page, errors) => {
70- await page.evaluate(() => (window as unknown as { cymbalsim: { modesReady: () => Promise<void> } }).cymbalsim.modesReady());
7170 let st = await status(page);
7271 assert.equal(st.design, "Flat bronze disc");
72+ assert.equal(st.modes, null, "the modes are computed when Synthesize is pressed");
73+ await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
74+ st = await status(page);
7375 assert.ok(st.modes && st.modes.count > 100, `modes ${st.modes?.count}`);
7476 assert.equal(st.modes!.missing, 0);
7577 assert.ok(st.modes!.first[0] > 1 && st.modes!.first[0] < 200, `first mode ${st.modes!.first[0]} Hz (the rock or the bounce on the stand)`);
78+ // A second synthesis reuses the cached modes.
79+ const t0 = Date.now();
7680 await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
77- st = await status(page);
81+ assert.ok(Date.now() - t0 < 4000, "cached modes");
7882 assert.ok(st.synthesized);
7983 assert.equal(st.source, "linear");
8084 assert.equal(st.samples, 48000);
@@ -125,7 +129,6 @@ test("a light hit sounds the same in the linear and the full model", async () =>
125129 await withPage("preset=flat-disc&duration=0.25&strikeVelocity=0.05&spacing=0.012&fps=60", async (page, errors) => {
126130 if (!(await hasGpu(page))) { console.log("no WebGPU in this Chromium; skipping"); return; }
127131 const hooks = "(window as unknown as { cymbalsim: any }).cymbalsim";
128- await page.evaluate(() => (window as unknown as { cymbalsim: { modesReady: () => Promise<void> } }).cymbalsim.modesReady());
129132 await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
130133 const linear = await page.evaluate(() => { const a = (window as unknown as { cymbalsim: { app: { rec: { audio: Float32Array; samples: number } } } }).cymbalsim.app; return Array.from(a.rec.audio.subarray(0, a.rec.samples)); });
131134 await page.evaluate(() => (window as unknown as { cymbalsim: { runFull: () => Promise<void> } }).cymbalsim.runFull());
↑↓ move↵openescclose