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
+4
-2
@@ -7,8 +7,10 @@ FROM python:3.13-alpine AS runtime
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# ffmpeg does the encoding; the application itself has no Python dependencies.
|
||||
RUN apk add --no-cache ffmpeg
|
||||
# ffmpeg does the encoding and rsgain the volume levelling; the application
|
||||
# itself has no Python dependencies. rsgain lives in the community repository,
|
||||
# which the official Python images already have enabled.
|
||||
RUN apk add --no-cache ffmpeg rsgain
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
|
||||
@@ -22,6 +22,7 @@ remembering to do anything.
|
||||
| Mirror up to date | skip |
|
||||
| Source is already MP3 | copy verbatim |
|
||||
| Source gone | delete the mirror file, prune empty dirs |
|
||||
| An album gained or lost a track | re-measure its ReplayGain, tags only |
|
||||
|
||||
Freshness is modification time: an encoded file is stamped with its source's
|
||||
mtime, so a file is stale exactly when the two differ. There is no database to
|
||||
@@ -94,6 +95,7 @@ music-mirror --source /music --mirror /music-mp3 --subdir "Artist/Album"
|
||||
| `--interval` | `MUSIC_MIRROR_INTERVAL` | unset | Repeat forever, e.g. `45m`, `6h`, `1d` |
|
||||
| `--subdir` | — | unset | Limit the pass to one directory; skips pruning |
|
||||
| `--fat32-safe` | `MUSIC_MIRROR_FAT32_SAFE` | off | Name files so a FAT32 device accepts them |
|
||||
| `--no-replaygain` | `MUSIC_MIRROR_REPLAYGAIN` | on | Write ReplayGain tags; set the variable to `0` to skip |
|
||||
| `--no-prune` | — | off | Keep mirror files whose source has gone |
|
||||
| `--dry-run` | — | off | Report what would change, write nothing |
|
||||
|
||||
@@ -114,7 +116,9 @@ The first full pass is the expensive one; after that only new and changed files
|
||||
are touched. Lower `MUSIC_MIRROR_JOBS` if you would rather the NAS stayed
|
||||
responsive than finished sooner.
|
||||
|
||||
Requires `ffmpeg` and `ffprobe` on `PATH`. The container image provides both.
|
||||
Requires `ffmpeg` and `ffprobe` on `PATH`, and `rsgain` for volume levelling.
|
||||
The container image provides all three. A missing `rsgain` is reported once per
|
||||
pass and costs the ReplayGain tags; everything else still runs.
|
||||
|
||||
## Running it on TrueNAS Scale
|
||||
|
||||
@@ -347,6 +351,8 @@ docker build --target test . # what CI runs
|
||||
pytest # needs ffmpeg and pytest on PATH
|
||||
```
|
||||
|
||||
The ReplayGain tests need `rsgain` as well and skip without it.
|
||||
|
||||
The suite runs real ffmpeg encodes rather than mocking them. The interesting
|
||||
failures are in what ffmpeg actually does with tags, cover art and container
|
||||
formats, and a mock cannot fail that way — which is also why CI runs the tests
|
||||
@@ -358,7 +364,7 @@ Run them directly instead if you prefer; they skip when ffmpeg is absent. On a
|
||||
Nix machine:
|
||||
|
||||
```sh
|
||||
nix shell nixpkgs#python3Packages.pytest nixpkgs#ffmpeg -c pytest
|
||||
nix shell nixpkgs#python3Packages.pytest nixpkgs#ffmpeg nixpkgs#rsgain -c pytest
|
||||
```
|
||||
|
||||
## FAT32 and Rockbox
|
||||
@@ -448,6 +454,40 @@ firmware reads the embedded one; Rockbox reads the file. Both are satisfied.
|
||||
A cover left behind in a directory whose tracks have all gone is pruned, or the
|
||||
directory would never look empty and never be removed.
|
||||
|
||||
### Volume levelling
|
||||
|
||||
Rockbox can level the volume between tracks, but only from tags. It applies the
|
||||
offset a ReplayGain tag carries and has no loudness analysis of its own, so a
|
||||
mirror without those tags plays every album at whatever level it was mastered
|
||||
to — and a 2008 remaster next to a 1972 pressing is a reach for the volume
|
||||
wheel on every track change.
|
||||
|
||||
The tags are therefore written here, with `rsgain`, once an album's tracks are
|
||||
in place. Both album gain and track gain are measured: album gain preserves the
|
||||
quiet track that a record is supposed to have, track gain is the one that makes
|
||||
sense on shuffle, and which of them is used is the device's decision, not this
|
||||
one.
|
||||
|
||||
Turn it on at the player end under **Settings → Playback Settings →
|
||||
Replaygain**:
|
||||
|
||||
| Setting | Suggested | Why |
|
||||
| ---------------- | -------------------- | --------------------------------------------------------- |
|
||||
| Replaygain type | `Album Gain`, or `Track Gain if Shuffling` | Keeps a record's own dynamics; the second switches per mode |
|
||||
| Prevent clipping | `Yes` | Uses the peak tags to back off rather than distort |
|
||||
| Pre-amp | `0 dB` | The reference level is already −18 LUFS; move it only if the result is too quiet |
|
||||
|
||||
Measuring costs a full decode of every track, so the first pass after enabling
|
||||
it takes roughly as long as the original encode did. After that only albums
|
||||
that gained, lost or replaced a track are re-measured. An album is re-measured
|
||||
as a whole, because album gain is a property of all of its tracks and one new
|
||||
track makes the value stored on every sibling wrong.
|
||||
|
||||
Tagging rewrites the file, and staleness here is an mtime comparison, so
|
||||
`rsgain` is run with `--preserve-mtimes`. Without it every levelled track would
|
||||
look newer than its source and the next pass would re-encode the entire
|
||||
library.
|
||||
|
||||
## Getting the result onto an iPod
|
||||
|
||||
The mirror is just a directory of MP3s, so any client will do:
|
||||
@@ -487,6 +527,6 @@ The mirror is just a directory of MP3s, so any client will do:
|
||||
Neither client transcodes at sync time; they copy finished MP3s.
|
||||
|
||||
Two device-side details worth knowing: the iPod reads cover art from the file's
|
||||
tags and ignores `folder.jpg`, which is why art is embedded here; and volume
|
||||
levelling on the device uses iTunes' Soundcheck tag, not ReplayGain, so
|
||||
ReplayGain tags in the source are not carried over as such.
|
||||
tags and ignores `folder.jpg`, which is why art is embedded here; and the Apple
|
||||
firmware levels volume from iTunes' Soundcheck tag rather than ReplayGain, so
|
||||
the tags written here do nothing until the iPod is running Rockbox.
|
||||
|
||||
@@ -31,6 +31,11 @@ services:
|
||||
# enabling it is quick -- but it is a one-way change to every such path,
|
||||
# so decide before running it rather than after.
|
||||
MUSIC_MIRROR_FAT32_SAFE: "true"
|
||||
# ReplayGain tags, so Rockbox can level the volume between albums. On by
|
||||
# default; set to 0 to skip the measuring pass. The first pass after
|
||||
# enabling it levels the whole library, which costs a decode of every
|
||||
# track, so expect it to take about as long as the original encode.
|
||||
# MUSIC_MIRROR_REPLAYGAIN: "0"
|
||||
volumes:
|
||||
- /mnt/tank/media/music:/music:ro
|
||||
- /mnt/tank/media/music-mp3:/mirror
|
||||
|
||||
+203
-3
@@ -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
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ version = "0.4.0"
|
||||
description = "Maintain a lossy MP3 mirror of a lossless music library"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
# No runtime Python dependencies: the work is done by ffmpeg, which must be on
|
||||
# PATH.
|
||||
# No runtime Python dependencies: the work is done by ffmpeg and rsgain, which
|
||||
# must be on PATH. A missing rsgain costs the ReplayGain tags and nothing else.
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
|
||||
+18
-1
@@ -19,6 +19,13 @@ def require_ffmpeg():
|
||||
pytest.skip(f"{tool} is not on PATH", allow_module_level=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def require_rsgain():
|
||||
"""Skip a test that measures loudness for real rather than faking it."""
|
||||
if shutil.which("rsgain") is None:
|
||||
pytest.skip("rsgain is not on PATH")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tight_umask():
|
||||
"""Run a test under a umask that would otherwise make the mirror private."""
|
||||
@@ -46,7 +53,14 @@ def owner_hostile_umask():
|
||||
def make_flac():
|
||||
"""Return a factory writing a short tagged FLAC file."""
|
||||
|
||||
def factory(path, title="Test Title", artist="Test Artist", album="Test Album", seconds=1):
|
||||
def factory(
|
||||
path,
|
||||
title="Test Title",
|
||||
artist="Test Artist",
|
||||
album="Test Album",
|
||||
seconds=1,
|
||||
gain=0,
|
||||
):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
[
|
||||
@@ -60,6 +74,9 @@ def make_flac():
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"sine=frequency=440:duration={seconds}",
|
||||
# Quieter or louder than the default, for tests that need two
|
||||
# tracks at different levels.
|
||||
*(["-af", f"volume={gain}dB"] if gain else []),
|
||||
"-metadata",
|
||||
f"title={title}",
|
||||
"-metadata",
|
||||
|
||||
+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