/ proof-of-concept / cymbalsim
Sign in
proof-of-concept / cymbalsim
cymbalsim / src / audio / level.ts
75 lines · 2.7 KBBlameHistoryRaw
1// Playback level. The synthesized signal is a sound pressure in pascals; the
2// gain maps it to full scale either at a fixed calibration (absolute, 1 Pa
3// at 0 dBFS plus a user gain), by its peak, or by its loudness (RMS of the
4// sustained part). A soft clipper above 0.9 of full scale keeps the output
5// bounded whatever the gain.
7export type LevelMode = "absolute" | "loudness" | "peak";
9const KNEE = 0.9;
10const PEAK_TARGET = 10 ** (-1 / 20); // -1 dBFS
11const LOUDNESS_TARGET = 10 ** (-20 / 20); // -20 dBFS RMS
12const LOUDNESS_SKIP_S = 0.05;
14function peakAbs(samples: Float32Array): number {
15 let max = 0;
16 for (let k = 0; k < samples.length; k++) {
17 const a = Math.abs(samples[k]);
18 if (a > max) max = a;
19 }
20 return max;
23/**
24 * Gain that maps the pressure signal [Pa] to full scale: absolute = 1 Pa at
25 * 0 dBFS shifted by gainDb; peak = normalize the peak to -1 dBFS; loudness =
26 * normalize the RMS of the signal after the first 50 ms to -20 dBFS. A silent
27 * signal gets unit gain in the normalizing modes (there is nothing to scale).
28 */
29export function levelGain(samples: Float32Array, fs: number, mode: LevelMode, gainDb: number): number {
30 if (mode === "absolute") return 10 ** (gainDb / 20);
31 if (mode === "peak") {
32 const max = peakAbs(samples);
33 return max > 0 ? PEAK_TARGET / max : 1;
34 }
35 const start = Math.min(samples.length, Math.round(LOUDNESS_SKIP_S * fs));
36 const count = samples.length - start;
37 if (count <= 0) return 1;
38 let sum = 0;
39 for (let k = start; k < samples.length; k++) sum += samples[k] * samples[k];
40 const rms = Math.sqrt(sum / count);
41 return rms > 0 ? LOUDNESS_TARGET / rms : 1;
44/**
45 * Applies the gain and a soft clipper and returns a new array. Below the
46 * knee (0.9 of full scale) the clipper is the identity; above it the excess
47 * is squashed with a tanh so that the output stays within (-1, 1) and the
48 * slope is continuous at the knee.
49 */
50export function applyLevel(samples: Float32Array, gain: number): Float32Array {
51 const out = new Float32Array(samples.length);
52 const room = 1 - KNEE;
53 for (let k = 0; k < samples.length; k++) {
54 const y = samples[k] * gain;
55 const a = Math.abs(y);
56 if (a <= KNEE) out[k] = y;
57 else {
58 const c = KNEE + room * Math.tanh((a - KNEE) / room);
59 out[k] = y < 0 ? -c : c;
60 }
61 }
62 return out;
65/** Peak level in dBFS of a signal (-Infinity when silent). */
66export function peakDbfs(samples: Float32Array): number {
67 const max = peakAbs(samples);
68 return max > 0 ? 20 * Math.log10(max) : -Infinity;
71/** Peak sound pressure level in dB SPL re 20 uPa of a pressure signal [Pa]. */
72export function peakSpl(samples: Float32Array): number {
73 const max = peakAbs(samples);
74 return max > 0 ? 20 * Math.log10(max / 20e-6) : -Infinity;
↑↓ move↵openescclose