"""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