feat(scripts): prototype the momentary fatness lane

scripts/fatness.py measures momentary bass fatness of rendered files with
the final model from Schmedeke's dissertation (section 5.4.3, adjusted R²
.773): two logistic terms on the C-weighted level and the MPEG-7 harmonic
ratio below 200 Hz, over 50 ms windows, after normalising to -23 LUFS
(BS.1770 integrated, gated).

The thesis does not state the dB(C) of its reference, so --offset maps
dBFS to dB(C) (default 100). On the seven Devath references, drops read
4-6, breakdowns near 0, and kick gaps show as 50 ms dips; SPL<200 medians
of 67-69 dB(C) suggest the offset may be 2-5 dB low.

Also corrects docs/ideas.md, which quoted the thesis's preliminary linear
model instead of the final one.
This commit is contained in:
nvrl committed 2026-11-06 13:34:04 +01:00
1 parent 8ecdc03832
commit 1479854503
3 files changed
+211 -13

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
"""Momentary bass fatness of rendered audio, after Schmedeke's dissertation.
Model (L. Schmedeke, "BASS DROP! Bass in modern popular Dance Music",
Hamburg 2021, section 5.4.3, adjusted R² .773 on dance-music excerpts):
l = 10 / (1 + 4.5487 · exp(0.13 · (62.9366 − SPL<200)))
h = 10 / (1 + 1.4317 · exp(2.5575 · (0.3299 − HR<200)))
Fat_mom = 0.14689 · l · h (0 .. 14.7)
SPL<200 is the C-weighted sound-pressure level below 200 Hz in dB(C), HR<200
the MPEG-7 harmonic ratio of the same band, both over 50 ms windows, with the
material normalised to −23 LUFS first.
Assumption: the thesis does not give the dB(C) its reference tone played at.
`--offset` maps dBFS to dB(C); the default 100 dB puts −20 dBFS RMS at 80 dB(C),
a usual studio calibration. The thesis reports most complex excerpts at 70–80
dB(C) below 200 Hz; the summary prints where the material lands so the offset
can be checked.
Requires ffmpeg, numpy and matplotlib. Describes one model's view of the low
band; it is not a quality score.
"""
import os
os.environ.setdefault('MPLCONFIGDIR', '/tmp/fatness-matplotlib')
import argparse
import json
import subprocess
from pathlib import Path
import numpy as np
RATE = 48000
WINDOW_S = 0.05
HR_RATE = 4000
HR_MAX_LAG_S = 0.04
def load(path):
raw = subprocess.check_output(['ffmpeg', '-v', 'error', '-i', str(path), '-f', 'f32le',
'-ac', '2', '-ar', str(RATE), 'pipe:1'])
return np.frombuffer(raw, dtype='<f4').reshape(-1, 2).T.astype(np.float64)
def k_weight_db(f):
"""Magnitude of the BS.1770 K-weighting (shelf + high-pass) at `f` Hz, in dB."""
def biquad(b, a):
z = np.exp(-2j * np.pi * f / RATE)
return (b[0] + b[1] * z + b[2] * z * z) / (a[0] + a[1] * z + a[2] * z * z)
shelf = biquad([1.53512485958697, -2.69169618940638, 1.19839281085285],
[1.0, -1.69065929318241, 0.73248077421585])
high = biquad([1.0, -2.0, 1.0], [1.0, -1.99004745483398, 0.99007225036621])
return 20 * np.log10(np.abs(shelf * high) + 1e-30)
def c_weight_db(f):
"""IEC 61672 C-weighting at `f` Hz, in dB."""
f2 = np.maximum(f, 1e-3) ** 2
r = (12194.0 ** 2 * f2) / ((f2 + 20.6 ** 2) * (f2 + 12194.0 ** 2))
return 20 * np.log10(r) + 0.06
def filtered(x, gain_db):
"""Zero-phase filter by an FFT magnitude response; energy is what is measured."""
n = x.shape[-1]
size = 1 << (n - 1).bit_length() # zero padding: fast FFT sizes only
spec = np.fft.rfft(x, n=size, axis=-1)
f = np.fft.rfftfreq(size, 1 / RATE)
return np.fft.irfft(spec * 10 ** (gain_db(f) / 20), n=size, axis=-1)[..., :n]
def integrated_lufs(x):
"""BS.1770-4 integrated loudness with absolute and relative gates."""
y = filtered(x, k_weight_db)
block, step = int(0.4 * RATE), int(0.1 * RATE)
sq = (y * y).sum(axis=0)
c = np.concatenate([[0.], np.cumsum(sq)])
starts = np.arange(0, len(sq) - block + 1, step)
power = (c[starts + block] - c[starts]) / block
loud = -0.691 + 10 * np.log10(power + 1e-30)
keep = power[loud > -70]
if keep.size == 0:
return -np.inf
relative = -0.691 + 10 * np.log10(keep.mean()) - 10
keep = power[(loud > -70) & (loud > relative)]
return -0.691 + 10 * np.log10(keep.mean())
def band_below(x, hz):
return filtered(x, lambda f: np.where(f < hz, 0.0, -300.0))
def harmonic_ratio(mono, rate):
"""MPEG-7 harmonic ratio per 50 ms window: the largest normalised
autocorrelation after the zero-lag lobe, with lags up to 40 ms."""
n = int(WINDOW_S * rate)
lags = int(HR_MAX_LAG_S * rate)
out = []
for start in range(lags, len(mono) - n + 1, n):
s = mono[start:start + n]
e0 = s @ s
if e0 <= 1e-12:
out.append(0.0)
continue
# Row m − 1 holds the window delayed by m samples.
past = np.lib.stride_tricks.sliding_window_view(mono[start - lags:start - 1 + n], n)[::-1]
gamma = (past @ s) / np.sqrt(e0 * np.einsum('ij,ij->i', past, past) + 1e-30)
below = np.nonzero(gamma <= 0)[0]
if below.size == 0:
out.append(0.0)
continue
out.append(float(max(gamma[below[0]:].max(), 0.0)))
return np.array(out), lags
def fatness(spl, hr):
l = 10 / (1 + 4.5487 * np.exp(0.13 * (62.9366 - spl)))
h = 10 / (1 + 1.4317 * np.exp(2.5575 * (0.3299 - hr)))
return 0.14689 * l * h
def measure(path, offset):
x = load(path)
lufs = integrated_lufs(x)
x = x * 10 ** ((-23 - lufs) / 20)
low = band_below(x, 200.)
mono = low.mean(axis=0)
# HR on a 4 kHz copy; the band is below 200 Hz, so nothing aliases.
hr, skip = harmonic_ratio(mono[::RATE // HR_RATE], HR_RATE)
weighted = filtered(low, c_weight_db)
n = int(WINDOW_S * RATE)
first = skip * (RATE // HR_RATE)
sq = (weighted * weighted).sum(axis=0) / 2
frames = len(hr)
spl = np.array([10 * np.log10(sq[first + i * n:first + (i + 1) * n].mean() + 1e-30)
for i in range(frames)]) + offset
times = (first + np.arange(frames) * n) / RATE
return lufs, times, spl, hr, fatness(spl, hr)
def main():
parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
parser.add_argument('files', nargs='*', type=Path)
parser.add_argument('--folder', type=Path, default=Path('references/devath'))
parser.add_argument('--offset', type=float, default=100.0,
help='dB(C) for a 0 dBFS RMS signal (default 100)')
parser.add_argument('--output', type=Path, required=True,
help='folder for fatness.png and fatness.json')
args = parser.parse_args()
files = args.files or sorted(args.folder.glob('*.flac'))
if not files:
parser.error('no audio files')
args.output.mkdir(parents=True, exist_ok=True)
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
fig, axes = plt.subplots(len(files), 1, squeeze=False, figsize=(15, 2.2 * len(files)),
constrained_layout=True)
summary = []
for ax, path in zip(axes[:, 0], files):
lufs, t, spl, hr, fat = measure(path, args.offset)
smooth = np.convolve(fat, np.ones(8) / 8, mode='same') # 400 ms
pct = lambda v: {str(p): round(float(np.percentile(v, p)), 3) for p in (10, 50, 90)}
row = {'file': path.name, 'lufs': round(float(lufs), 2), 'spl_c_below_200': pct(spl),
'hr_below_200': pct(hr), 'fatness': pct(fat),
'share_spl_70_80': round(float(np.mean((spl >= 70) & (spl <= 80))), 3)}
summary.append(row)
print(json.dumps(row))
ax.plot(t, fat, color='#ccc', lw=0.5)
ax.plot(t, smooth, color='#e0115f', lw=1.2)
ax.set_ylim(0, 14.7)
ax.set_xlim(0, t[-1] if len(t) else 1)
ax.set_title(f'{path.stem} ({lufs:.1f} LUFS, normalised to -23)', loc='left', fontsize=9)
ax.set_ylabel('Fat_mom')
axes[-1, 0].set_xlabel('seconds')
fig.savefig(args.output / 'fatness.png', dpi=110)
(args.output / 'fatness.json').write_text(json.dumps(
{'offset_db': args.offset, 'window_s': WINDOW_S, 'tracks': summary}, indent=2))
if __name__ == '__main__':
main()