Absolute playback level with gain slider and peak readout
7 changed files+76−25
README.mdmodified+1−0View file
@@ -18,6 +18,7 @@ Press Start. With the default excitation the sheet is pushed at the bottom-right
1818 - The sheet has a stress-free initial bow (default 3 mm across the width; set the shape to Flat for the ideal plate). A negative push amount pushes against the bow.
1919 - Plate, initial shape, excitation, and numerics parameters take effect at the next Start. Damping, listener position, and the radiation model can be changed while a simulation runs.
2020 - Pause halts the run temporarily; Stop ends it, keeps the audio and movie, and unlocks the parameter fields for the next Start.
21+- Playback level: "Absolute" maps 1 Pa to full scale, shifted by the gain slider, so different runs can be compared by ear (the peak level in dBFS is shown, with a clipping warning); "Loudness" and "Peak" normalize each run instead. The WAV download uses the same setting.
2122 - After the simulation completes, drag the time slider to explore the movie; Play starts audio from the slider position and moves the movie and the spectrogram cursor with it. Live returns to the running view.
2223 - To extend a finished simulation, raise the duration and press Continue; the state is kept on the GPU.
2324 - Any parameter can be preset in the URL in SI units, for example `?spacing=0.006&duration=2&autostart=1`.
THEORY.mdmodified+1−1View file
@@ -89,7 +89,7 @@ $$
8989
9090 where the sum runs over the nodes, $r_k$ is the distance from node $k$ to the listener, and $c = 343$ m/s. The transverse velocity of every node is recorded in a ring buffer at the audio rate, and each output sample is a delayed, weighted sum over the plate, evaluated on the GPU with linear interpolation between samples; the acceleration is formed as the difference between successive delayed velocities, which is the average acceleration over one sample interval. This matters in single precision: the force evaluation carries a broadband rounding error of the order of $1\ \mathrm{m/s^2}$ per node, and sampling the instantaneous acceleration would put that noise straight into the audio, whereas the averaged form suppresses it by the square root of the number of steps per sample. The common delay is discarded; only the spread of delays across the plate (about 5 samples for a listener 1 m in front) is kept. The plate is unbaffled in reality, so the sound from the two faces partly cancels at low frequencies. The "dipole" option is a heuristic for this: each element is weighted by the cosine of its angle to the listener, and the signal is high-pass filtered with a first-order filter at $c/(2\sqrt{L_x L_y})$, about 460 Hz for the default sheet. An accurate treatment would require solving the exterior acoustic problem, which is outside the scope of this simulator.
9191
92-The pressure is reported in pascals, and the peak sound pressure level is displayed. Playback and the downloaded WAV file are normalized either to their peak or, by default, to the loudness of the sustained sound (the RMS after the first 50 ms is scaled to -16 dBFS and a soft clipper bounds the onset), since a signal dominated by the lowest modes has a large peak-to-RMS ratio.
92+The pressure is reported in pascals, and the peak sound pressure level is displayed. Playback and the downloaded WAV file use, by default, a fixed mapping from pressure to full scale (1 Pa at 0 dBFS, shifted by a user gain in decibels), so that runs with different excitations can be compared by ear; a soft clipper bounds the output and the resulting peak level is shown. Two normalized modes are also offered: to the peak, or to the loudness of the sustained sound (the RMS after the first 50 ms scaled to -16 dBFS), the latter being useful because a signal dominated by the lowest modes has a large peak-to-RMS ratio.
9393
9494 ## 7. Limitations
9595
src/audio/level.tsmodified+30−12View file
@@ -1,25 +1,43 @@
1-// Playback level: peak normalization, or loudness normalization with a soft clipper.
1+// Playback level: absolute (fixed pascal-to-full-scale mapping with a user gain),
2+// loudness normalization, or peak normalization. A soft clipper bounds the output.
23
3-export type LevelMode = "peak" | "loudness";
4+export type LevelMode = "absolute" | "loudness" | "peak";
45
5-/** Returns a copy of `samples` scaled for playback or export, in [-1, 1]. */
6-export function normalize(samples: Float32Array, fs: number, mode: LevelMode, peak = 0.95): Float32Array {
7- const out = new Float32Array(samples.length);
8- if (samples.length === 0) return out;
6+/** Linear gain applied to the pressure signal [Pa] for the given mode. */
7+export function levelGain(samples: Float32Array, fs: number, mode: LevelMode, gainDb: number, peak = 0.95): number {
8+ if (samples.length === 0) return 0;
9+ if (mode === "absolute") {
10+ // 0 dBFS corresponds to 1 Pa at 0 dB gain.
11+ return 10 ** (gainDb / 20);
12+ }
913 if (mode === "peak") {
1014 let max = 0;
1115 for (let k = 0; k < samples.length; k++) max = Math.max(max, Math.abs(samples[k]));
12- const g = max > 0 ? peak / max : 0;
13- for (let k = 0; k < samples.length; k++) out[k] = samples[k] * g;
14- return out;
16+ return max > 0 ? peak / max : 0;
1517 }
16- // Loudness: scale the RMS of the sustained part (after the first 50 ms) to -16 dBFS,
17- // then soft-clip so that transients cannot exceed the peak.
18+ // Loudness: scale the RMS of the sustained part (after the first 50 ms) to -16 dBFS.
1819 const start = Math.min(samples.length - 1, Math.round(0.05 * fs));
1920 let sum = 0;
2021 for (let k = start; k < samples.length; k++) sum += samples[k] * samples[k];
2122 const rms = Math.sqrt(sum / Math.max(1, samples.length - start));
22- const g = rms > 0 ? 10 ** (-16 / 20) / rms : 0;
23+ return rms > 0 ? 10 ** (-16 / 20) / rms : 0;
24+}
25+
26+/** Peak level [dBFS] the signal would reach before clipping. */
27+export function peakDbfs(samples: Float32Array, gain: number): number {
28+ let max = 0;
29+ for (let k = 0; k < samples.length; k++) max = Math.max(max, Math.abs(samples[k]));
30+ return max > 0 ? 20 * Math.log10(max * gain) : -Infinity;
31+}
32+
33+/** Returns a copy of `samples` scaled for playback or export, soft-clipped to [-peak, peak]. */
34+export function normalize(samples: Float32Array, fs: number, mode: LevelMode, gainDb: number, peak = 0.95): Float32Array {
35+ const out = new Float32Array(samples.length);
36+ const g = levelGain(samples, fs, mode, gainDb, peak);
37+ if (mode === "peak") {
38+ for (let k = 0; k < samples.length; k++) out[k] = samples[k] * g;
39+ return out;
40+ }
2341 for (let k = 0; k < samples.length; k++) out[k] = peak * Math.tanh((samples[k] * g) / peak);
2442 return out;
2543 }
src/audio/player.tsmodified+2−2View file
@@ -20,13 +20,13 @@ export class Player {
2020 return Math.min(this.length, this.offset + this.ctx.currentTime - this.startedAt);
2121 }
2222
23- play(samples: Float32Array, fs: number, offset = 0, mode: LevelMode = "peak"): void {
23+ play(samples: Float32Array, fs: number, offset = 0, mode: LevelMode = "peak", gainDb = 0): void {
2424 this.stop();
2525 if (samples.length === 0) return;
2626 if (!this.ctx) this.ctx = new AudioContext({ sampleRate: fs });
2727 const ctx = this.ctx;
2828 const buffer = ctx.createBuffer(1, samples.length, fs);
29- buffer.getChannelData(0).set(normalize(samples, fs, mode, 0.9));
29+ buffer.getChannelData(0).set(normalize(samples, fs, mode, gainDb, 0.9));
3030 const src = ctx.createBufferSource();
3131 src.buffer = buffer;
3232 src.connect(ctx.destination);
src/audio/wav.tsmodified+2−2View file
@@ -2,8 +2,8 @@
22
33 import { normalize, type LevelMode } from "./level.ts";
44
5-export function encodeWav(samples: Float32Array, fs: number, mode: LevelMode = "peak"): Blob {
6- const scaled = normalize(samples, fs, mode, 0.99);
5+export function encodeWav(samples: Float32Array, fs: number, mode: LevelMode = "peak", gainDb = 0): Blob {
6+ const scaled = normalize(samples, fs, mode, gainDb, 0.99);
77 const buf = new ArrayBuffer(44 + samples.length * 2);
88 const v = new DataView(buf);
99 const str = (o: number, s: string) => { for (let k = 0; k < s.length; k++) v.setUint8(o + k, s.charCodeAt(k)); };
src/main.tsmodified+37−5View file
@@ -6,7 +6,7 @@ import { Renderer, type ViewMode } from "./render/renderer.ts";
66 import { Spectrogram } from "./audio/spectrogram.ts";
77 import { Player } from "./audio/player.ts";
88 import { encodeWav } from "./audio/wav.ts";
9-import { type LevelMode } from "./audio/level.ts";
9+import { levelGain, peakDbfs, type LevelMode } from "./audio/level.ts";
1010
1111 const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
1212
@@ -321,6 +321,25 @@ const btnLive = mkButton("Live");
321321 const btnPlay = mkButton("Play");
322322 const btnWav = mkButton("Download .wav");
323323 const levelSel = document.createElement("select");
324+const gainSlider = document.createElement("input");
325+const gainLabel = document.createElement("span");
326+const peakLabel = document.createElement("span");
327+
328+function gainDb(): number {
329+ return parseFloat(gainSlider.value) || 0;
330+}
331+
332+function updateLevelReadout(): void {
333+ gainLabel.textContent = `${gainDb() >= 0 ? "+" : ""}${gainDb()} dB`;
334+ gainSlider.disabled = levelSel.value !== "absolute";
335+ if (!sim || sim.samples === 0) { peakLabel.textContent = ""; return; }
336+ const audio = sim.processedAudio();
337+ const db = peakDbfs(audio, levelGain(audio, sim.fs, levelSel.value as LevelMode, gainDb()));
338+ peakLabel.textContent = isFinite(db) ? `peak ${db.toFixed(1)} dBFS` : "";
339+ peakLabel.className = db > 0 ? "warn" : "sub";
340+ peakLabel.style.margin = "0";
341+ if (db > 0) peakLabel.textContent += " (clipped)";
342+}
324343 let exagInput: HTMLInputElement;
325344 let rangeInput: HTMLInputElement;
326345 let shapeCheck: HTMLInputElement;
@@ -361,26 +380,38 @@ function buildMovieToolbar(): void {
361380 if (!sim || sim.samples === 0) return;
362381 const offset = live ? 0 : scrubFrame / sim.fps;
363382 live = false;
364- player.play(sim.processedAudio(), sim.fs, offset, levelSel.value as LevelMode);
383+ player.play(sim.processedAudio(), sim.fs, offset, levelSel.value as LevelMode, gainDb());
365384 btnPlay.textContent = "Stop";
366385 updateLiveButton();
367386 });
368387 player.onEnded = () => { btnPlay.textContent = "Play"; };
369388 btnWav.addEventListener("click", () => {
370389 if (!sim || sim.samples === 0) return;
371- const blob = encodeWav(sim.processedAudio(), sim.fs, levelSel.value as LevelMode);
390+ const blob = encodeWav(sim.processedAudio(), sim.fs, levelSel.value as LevelMode, gainDb());
372391 const a = document.createElement("a");
373392 a.href = URL.createObjectURL(blob);
374393 a.download = `sheet_${sim.samples}samples_${sim.fs}Hz.wav`;
375394 a.click();
376395 setTimeout(() => URL.revokeObjectURL(a.href), 5000);
377396 });
378- for (const [v, t] of [["loudness", "Loudness (RMS, soft clip)"], ["peak", "Peak"]]) {
397+ for (const [v, t] of [["absolute", "Absolute (0 dBFS = 1 Pa at 0 dB)"], ["loudness", "Loudness (RMS, soft clip)"], ["peak", "Peak"]]) {
379398 const o = document.createElement("option");
380399 o.value = v; o.textContent = t;
381400 levelSel.appendChild(o);
382401 }
383- tb.append(btnPlay, btnLive, scrub, timeLabel, labeled("Level", levelSel), btnWav);
402+ gainSlider.type = "range";
403+ gainSlider.min = "-40"; gainSlider.max = "60"; gainSlider.step = "1"; gainSlider.value = "20";
404+ gainSlider.style.width = "120px";
405+ gainSlider.style.flex = "none";
406+ const restart = () => {
407+ updateLevelReadout();
408+ const pos = player.position();
409+ if (pos !== null && sim) player.play(sim.processedAudio(), sim.fs, pos, levelSel.value as LevelMode, gainDb());
410+ };
411+ gainSlider.addEventListener("input", restart);
412+ levelSel.addEventListener("change", restart);
413+ tb.append(btnPlay, btnLive, scrub, timeLabel, labeled("Level", levelSel), labeled("Gain", gainSlider), gainLabel, peakLabel, btnWav);
414+ updateLevelReadout();
384415 updateLiveButton();
385416 }
386417
@@ -464,6 +495,7 @@ async function runLoop(): Promise<void> {
464495 spec.update(audio, sim.samples);
465496 scrub.max = String(Math.max(0, sim.frames.length - 1));
466497 if (live) scrub.value = scrub.max;
498+ updateLevelReadout();
467499 const now = performance.now();
468500 if (now - lastStatsTime > 500) {
469501 stepsPerSec = ((sim.totalSteps - lastStatsSteps) * 1000) / (now - lastStatsTime);
src/params.tsmodified+3−3View file
@@ -43,8 +43,8 @@ export type Excitation = "push" | "strike";
4343 export type BowShape = "none" | "width" | "height" | "dish";
4444
4545 export const defaultParams: PhysParams = {
46- Lx: 18 * 0.0254,
47- Ly: 12 * 0.0254,
46+ Lx: 12 * 0.0254,
47+ Ly: 18 * 0.0254,
4848 h: 0.79e-3,
4949 E: 193e9,
5050 nu: 0.29,
@@ -60,7 +60,7 @@ export const defaultParams: PhysParams = {
6060 releaseTime: 0.02,
6161 settleSigma0: 300,
6262 settleKE: 1e-8,
63- spacing: 0.004,
63+ spacing: 0.008,
6464 fs: 48000,
6565 fps: 120,
6666 duration: 4,