feat: level the mirror's volume with ReplayGain tags
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:
Emma Thorpe
2026-08-28 16:49:37 +01:00
co-authored by Claude Opus 5
parent 8d6885c46a
commit c63115f246
7 changed files with 472 additions and 15 deletions
+203 -3
View File
@@ -11,6 +11,11 @@ writes to the source library.
Staleness is tracked by modification time: an encoded file is given its
source's mtime, so a file is out of date exactly when the two differ. That
makes runs idempotent without a database to keep in step.
Finished albums are levelled with rsgain, which writes ReplayGain tags into the
mirror. Rockbox applies the offset those tags carry but has no loudness
analysis of its own, so without them every album plays at whatever level it was
mastered to.
"""
import argparse
@@ -81,6 +86,21 @@ MIRROR_COVER = "cover.jpg"
# Files the mirror is allowed to contain, and therefore allowed to delete.
MIRROR_SUFFIX = ".mp3"
# Measures loudness and writes the ReplayGain tags. Not a hard requirement: a
# pass without it still produces a correct mirror, only one the player cannot
# level, so a missing binary is a warning rather than a failure.
REPLAYGAIN_TOOL = "rsgain"
# Looked for in a file's ID3v2 tag to tell a levelled track from an unlevelled
# one. Album gain rather than track gain because the album value is the one
# this writes for; a file carrying only track gain came from somewhere else and
# should be rescanned.
REPLAYGAIN_TAG = b"replaygain_album_gain"
# Enough of a TXXX frame body to hold the encoding byte and the description.
# The value after it says what the gain is, which is not the question here.
TXXX_DESCRIPTION_BYTES = 128
# Filesystems disagree about mtime precision; SMB in particular rounds.
MTIME_TOLERANCE_SECONDS = 2
@@ -541,8 +561,13 @@ def prune(mirror_root, expected, dry_run):
Driven by the set of paths the pass expects to exist rather than by
probing the source tree for names, which would disagree with it over
letter case and over any extension the walker does not collect.
Returns the number of files removed and the directories they came out of.
Losing a track changes an album's loudness, so those directories need
levelling again even though nothing was written into them.
"""
removed = 0
emptied = set()
for mirror in sorted(mirror_root.rglob(f"*{MIRROR_SUFFIX}")):
if mirror in expected:
@@ -553,6 +578,7 @@ def prune(mirror_root, expected, dry_run):
continue
logger.info("removing orphan %s", mirror)
mirror.unlink(missing_ok=True)
emptied.add(mirror.parent)
if not dry_run:
# A cover copied for an album whose tracks have all gone is an orphan
@@ -567,7 +593,164 @@ def prune(mirror_root, expected, dry_run):
if directory.is_dir() and not any(directory.iterdir()):
directory.rmdir()
return removed
return removed, emptied
def syncsafe(data):
"""Return the integer held in syncsafe bytes: seven bits of each."""
value = 0
for byte in data:
value = (value << 7) | (byte & 0x7F)
return value
def has_replaygain(path):
"""Return whether an MP3 already carries ReplayGain tags.
Walks the ID3v2 frame headers and seeks over the bodies rather than reading
the tag whole. Every file in this mirror has its cover art embedded, so the
tag is routinely half a megabyte; reading all of it for every track on
every pass would turn an idle pass into a full read of the library.
"""
try:
with open(path, "rb") as handle:
header = handle.read(10)
if len(header) < 10 or header[:3] != b"ID3" or header[3] not in (3, 4):
return False
remaining = syncsafe(header[6:10])
# Unsynchronisation shifts every offset in the tag, and the two
# versions describe an extended header differently. Nothing that
# writes this mirror emits either, so reading the tag whole is a
# cheaper answer than the code to walk one that does.
if header[5] & 0xC0:
return REPLAYGAIN_TAG in handle.read(remaining).lower()
while remaining >= 10:
frame = handle.read(10)
remaining -= 10
# Frame ids are upper-case letters and digits, so anything else
# is the padding that follows the last frame.
if len(frame) < 10 or not frame[:4].isalnum():
return False
# 2.3 sizes count all eight bits per byte; 2.4 made them
# syncsafe like the tag length above.
length = (
int.from_bytes(frame[4:8], "big")
if header[3] == 3
else syncsafe(frame[4:8])
)
if length <= 0 or length > remaining:
return False
if frame[:4] == b"TXXX":
body = handle.read(min(length, TXXX_DESCRIPTION_BYTES))
handle.seek(length - len(body), os.SEEK_CUR)
if REPLAYGAIN_TAG in body.lower():
return True
else:
handle.seek(length, os.SEEK_CUR)
remaining -= length
except OSError:
return False
return False
def replaygain_albums(expected, written):
"""Return the album directories needing a scan, each with its tracks.
A directory is scanned when this pass changed what is in it, because album
gain is a property of the whole album: one track added, replaced or removed
makes the value stored on every one of its siblings wrong. It is also
scanned when a track in it has never been levelled, which is what backfills
a mirror built before any of this existed.
"""
albums = {}
for mirror in expected:
albums.setdefault(mirror.parent, []).append(mirror)
needed = {}
for directory, tracks in sorted(albums.items()):
# A dry run reaches here before anything has been encoded, so the
# tracks a changed album is going to hold do not exist yet.
present = sorted(track for track in tracks if track.is_file())
if directory in written:
needed[directory] = present
elif present and not all(map(has_replaygain, present)):
needed[directory] = present
return needed
def replaygain_command(tracks):
"""Return the rsgain command that levels one album directory."""
return [
REPLAYGAIN_TOOL,
"custom",
# Album mode writes the per-track tags as well as the album ones, so
# the device is left to choose between them -- Rockbox can apply track
# gain when shuffling and album gain otherwise, and only if both are
# present.
"--album",
"--tagmode=i",
# The mirror is ID3v2.3 for the iPod firmware's sake. rsgain would
# otherwise keep whatever version it found, and "whatever it found" is
# not a guarantee.
"--id3v2-version=3",
# Staleness here is an mtime comparison and tagging rewrites the file.
# Without this every levelled track would look newer than its source
# and the next pass would re-encode the entire library, forever.
"--preserve-mtimes",
"--quiet",
*[str(track) for track in tracks],
]
def scan_album(directory, tracks):
"""Write ReplayGain tags across one album. Returns whether it worked."""
completed = subprocess.run(replaygain_command(tracks), capture_output=True, text=True)
if completed.returncode != 0:
lines = completed.stderr.strip().splitlines()
logger.warning("could not level %s: %s", directory, lines[-1] if lines else "rsgain failed")
return False
logger.info("levelled %s", directory)
return True
def replaygain(expected, written, jobs, dry_run):
"""Write ReplayGain tags into the albums that need them. Returns how many.
A failure here is reported and then left alone. The mirror is still correct
audio in the right place; it just plays at the level it was mastered to,
which is what every pass before this one produced.
"""
albums = {
directory: tracks
for directory, tracks in replaygain_albums(expected, written).items()
if tracks or dry_run
}
if not albums:
return 0
if dry_run:
logger.info("would level %d album%s", len(albums), "" if len(albums) == 1 else "s")
return len(albums)
if shutil.which(REPLAYGAIN_TOOL) is None:
logger.warning(
"%s is not on PATH; %d albums are left without ReplayGain tags",
REPLAYGAIN_TOOL,
len(albums),
)
return 0
levelled = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
futures = [
pool.submit(scan_album, directory, tracks) for directory, tracks in albums.items()
]
for future in concurrent.futures.as_completed(futures):
if future.result():
levelled += 1
return levelled
def run_once(
@@ -580,6 +763,7 @@ def run_once(
do_prune,
safe=False,
budget=0,
do_replaygain=True,
):
"""Run a single pass. Returns the number of failures.
@@ -590,6 +774,7 @@ def run_once(
logger.info("pass starting with %d concurrent encoders", jobs)
counts = {"encoded": 0, "copied": 0, "renamed": 0, "skipped": 0, "failed": 0}
failures = []
written = set()
work = plan(scan_root, source_root, mirror_root, safe, budget)
@@ -617,6 +802,8 @@ def run_once(
counts[result.action] += 1
if result.action == "failed":
failures.append(result)
elif result.action != "skipped":
written.add(result.path.parent)
expected = set(work)
if safe and dry_run:
@@ -627,20 +814,25 @@ def run_once(
for source in work.values():
expected.add(mirror_path_for(source, source_root, mirror_root))
expected.add(mirror_path_for(source, source_root, mirror_root, True))
removed = prune(mirror_root, expected, dry_run) if do_prune else 0
removed, emptied = prune(mirror_root, expected, dry_run) if do_prune else (0, set())
# After pruning, so an album is not measured with a track in it that is
# about to be deleted.
levelled = replaygain(expected, written | emptied, jobs, dry_run) if do_replaygain else 0
for failure in failures:
logger.error("failed: %s: %s", failure.path, failure.error)
logger.info(
"pass complete in %.1fs: %d encoded, %d copied, %d renamed, %d up to date,"
" %d removed, %d failed",
" %d removed, %d levelled, %d failed",
time.monotonic() - started,
counts["encoded"],
counts["copied"],
counts["renamed"],
counts["skipped"],
removed,
levelled,
counts["failed"],
)
return counts["failed"]
@@ -740,6 +932,13 @@ def build_parser():
action="store_true",
help="keep mirror files whose source has been deleted",
)
parser.add_argument(
"--no-replaygain",
action="store_true",
default=os.getenv("MUSIC_MIRROR_REPLAYGAIN", "").lower() in ("0", "false", "no"),
help="do not write ReplayGain tags; skips the rsgain pass over changed"
" albums (env MUSIC_MIRROR_REPLAYGAIN=0)",
)
parser.add_argument(
"--dry-run",
action="store_true",
@@ -831,6 +1030,7 @@ def main(argv=None):
do_prune,
args.fat32_safe,
budget,
not args.no_replaygain,
)
if interval is None or stopping:
return 1 if failures else 0