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.
190 lines
7.2 KiB
Python
190 lines
7.2 KiB
Python
"""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
|