Loudness warning applies to the signal about to be played and returns on any change
The accepted level is no longer remembered: once the user agrees, the same signal replays without a prompt, and a new recording, another level mode or a change of the gain brings the warning back. A signal counts as loud when its peak is within 10 dB of full scale or the RMS over some 50 ms window reaches -20 dBFS, so that a loudness-normalized hit warns as well.
4 changed files+50−19
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. 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.
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 is loud enough to hurt in headphones (a peak within 10 dB of full scale, or a short-term level of -20 dBFS RMS over some 50 ms window, which every loudness-normalized hit reaches), Play first shows a warning to turn the volume down, with a Play anyway button. Once accepted, the same signal replays without a prompt; a new recording, another level mode or a change of the gain brings the warning back.
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/audio/level.tsmodified+25−0View file
@@ -68,6 +68,31 @@ export function peakDbfs(samples: Float32Array): number {
6868 return max > 0 ? 20 * Math.log10(max) : -Infinity;
6969 }
7070
71+/** Peak level [dBFS] from which a signal is considered loud enough to hurt in headphones. */
72+export const LOUD_PEAK_DBFS = -10;
73+/** Short-term RMS level [dBFS] over any 50 ms window from which a signal is considered loud. */
74+export const LOUD_RMS_DBFS = -20;
75+
76+/**
77+ * Whether a full-scale signal is loud enough to hurt in headphones at an
78+ * ordinary volume setting: its peak reaches LOUD_PEAK_DBFS, or the RMS of
79+ * some 50 ms window reaches LOUD_RMS_DBFS (the attack of a hit is short, so
80+ * the peak alone can miss a sustained loud passage and the RMS alone can
81+ * miss a sharp transient).
82+ */
83+export function loudness(samples: Float32Array, fs: number): { peakDb: number; shortTermDb: number; loud: boolean } {
84+ const peakDb = peakDbfs(samples);
85+ const win = Math.max(1, Math.round(0.05 * fs));
86+ let sum = 0, max = 0;
87+ for (let k = 0; k < samples.length; k++) {
88+ sum += samples[k] * samples[k];
89+ if (k >= win) sum -= samples[k - win] * samples[k - win];
90+ if (k >= win - 1 && sum > max) max = sum;
91+ }
92+ const shortTermDb = max > 0 ? 10 * Math.log10(max / win) : -Infinity;
93+ return { peakDb, shortTermDb, loud: peakDb >= LOUD_PEAK_DBFS || shortTermDb >= LOUD_RMS_DBFS };
94+}
95+
7196 /** Peak sound pressure level in dB SPL re 20 uPa of a pressure signal [Pa]. */
7297 export function peakSpl(samples: Float32Array): number {
7398 const max = peakAbs(samples);
src/main.tsmodified+12−14View file
@@ -32,7 +32,7 @@ import { PlateRenderer, type ViewMode, type ColorMode } from "./render/renderer.
3232 import { Spectrogram } from "./audio/spectrogram.ts";
3333 import { Player } from "./audio/player.ts";
3434 import { encodeWav } from "./audio/wav.ts";
35-import { levelGain, applyLevel, peakDbfs, peakSpl, type LevelMode } from "./audio/level.ts";
35+import { levelGain, applyLevel, peakDbfs, peakSpl, loudness, type LevelMode } from "./audio/level.ts";
3636 import { DesignEditor } from "./ui/editor.ts";
3737 import { ParamForm, formatNumber } from "./ui/form.ts";
3838 import { ModeBrowser } from "./ui/modes.ts";
@@ -44,10 +44,6 @@ 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;
5147
5248 /** A recording: the listener signal and the movie of one run, from either model. */
5349 interface Recording {
@@ -131,8 +127,8 @@ class App {
131127 loudWarnText!: HTMLSpanElement;
132128 loudPlayBtn!: HTMLButtonElement;
133129 loudGo: (() => void) | null = null;
134- /** The highest peak level [dBFS] the user has agreed to play; louder signals prompt again. */
135- acceptedPeakDb = -Infinity;
130+ /** Whether the user has agreed to play the current signal; any change of the recording, the level mode or the gain resets it. */
131+ loudAccepted = false;
136132
137133 async init(): Promise<void> {
138134 const url = new URLSearchParams(location.search);
@@ -378,9 +374,9 @@ class App {
378374 }
379375
380376 /** Shows the loudness warning; `go` starts the playback if the user accepts. */
381- private showLoudWarning(peakDb: number, go: () => void): void {
377+ private showLoudWarning(peakDb: number, shortTermDb: number, go: () => void): void {
382378 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.`;
379+ this.loudWarnText.textContent = `This recording is loud (peak ${peakDb.toFixed(1)} dBFS, short-term level ${shortTermDb.toFixed(1)} dBFS), enough to hurt in headphones. Turn your volume down before playing.`;
384380 this.loudWarn.hidden = false;
385381 this.loudPlayBtn.focus();
386382 }
@@ -861,7 +857,9 @@ class App {
861857 }
862858
863859 private updatePeak(): void {
860+ // Anything that changes the signal to be played (a new recording, the level mode, the gain) comes through here.
864861 this.hideLoudWarning();
862+ this.loudAccepted = false;
865863 const s = this.playable();
866864 if (!s) { this.peakLabel.textContent = ""; this.downloadBtn.disabled = true; this.playBtn.disabled = true; return; }
867865 const db = peakDbfs(s);
@@ -875,11 +873,11 @@ class App {
875873 if (this.player.playing) { this.player.stop(); this.playBtn.textContent = "Play"; this.spec.setCursor(null); return; }
876874 const s = this.playable(), rec = this.rec;
877875 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); });
876+ // A loud signal can hurt in headphones: warn before playing it, once per signal; any
877+ // change of the recording, the level mode or the gain asks again.
878+ const l = loudness(s, rec.fs);
879+ if (l.loud && !this.loudAccepted) {
880+ this.showLoudWarning(l.peakDb, l.shortTermDb, () => { this.loudAccepted = true; this.startPlayback(s, rec); });
883881 return;
884882 }
885883 this.hideLoudWarning();
tests/gpu.test.tsmodified+12−4View file
@@ -147,9 +147,9 @@ test("a loud recording warns before playing and plays once accepted", async () =
147147 r = await run(`a.levelMode = "peak"; a.updatePeak(); a.togglePlay();`);
148148 assert.equal(r.playing, false);
149149 assert.equal(r.warn, true);
150- assert.match(r.text, /-1\.0 dBFS/);
150+ assert.match(r.text, /peak -1\.0 dBFS/);
151151 assert.match(r.text, /volume/);
152- // Cancel hides it; Play anyway plays and remembers the level, so the next Play needs no prompt.
152+ // Cancel hides it; Play anyway plays, and the same signal replays without a prompt.
153153 await page.click("#movieToolbar .loud-warn button:has-text('Cancel')");
154154 r = await run("");
155155 assert.equal(r.warn, false);
@@ -160,10 +160,18 @@ test("a loud recording warns before playing and plays once accepted", async () =
160160 assert.equal(r.playing, true);
161161 assert.equal(r.warn, false);
162162 r = await run("a.togglePlay(); a.togglePlay();");
163- assert.equal(r.playing, true, "the accepted level plays without a prompt");
163+ assert.equal(r.playing, true, "the accepted signal replays without a prompt");
164164 assert.equal(r.warn, false);
165+ // Any change brings the warning back: the level mode (loudness normalization is loud too), then the gain.
166+ r = await run(`a.togglePlay(); a.levelMode = "loudness"; a.updatePeak(); a.togglePlay();`);
167+ assert.equal(r.playing, false);
168+ assert.equal(r.warn, true, "loudness normalization warns");
169+ await page.click("#movieToolbar .loud-warn button:has-text('Play anyway')");
170+ r = await run(`a.togglePlay(); a.levelMode = "absolute"; a.gainDb = 0; a.updatePeak(); a.togglePlay();`);
171+ assert.equal(r.playing, false);
172+ assert.equal(r.warn, true, "a gain change warns again");
165173 // A quiet signal never prompts.
166- r = await run(`a.togglePlay(); a.levelMode = "absolute"; a.gainDb = -60; a.updatePeak(); a.togglePlay();`);
174+ r = await run(`a.gainDb = -60; a.updatePeak(); a.togglePlay();`);
167175 assert.equal(r.playing, true);
168176 assert.equal(r.warn, false);
169177 assert.deepEqual(errors, []);