/ proof-of-concept / cymbalsim
Sign in
proof-of-concept / cymbalsim
Warn before playing a recording that peaks near full scale
Play shows a notice to turn the volume down, with Play anyway and Cancel, when the signal to be played peaks within 6 dB of full scale; the accepted level is remembered, so the same or a quieter recording plays without a prompt and one louder by more than 3 dB prompts again. A browser test covers the prompt, the cancel, the acceptance and a quiet signal.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit c99922dcd962 parent 351af76 Browse files
4 changed files+87−1
README.mdmodified+1−1View file
@@ -57,7 +57,7 @@ end
5757
5858 **Numerics.** The grid spacing (8 mm by default), the cutoff frequency of the modes, the lift-off of the pads in the full model, the sample rate, and the frame rate. The Notes under the group report the grid, the frequencies at which grid dispersion lowers the partials by 3 and 10 percent, the count and time of the modes, the time step and step count of the full model, and the decay times of the fitted two-parameter loss next to the requested ones.
5959
60-**Playback.** After a run, drag the time slider to explore the movie; Play starts the audio from the slider position and moves the movie and the spectrogram cursor with it; Design returns to the stress-free shape and, in full mode, Live to the running view. The audio can be played or downloaded as a 16-bit WAV at any time; Absolute level maps 1 Pa to full scale shifted by the gain slider (the peak level in dBFS is shown with a clipping warning), and the two normalized modes scale each run to its loudness or its peak.
60+**Playback.** After a run, drag the time slider to explore the movie; Play starts the audio from the slider position and moves the movie and the spectrogram cursor with it; Design returns to the stress-free shape and, in full mode, Live to the running view. The audio can be played or downloaded as a 16-bit WAV at any time; Absolute level maps 1 Pa to full scale shifted by the gain slider (the peak level in dBFS is shown with a clipping warning), and the two normalized modes scale each run to its loudness or its peak. When the signal to be played peaks within 6 dB of full scale, loud enough to hurt in headphones, Play first shows a warning to turn the volume down, with a Play anyway button; a recording at the accepted level or a quieter one then plays without a prompt, and one louder by more than 3 dB prompts again.
6161
6262 **Tutorial.** The Tutorial button at the top of the sidebar opens a panel beside the view with a guided tour of the physics and the mathematics of the simulator in nineteen pages: the bending stiffness and the plate equation, dispersion, modes and their scaling laws, the density of modes, the grid and its dispersion error, the eigenvalue computation, decay times, the strike and the driving-point impedance, the modal force and the choice of the strike point, the modal synthesis, radiation and directivity, the spectrogram, the stand, the taper, curvature, the nonlinear model, and the limitations. Each page reads the design in the editor and shows its numbers (the bending stiffness, the wavelength at 1 kHz, the count of modes against Weyl's estimate, the gains of the first modes at the strike point and at the listener, the bounce and the rock of a stand against their formulas), and the buttons under Try it load presets, change parameters, compute the modes, strike and play, so that every formula can be checked against the model; a few pages remember mode lists for a comparison, such as the plate twice as thick against the reference. The panel keeps its page between visits, and `?tutorial=1` or `?tutorial=<page id>` opens it from the address.
6363
src/main.tsmodified+47−0View file
@@ -44,6 +44,11 @@ type ViewState = "design" | "mode" | "live" | "movie";
4444
4545 const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
4646
47+/** Peak level [dBFS] from which a recording is considered loud enough to warn before playing. */
48+const LOUD_DBFS = -6;
49+/** A recording louder than the accepted level by this much prompts again [dB]. */
50+const LOUD_STEP_DB = 3;
51+
4752 /** A recording: the listener signal and the movie of one run, from either model. */
4853 interface Recording {
4954 audio: Float32Array;
@@ -121,6 +126,13 @@ class App {
121126 downloadBtn!: HTMLButtonElement;
122127 overlay!: HTMLDivElement;
123128 synthTimer: number | null = null;
129+ // Loudness warning
130+ loudWarn!: HTMLDivElement;
131+ loudWarnText!: HTMLSpanElement;
132+ loudPlayBtn!: HTMLButtonElement;
133+ loudGo: (() => void) | null = null;
134+ /** The highest peak level [dBFS] the user has agreed to play; louder signals prompt again. */
135+ acceptedPeakDb = -Infinity;
124136
125137 async init(): Promise<void> {
126138 const url = new URLSearchParams(location.search);
@@ -354,6 +366,28 @@ class App {
354366 this.downloadBtn = this.button("Download .wav", () => this.download());
355367 const lab = (text: string, ...els: HTMLElement[]) => { const l = document.createElement("label"); l.append(text, ...els); return l; };
356368 t.append(this.playBtn, this.liveBtn, this.designBtn, this.scrub, this.timeLabel, levelSel, lab("Gain", gain, gainLabel), this.peakLabel, this.downloadBtn);
369+ // The loudness warning, shown in place of playing when the signal peaks near full scale.
370+ this.loudWarn = document.createElement("div");
371+ this.loudWarn.className = "loud-warn";
372+ this.loudWarn.hidden = true;
373+ this.loudWarnText = document.createElement("span");
374+ this.loudPlayBtn = this.button("Play anyway", () => { const go = this.loudGo; this.hideLoudWarning(); go?.(); });
375+ const cancel = this.button("Cancel", () => this.hideLoudWarning());
376+ this.loudWarn.append(this.loudWarnText, this.loudPlayBtn, cancel);
377+ t.append(this.loudWarn);
378+ }
379+
380+ /** Shows the loudness warning; `go` starts the playback if the user accepts. */
381+ private showLoudWarning(peakDb: number, go: () => void): void {
382+ this.loudGo = go;
383+ this.loudWarnText.textContent = `This recording peaks at ${peakDb.toFixed(1)} dBFS, loud enough to hurt in headphones. Turn your volume down before playing.`;
384+ this.loudWarn.hidden = false;
385+ this.loudPlayBtn.focus();
386+ }
387+
388+ private hideLoudWarning(): void {
389+ this.loudWarn.hidden = true;
390+ this.loudGo = null;
357391 }
358392
359393 private buildSpecToolbar(): void {
@@ -827,6 +861,7 @@ class App {
827861 }
828862
829863 private updatePeak(): void {
864+ this.hideLoudWarning();
830865 const s = this.playable();
831866 if (!s) { this.peakLabel.textContent = ""; this.downloadBtn.disabled = true; this.playBtn.disabled = true; return; }
832867 const db = peakDbfs(s);
@@ -840,6 +875,18 @@ class App {
840875 if (this.player.playing) { this.player.stop(); this.playBtn.textContent = "Play"; this.spec.setCursor(null); return; }
841876 const s = this.playable(), rec = this.rec;
842877 if (!s || !rec) return;
878+ // A signal that peaks near full scale can hurt in headphones: warn before the first
879+ // playback at such a level, and again whenever the level is well above the one accepted.
880+ const peak = peakDbfs(s);
881+ if (peak >= LOUD_DBFS && peak > this.acceptedPeakDb + LOUD_STEP_DB) {
882+ this.showLoudWarning(peak, () => { this.acceptedPeakDb = peak; this.startPlayback(s, rec); });
883+ return;
884+ }
885+ this.hideLoudWarning();
886+ this.startPlayback(s, rec);
887+ }
888+
889+ private startPlayback(s: Float32Array, rec: Recording): void {
843890 // Play from the scrubbed position only when the user has scrubbed to one; from the live
844891 // view, or from the end of the recording, start over.
845892 const atEnd = rec.frames.length > 1 && this.frameIndex >= rec.frames.length - 1;
src/style.cssmodified+2−0View file
@@ -85,6 +85,8 @@ details { border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-bo
8585 .progress > div { height: 100%; background: var(--accent); width: 0; transition: width 0.15s; }
8686 .badge { display: inline-block; padding: 1px 6px; border-radius: 3px; background: var(--bg3); color: var(--fg2); font-size: 11px; }
8787 .badge.warn { color: var(--warn); }
88+.loud-warn { flex-basis: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 5px 8px; border: 1px solid var(--warn); border-radius: 4px; color: var(--warn); background: #2a2415; }
89+.loud-warn button { border-color: var(--warn); }
8890 #nogpu { color: var(--warn); }
8991 /* Tutorial */
9092 #tutorial { width: 440px; min-width: 320px; display: flex; flex-direction: column; background: var(--bg2); border-left: 1px solid var(--border); font-size: 13.5px; }
tests/gpu.test.tsmodified+37−0View file
@@ -121,6 +121,43 @@ test("the tutorial opens, typesets its mathematics, and its actions drive the ap
121121 });
122122 });
123123
124+test("a loud recording warns before playing and plays once accepted", async () => {
125+ await withPage("preset=flat-rectangle&duration=0.5&spacing=0.016", async (page, errors) => {
126+ await page.evaluate(() => (window as unknown as { cymbalsim: { runLinear: () => Promise<void> } }).cymbalsim.runLinear());
127+ // The page's App, with the private members the test pokes at.
128+ type A = { gainDb: number; player: { playing: boolean }; levelMode: string; updatePeak: () => void; togglePlay: () => void; loudWarn: HTMLElement };
129+ const run = (script: string) => page.evaluate((s: string) => {
130+ const a = (window as unknown as { cymbalsim: { app: A } }).cymbalsim.app;
131+ new Function("a", s)(a);
132+ return { playing: a.player.playing, warn: !a.loudWarn.hidden, text: a.loudWarn.textContent ?? "" };
133+ }, script);
134+ // Normalizing the peak puts it at -1 dBFS: Play warns instead of playing.
135+ let r = await run(`a.levelMode = "peak"; a.updatePeak(); a.togglePlay();`);
136+ assert.equal(r.playing, false);
137+ assert.equal(r.warn, true);
138+ assert.match(r.text, /-1\.0 dBFS/);
139+ assert.match(r.text, /volume/);
140+ // Cancel hides it; Play anyway plays and remembers the level, so the next Play needs no prompt.
141+ await page.click("#movieToolbar .loud-warn button:has-text('Cancel')");
142+ r = await run("");
143+ assert.equal(r.warn, false);
144+ assert.equal(r.playing, false);
145+ await run("a.togglePlay();");
146+ await page.click("#movieToolbar .loud-warn button:has-text('Play anyway')");
147+ r = await run("");
148+ assert.equal(r.playing, true);
149+ assert.equal(r.warn, false);
150+ r = await run("a.togglePlay(); a.togglePlay();");
151+ assert.equal(r.playing, true, "the accepted level plays without a prompt");
152+ assert.equal(r.warn, false);
153+ // A quiet signal never prompts.
154+ r = await run(`a.togglePlay(); a.levelMode = "absolute"; a.gainDb = -60; a.updatePeak(); a.togglePlay();`);
155+ assert.equal(r.playing, true);
156+ assert.equal(r.warn, false);
157+ assert.deepEqual(errors, []);
158+ });
159+});
160+
124161 test("a script with an error is reported", async () => {
125162 await withPage("preset=ellipse", async (page) => {
126163 await page.evaluate(() => {
↑↓ move↵openescclose