v0.52.0: volume normalization — every song plays at one loudness

Measured once per file with ffmpeg's ebur128 (cached on this machine),
applied as a gain on the output volume; the next songs are measured
ahead and an unmeasured one glides to its level. Toggle in Controls and
Preferences → General.
This commit is contained in:
2026-10-13 13:29:37 -07:00
parent 78338d84a7
commit 6178f5d0f7
8 changed files with 571 additions and 7 deletions
+189
View File
@@ -0,0 +1,189 @@
"""Round 83: volume normalization.
Every song is measured once (ffmpeg's ebur128, integrated LUFS), cached per
machine, and played at TARGET_LUFS by a gain on the output volume — a DJ
mustn't have to ride the volume between a quiet old record and a loud new
one. A song not measured yet starts at a typical song's gain and glides to
its own when the measurement lands.
"""
import math
import shutil
import subprocess
import time
import pytest
from PyQt6.QtMultimedia import QAudio
from lintunes import loudness
from lintunes.player import volume_with_gain
from lintunes.preferences import Preferences
from tests.test_round11 import _FakeLastFm
from tests.test_round29 import _player, _local_track, _FakeSink
SUMMARY = """[Parsed_ebur128_0 @ 0x1] Summary:
Integrated loudness:
I: -9.3 LUFS
Threshold: -19.4 LUFS
"""
class _FakeAnalyzer(loudness.LoudnessAnalyzer):
"""Measures nothing on its own: the test says what the file measured."""
def __init__(self, tmp_path, known=None):
super().__init__(cache_path=tmp_path / "loudness.json",
measure=lambda path: None)
self.known = dict(known or {})
self.requested = []
def lufs(self, path):
return (True, self.known[path]) if path in self.known else (False, None)
def request(self, path, urgent=False):
self.requested.append((path, urgent))
def land(self, path, lufs):
self.known[path] = lufs
self.measured.emit(path, lufs)
def _linear(level):
return QAudio.convertVolume(level, QAudio.VolumeScale.LogarithmicVolumeScale,
QAudio.VolumeScale.LinearVolumeScale)
class TestMath:
def test_parse(self):
assert loudness.parse_ebur128(SUMMARY) == -9.3
def test_parse_silence_and_garbage(self):
assert loudness.parse_ebur128(SUMMARY.replace("-9.3", "-70.0")) is None
assert loudness.parse_ebur128("No such file") is None
def test_gain_is_clamped(self):
assert loudness.gain_db(-9.0) == -5.0
assert loudness.gain_db(-40.0) == loudness.MAX_BOOST_DB
assert loudness.gain_db(None) == 0.0
def test_gain_follows_qts_own_volume_curve(self):
# -6 dB halves the amplitude QAudioOutput actually plays.
out = volume_with_gain(0.8, -20 * math.log10(2))
assert _linear(out) == pytest.approx(_linear(0.8) / 2, rel=1e-3)
def test_no_gain_and_no_volume_change_nothing(self):
assert volume_with_gain(0.6, 0.0) == 0.6
assert volume_with_gain(0.0, 6.0) == 0.0
def test_a_boost_cant_pass_full(self):
assert volume_with_gain(0.95, 12.0) == 1.0
class TestCache:
def test_remembers_by_size_and_mtime(self, qapp, tmp_path):
song = tmp_path / "a.mp3"
song.write_bytes(b"x" * 10)
cache = tmp_path / "cache" / "loudness.json"
analyzer = loudness.LoudnessAnalyzer(cache_path=cache)
assert analyzer.lufs(str(song)) == (False, None)
analyzer._remember(str(song), -11.0)
analyzer.save()
again = loudness.LoudnessAnalyzer(cache_path=cache)
assert again.lufs(str(song)) == (True, -11.0)
song.write_bytes(b"x" * 11) # retagged: measure again
assert again.lufs(str(song)) == (False, None)
def test_measures_on_its_thread(self, qapp, tmp_path):
song = tmp_path / "a.mp3"
song.write_bytes(b"x")
got = []
analyzer = loudness.LoudnessAnalyzer(
cache_path=tmp_path / "l.json", measure=lambda p: -12.5)
analyzer.measured.connect(lambda p, v: got.append((p, v)))
analyzer.request(str(song))
for _ in range(200):
qapp.processEvents()
if got:
break
time.sleep(0.01)
assert got == [(str(song), -12.5)]
assert analyzer.lufs(str(song)) == (True, -12.5)
class TestPlayer:
def _setup(self, qapp, tmp_path, known):
tracks = [_local_track(tmp_path, i) for i in (1, 2, 3, 4)]
player = _player(qapp, tracks)
player.loudness = _FakeAnalyzer(
tmp_path, {tracks[i - 1].location: v for i, v in known.items()})
sink = _FakeSink()
player.set_sink(sink)
player.set_volume(0.8)
return player, sink, tracks
def test_off_by_default(self, qapp, tmp_path):
player, sink, _ = self._setup(qapp, tmp_path, {1: -6.0})
player.play_queue([1, 2], 0)
assert sink.volume == pytest.approx(0.8)
assert player.loudness.requested == []
def test_a_measured_song_starts_at_its_own_level(self, qapp, tmp_path):
player, sink, _ = self._setup(qapp, tmp_path, {1: -6.0, 2: -20.0})
player.set_normalize(True)
player.play_queue([1, 2], 0)
assert sink.volume == pytest.approx(volume_with_gain(0.8, -8.0))
player.next()
assert sink.volume == pytest.approx(volume_with_gain(0.8, 6.0))
assert player.volume() == 0.8 # the slider is untouched
def test_the_next_songs_are_measured_ahead(self, qapp, tmp_path):
player, _, tracks = self._setup(qapp, tmp_path, {})
player.set_normalize(True)
player.play_queue([1, 2, 3, 4], 0)
assert player.loudness.requested == [
(tracks[0].location, True),
(tracks[1].location, False), (tracks[2].location, False)]
def test_an_unmeasured_song_glides_to_its_level(self, qapp, tmp_path):
player, sink, tracks = self._setup(qapp, tmp_path, {})
player.set_normalize(True)
player.play_queue([1], 0)
typical = loudness.gain_db(loudness.TYPICAL_LUFS)
assert sink.volume == pytest.approx(volume_with_gain(0.8, typical))
player.loudness.land(tracks[0].location, -24.0)
assert player._glide.isActive()
while player._glide.isActive():
player._glide_step()
assert sink.volume == pytest.approx(volume_with_gain(0.8, 10.0))
def test_turning_it_off_glides_back(self, qapp, tmp_path):
player, sink, _ = self._setup(qapp, tmp_path, {1: -6.0})
player.set_normalize(True)
player.play_queue([1], 0)
player.set_normalize(False)
while player._glide.isActive():
player._glide_step()
assert sink.volume == pytest.approx(0.8)
def test_preferences_and_menu_agree(qapp, tmp_path, monkeypatch):
from lintunes.gui.preferences_dialog import PreferencesDialog
monkeypatch.setattr(loudness, "ffmpeg_available", lambda: True)
prefs = Preferences(tmp_path)
dialog = PreferencesDialog(prefs, _FakeLastFm())
assert not dialog._normalize_check.isChecked()
dialog._normalize_check.setChecked(True)
assert prefs.get("normalize_volume") is True
prefs.set("normalize_volume", False) # Controls → Normalize Volume
assert not dialog._normalize_check.isChecked()
dialog.deleteLater()
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="needs ffmpeg")
def test_real_ffmpeg_measures_a_tone(tmp_path):
song = tmp_path / "tone.flac"
subprocess.run(["ffmpeg", "-loglevel", "error", "-f", "lavfi", "-i",
"sine=f=1000:d=5:sample_rate=44100",
str(song)], check=True)
lufs = loudness.measure_lufs(str(song))
assert lufs is not None and -26 < lufs < -16 # a 1/8-amplitude sine