From 1479854503accb0955147dd805a3a68ffbbef47c Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 6 Nov 2026 13:34:04 +0100 Subject: [PATCH] feat(scripts): prototype the momentary fatness lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/ideas.md | 34 ++++---- references/README.md | 9 +++ scripts/fatness.py | 181 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 scripts/fatness.py diff --git a/docs/ideas.md b/docs/ideas.md index 623f03c..25ea099 100644 --- a/docs/ideas.md +++ b/docs/ideas.md @@ -97,14 +97,21 @@ From the papers in `research/` (local, not committed). Each finding names where it goes in the suite. They are starting points to test by ear, not rules. - **A published bass fatness model.** Schmedeke's dissertation (Hamburg 2021, - "BASS DROP!") fits a regression on 4,070 ratings by DJs and club-goers, - measured at sixteenth-note resolution: - `Fatness = 1.743 − 0.013·Centroid<200Hz + 0.080·Loudness<200Hz − 0.041·Loudness>200Hz + 5.923·SNR<200Hz` - (adjusted R² 0.597). The strongest term is how tonal the band below 200 Hz - is (β 0.616), and loudness above 200 Hz lowers fatness. Anvil's clean sine - maximises the strongest term; Quench keeps it when the top is clipped. Goes - into Assay as one lane, never as an overall score. Before building, check the - thesis's exact SNR and loudness definitions (units, windows). + "BASS DROP!", section 5.4.3) models momentary fatness of dance-music excerpts + over 50 ms windows from two inputs only, with adjusted R² 0.773: + `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 = 0.14689·l·h` (0 to 14.7). SPL<200 is the C-weighted level below + 200 Hz in dB(C) with the material normalised to −23 LUFS; HR<200 is the + MPEG-7 harmonic ratio (how periodic) of the same band. Chapter 4's earlier + linear model on synthetic tones (R² 0.597) points the same way: a tonal low + band is fatter, a noisy one is not. Anvil's clean sine maximises HR; Quench + keeps it when the top is clipped. The thesis does not state the dB(C) of its + reference, so the dBFS-to-dB(C) offset is an assumption. Measured with + `scripts/fatness.py` on the seven Devath references (offset 100 dB): drops + sit at 4–6, breakdowns near 0, kick gaps show as 50 ms dips; SPL<200 medians + are 67–69 dB(C), a few dB under the thesis's "mostly 70–80", so the offset + may be 2–5 dB low. Goes into Assay as one lane, never as an overall score. - **Virtual bass from known harmonics.** Moliner, Rämö and Välimäki (DAFx 2020) boost the harmonics that exist with weights that follow the sound's spectral envelope (limits 10 dB/oct below, 3 dB/oct above) and generate the missing @@ -348,11 +355,12 @@ patterns. No name yet. ### Fatness lane (problems 2, 8) -Momentary fatness per sixteenth note from Schmedeke's model (see Research -findings), drawn as one lane next to sub/mid energy, for a patch and a -reference. It explains why two drops at the same LUFS feel different in weight: -noise below 200 Hz and loud upper bands both lower the value. The first piece -of Assay. +Momentary fatness per 50 ms from Schmedeke's model (see Research findings), +drawn as one lane next to sub/mid energy, for a patch and a reference. It +explains why two drops at the same LUFS feel different in weight: a quieter or +noisier low band lowers the value. Prototype: `scripts/fatness.py` (offline, +on rendered files). The first piece of Assay. Open: the dB(C) offset, and how +the lane reads on a single bass patch rather than a full mix. ### Drop compare (problem 8) diff --git a/references/README.md b/references/README.md index 9432286..312a10c 100644 --- a/references/README.md +++ b/references/README.md @@ -12,3 +12,12 @@ python scripts/analyze-references.py Measurements are written to `docs/rivet/research/measurements/`. The script requires ffmpeg, NumPy, and Matplotlib. It measures level, spectral attacks, and texture changes; it does not transcribe notes or infer emotion. + +For the momentary bass fatness lane (Schmedeke's model, see `docs/ideas.md`): + +```sh +python scripts/fatness.py --output target/fatness +``` + +It writes `fatness.png` and `fatness.json`; `--offset` sets the assumed dB(C) +of a 0 dBFS RMS signal (default 100). diff --git a/scripts/fatness.py b/scripts/fatness.py new file mode 100644 index 0000000..138c41e --- /dev/null +++ b/scripts/fatness.py @@ -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=' -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()