feat: level the mirror's volume with ReplayGain tags
Build and publish container / build (pull_request) Successful in 7m41s
Build and publish container / build (pull_request) Successful in 7m41s
Rockbox applies the offset a ReplayGain tag carries but has no loudness analysis of its own, so an untagged mirror plays every album at whatever level it was mastered to. Albums are measured with rsgain once their tracks are in place, album gain and track gain both, leaving the device to choose between them. An album is re-measured as a whole whenever it gains, loses or replaces a track, because album gain is a property of all of its tracks and one new track makes the value stored on every sibling wrong. rsgain runs with --preserve-mtimes. Staleness here is an mtime comparison and tagging rewrites the file, so without it every levelled track would look newer than its source and the next pass would re-encode the whole library. Whether a file has already been levelled is decided by walking its ID3v2 frame headers and seeking over the bodies. Cover art is embedded in every mirror file, so reading the tag whole would turn an idle pass into a full read of the library. A missing rsgain is reported and then left alone rather than failing the pass: the mirror is still correct audio in the right place. Two existing tests move with the change. ffprobe's csv writer renders the ReplayGain side data as a trailing empty field, and a copied MP3 now differs from its source in the container while carrying identical audio. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8d6885c46a
commit
c63115f246
+195
-2
@@ -14,6 +14,16 @@ def run(source, mirror, *extra):
|
||||
return music_mirror.main(["--source", str(source), "--mirror", str(mirror), *extra])
|
||||
|
||||
|
||||
def audio_frames(path):
|
||||
"""Return a hash of a file's audio frames, ignoring its tags."""
|
||||
return subprocess.run(
|
||||
["ffmpeg", "-v", "error", "-i", str(path), "-map", "0:a", "-c", "copy", "-f", "md5", "-"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def test_parse_quality_accepts_vbr_and_cbr():
|
||||
assert music_mirror.parse_quality("V0") == ["-q:a", "0"]
|
||||
assert music_mirror.parse_quality("v2") == ["-q:a", "2"]
|
||||
@@ -61,8 +71,10 @@ def test_output_is_mp3(tmp_path, make_flac):
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name",
|
||||
# Not csv: a levelled file carries ReplayGain side data, which the
|
||||
# csv writer renders as a trailing empty field.
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(mirror / "a.mp3"),
|
||||
],
|
||||
check=True,
|
||||
@@ -140,6 +152,9 @@ def test_no_prune_keeps_orphans(tmp_path, make_flac):
|
||||
|
||||
|
||||
def test_existing_mp3_is_copied_not_re_encoded(tmp_path, make_flac):
|
||||
"""Compared by the audio frames rather than the whole file: the copy is
|
||||
tagged with its ReplayGain values afterwards, so the two differ in the
|
||||
container while carrying identical audio."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
flac = make_flac(source / "a.flac")
|
||||
@@ -152,7 +167,7 @@ def test_existing_mp3_is_copied_not_re_encoded(tmp_path, make_flac):
|
||||
|
||||
run(source, mirror)
|
||||
|
||||
assert (mirror / "b.mp3").read_bytes() == (source / "b.mp3").read_bytes()
|
||||
assert audio_frames(mirror / "b.mp3") == audio_frames(source / "b.mp3")
|
||||
|
||||
|
||||
def test_interrupted_copy_leaves_nothing_behind(tmp_path, make_flac, monkeypatch):
|
||||
@@ -786,3 +801,181 @@ def test_the_budget_is_reported_so_it_can_be_checked(tmp_path, make_flac, caplog
|
||||
run(source, mirror, "--fat32-safe")
|
||||
|
||||
assert "limited to 253 characters" in caplog.text
|
||||
|
||||
|
||||
# Rockbox applies the offset a ReplayGain tag carries but never measures
|
||||
# loudness itself, so an untagged mirror plays each album at whatever level it
|
||||
# was mastered to. The tags have to be written here or nowhere.
|
||||
|
||||
|
||||
def test_replaygain_tags_are_written(tmp_path, make_flac, probe_tag, require_rsgain):
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "a.flac")
|
||||
|
||||
run(source, mirror)
|
||||
|
||||
track = mirror / "Album" / "a.mp3"
|
||||
assert probe_tag(track, "REPLAYGAIN_TRACK_GAIN").endswith("dB")
|
||||
assert probe_tag(track, "REPLAYGAIN_ALBUM_GAIN").endswith("dB")
|
||||
assert probe_tag(track, "REPLAYGAIN_TRACK_PEAK")
|
||||
|
||||
|
||||
def test_the_album_shares_one_gain_and_the_tracks_keep_their_own(
|
||||
tmp_path, make_flac, probe_tag, require_rsgain
|
||||
):
|
||||
"""Album gain is what keeps a quiet track quiet within a record it belongs
|
||||
to. Track gain is written alongside it so the device can pick the other
|
||||
behaviour when shuffling."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "loud.flac")
|
||||
make_flac(source / "Album" / "quiet.flac", gain=-12)
|
||||
|
||||
run(source, mirror)
|
||||
|
||||
loud = mirror / "Album" / "loud.mp3"
|
||||
quiet = mirror / "Album" / "quiet.mp3"
|
||||
assert probe_tag(loud, "REPLAYGAIN_ALBUM_GAIN") == probe_tag(quiet, "REPLAYGAIN_ALBUM_GAIN")
|
||||
assert probe_tag(loud, "REPLAYGAIN_TRACK_GAIN") != probe_tag(quiet, "REPLAYGAIN_TRACK_GAIN")
|
||||
|
||||
|
||||
def test_levelling_does_not_make_the_next_pass_re_encode(
|
||||
tmp_path, make_flac, caplog, require_rsgain
|
||||
):
|
||||
"""Writing tags rewrites the file, and staleness here is an mtime
|
||||
comparison. Without --preserve-mtimes every pass would re-encode the whole
|
||||
library and then level it again, forever."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "a.flac")
|
||||
|
||||
run(source, mirror)
|
||||
before = (mirror / "Album" / "a.mp3").stat().st_mtime
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
run(source, mirror)
|
||||
|
||||
assert "0 encoded" in caplog.text
|
||||
assert (mirror / "Album" / "a.mp3").stat().st_mtime == before
|
||||
|
||||
|
||||
def test_an_already_levelled_album_is_not_measured_again(
|
||||
tmp_path, make_flac, caplog, require_rsgain
|
||||
):
|
||||
"""Measuring costs a decode of every track. A pass that changed nothing
|
||||
must not pay it."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "a.flac")
|
||||
run(source, mirror)
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
run(source, mirror)
|
||||
|
||||
assert "0 levelled" in caplog.text
|
||||
|
||||
|
||||
def test_a_new_track_relevels_the_album_around_it(
|
||||
tmp_path, make_flac, probe_tag, require_rsgain
|
||||
):
|
||||
"""Album gain is a property of the whole album, so a track arriving late
|
||||
makes the value stored on every one of its siblings wrong.
|
||||
|
||||
-10 dB rather than something more dramatic: R128 gates quiet passages out
|
||||
of the measurement, and a track far enough below the rest of the record is
|
||||
excluded from it entirely."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "a.flac")
|
||||
run(source, mirror)
|
||||
first = probe_tag(mirror / "Album" / "a.mp3", "REPLAYGAIN_ALBUM_GAIN")
|
||||
|
||||
make_flac(source / "Album" / "b.flac", gain=-10)
|
||||
run(source, mirror)
|
||||
|
||||
assert probe_tag(mirror / "Album" / "a.mp3", "REPLAYGAIN_ALBUM_GAIN") != first
|
||||
|
||||
|
||||
def test_a_removed_track_relevels_the_album_behind_it(
|
||||
tmp_path, make_flac, caplog, require_rsgain
|
||||
):
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "a.flac")
|
||||
make_flac(source / "Album" / "b.flac", gain=-20)
|
||||
run(source, mirror)
|
||||
|
||||
(source / "Album" / "b.flac").unlink()
|
||||
with caplog.at_level("INFO"):
|
||||
run(source, mirror)
|
||||
|
||||
assert "1 levelled" in caplog.text
|
||||
|
||||
|
||||
def test_embedded_cover_art_does_not_hide_the_tags(
|
||||
tmp_path, make_flac, make_cover, require_rsgain
|
||||
):
|
||||
"""The check walks ID3v2 frame headers and seeks over the bodies. Cover art
|
||||
sits between the text frames and the ones rsgain appends, so a check that
|
||||
only read the start of the tag would never reach them -- and would measure
|
||||
every album with a cover on every pass."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "a.flac")
|
||||
make_cover(source / "Album" / "cover.jpg")
|
||||
|
||||
run(source, mirror)
|
||||
|
||||
assert music_mirror.has_replaygain(mirror / "Album" / "a.mp3")
|
||||
|
||||
|
||||
def test_no_replaygain_leaves_the_tags_off(tmp_path, make_flac, probe_tag):
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "a.flac")
|
||||
|
||||
run(source, mirror, "--no-replaygain")
|
||||
|
||||
assert not probe_tag(mirror / "Album" / "a.mp3", "REPLAYGAIN_ALBUM_GAIN")
|
||||
assert not music_mirror.has_replaygain(mirror / "Album" / "a.mp3")
|
||||
|
||||
|
||||
def test_an_unlevelled_mirror_is_backfilled(tmp_path, make_flac, probe_tag, require_rsgain):
|
||||
"""A mirror built before any of this existed has correct mtimes, so no pass
|
||||
would ever revisit those files on its own."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "a.flac")
|
||||
run(source, mirror, "--no-replaygain")
|
||||
|
||||
run(source, mirror)
|
||||
|
||||
assert probe_tag(mirror / "Album" / "a.mp3", "REPLAYGAIN_ALBUM_GAIN").endswith("dB")
|
||||
|
||||
|
||||
def test_a_missing_scanner_is_reported_and_not_fatal(tmp_path, make_flac, caplog, monkeypatch):
|
||||
"""The mirror is still correct audio in the right place. It just plays at
|
||||
the level it was mastered to."""
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "a.flac")
|
||||
monkeypatch.setattr(music_mirror.shutil, "which", lambda name: None)
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
assert run(source, mirror) == 0
|
||||
|
||||
assert "rsgain is not on PATH" in caplog.text
|
||||
assert (mirror / "Album" / "a.mp3").is_file()
|
||||
|
||||
|
||||
def test_a_dry_run_measures_nothing(tmp_path, make_flac, caplog, require_rsgain):
|
||||
source = tmp_path / "src"
|
||||
mirror = tmp_path / "dst"
|
||||
make_flac(source / "Album" / "a.flac")
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
run(source, mirror, "--dry-run")
|
||||
|
||||
assert "would level 1 album" in caplog.text
|
||||
assert not mirror.joinpath("Album").exists()
|
||||
|
||||
Reference in New Issue
Block a user