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:
@@ -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
|
||||
Reference in New Issue
Block a user