From a64cb26cfed5b546682df2ad5b73b69cea3aa80f Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Sun, 27 Sep 2026 22:17:34 -0700 Subject: add support for replaygain --- README.md | 9 ++++++--- expergo.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++------ expergo.spec | 38 ++++++++++++++++++++++++++++++++++++++ gui.py | 32 ++++++++++++++++++++++++-------- 4 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 expergo.spec diff --git a/README.md b/README.md index 5b97eb5..84c63d5 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,13 @@ These are the rules that the CLI applies (can be OPTIONALLY applied from the GUI - Re-encodes files with block size higher than 4096 via `flac` CLI - Rename FLAC file to `TRACK_NAME - ARTIST.flac` - Resize embedded album art to 500x500px (good enough for small screen players, without taking up much space) -- Normalize all audio to -14 LUFS +- Normalize all audio to -14 LUFS (or, with `--replaygain`, write ReplayGain tags instead and leave the audio untouched) - Download LRC files (places them in same folder with same name, according to Snowsky Echo rules) - A delay of 0.3 sec is hardcoded to avoid rate limit, this is a hard limit across all threads (if running multithreaded) +> [!DANGER] +> Audio normalization is DESTRUCTIVE by default! This is because some DAPs do not support the ReplyGain tag. You can toggle ReplayGain by clicking the drop down under the "Echo Rules" button. + # External Dependencies You need the FLAC command line tool to be accessible globally, meaning it must be able to run anywhere on your machine. Using the official tool was the most consistent way of fixing the block-size issue cross-platform. @@ -38,12 +41,12 @@ uv sync **Apply Echo Rules:** ```bash -uv run expergo.py [--nolrc] [-n workers] +uv run expergo.py [--nolrc] [--replaygain] [-n workers] ``` **Subcommands:** ```bash -uv run expergo.py process [--nolrc] [-n workers] +uv run expergo.py process [--nolrc] [--replaygain] [-n workers] uv run expergo.py auto-lrc [--force] [-n workers] uv run expergo.py auto-cover [--force] [--tries N] [-n workers] uv run expergo.py reorg [--artist-only] [--no-cleanup] [-n workers] diff --git a/expergo.py b/expergo.py index 7d5a26a..f246f28 100644 --- a/expergo.py +++ b/expergo.py @@ -1,5 +1,6 @@ import argparse import os +import re import shutil import subprocess import threading @@ -170,7 +171,41 @@ def normalize_loudness(path: Path, target_lufs: float = -14.0, target_tp: float return path -def process_file(fp: Path, nolrc: bool) -> str: +REPLAYGAIN_REFERENCE_LUFS = -14.0 + + +def measure_loudness(path: Path) -> tuple[float, float]: + result = subprocess.run( + [ + "ffmpeg", "-hide_banner", "-nostats", + "-i", str(path), + "-map", "0:a:0", + "-af", "ebur128=peak=true", + "-f", "null", "-", + ], + capture_output=True, + text=True, + check=True, + ) + number = r"(-?(?:inf|\d+(?:\.\d+)?))" + loudness = re.findall(rf"^\s*I:\s*{number}\s*LUFS", result.stderr, re.MULTILINE) + peak = re.findall(rf"^\s*Peak:\s*{number}\s*dBFS", result.stderr, re.MULTILINE) + if not loudness or not peak: + raise RuntimeError(f"Could not parse loudness measurement for {path.name}") + return float(loudness[-1]), float(peak[-1]) + + +def write_replaygain(path: Path, reference_lufs: float = REPLAYGAIN_REFERENCE_LUFS) -> float: + loudness, peak_db = measure_loudness(path) + gain = reference_lufs - loudness + audio = FLAC(str(path)) + audio["REPLAYGAIN_TRACK_GAIN"] = f"{gain:+.2f} dB" + audio["REPLAYGAIN_TRACK_PEAK"] = f"{10 ** (peak_db / 20):.6f}" + audio.save() + return gain + + +def process_file(fp: Path, nolrc: bool, replaygain: bool = False) -> str: lines = [] log = lines.append @@ -214,8 +249,12 @@ def process_file(fp: Path, nolrc: bool) -> str: log(f" Fixing via ffmpeg: {', '.join(reasons)}") fp = fix_with_ffmpeg(fp, issues["needs_sample_rate_fix"], issues["needs_bitdepth_fix"]) - log(" Normalizing loudness to -14 LUFS") - fp = normalize_loudness(fp) + if replaygain: + gain = write_replaygain(fp) + log(f" Wrote ReplayGain tags (track gain {gain:+.2f} dB, audio untouched)") + else: + log(" Normalizing loudness to -14 LUFS") + fp = normalize_loudness(fp) post_blocksize = getattr(FLAC(str(fp)).info, "max_blocksize", 4096) if post_blocksize > 4096: @@ -379,10 +418,10 @@ def run_jobs(items: list, worker: Callable, workers: int, desc: str) -> tuple[in return ok, errors -def command_process(base_dir: Path, workers: int, nolrc: bool) -> int: +def command_process(base_dir: Path, workers: int, nolrc: bool, replaygain: bool = False) -> int: files = find_flacs(base_dir) _claimed_names.clear() - ok, errors = run_jobs(files, lambda fp: process_file(fp, nolrc), workers, "Applying Echo rules") + ok, errors = run_jobs(files, lambda fp: process_file(fp, nolrc, replaygain), workers, "Applying Echo rules") tqdm.write(f"Done: {ok} processed, {errors} errors") return 0 if errors == 0 else 1 @@ -447,6 +486,8 @@ def build_parser() -> argparse.ArgumentParser: p_process = sub.add_parser("process", help="Apply full Echo rules") p_process.add_argument("base_dir", type=Path) p_process.add_argument("--nolrc", action="store_true", dest="nolrc") + p_process.add_argument("--replaygain", action="store_true", + help="Write ReplayGain tags instead of destructively normalizing the audio") p_process.add_argument("-n", "--workers", type=int, default=1) p_lrc = sub.add_parser("auto-lrc", help="Fetch missing LRC files") @@ -491,7 +532,7 @@ def main() -> int: match args.command: case "process": - return command_process(args.base_dir, args.workers, args.nolrc) + return command_process(args.base_dir, args.workers, args.nolrc, args.replaygain) case "auto-lrc": return command_auto_lrc(args.base_dir, args.workers, args.force) case "auto-cover": diff --git a/expergo.spec b/expergo.spec new file mode 100644 index 0000000..0335293 --- /dev/null +++ b/expergo.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['expergo.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='expergo', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/gui.py b/gui.py index 487798d..3235ed2 100644 --- a/gui.py +++ b/gui.py @@ -436,7 +436,7 @@ class TrackTable(QTableView): class ImportDialog(QDialog): imported = pyqtSignal() - def __init__(self, dest: Path | None, workers: int, nolrc: bool, parent=None): + def __init__(self, dest: Path | None, workers: int, nolrc: bool, replaygain: bool = False, parent=None): super().__init__(parent) self.setWindowTitle("Add Songs") self.resize(1000, 600) @@ -476,6 +476,12 @@ class ImportDialog(QDialog): self.organize.setToolTip("Uses Album Artist when set (keeps compilations together), otherwise Artist") self.nolrc = QCheckBox("Skip LRC download") self.nolrc.setChecked(nolrc) + self.replaygain = QCheckBox("Use ReplayGain") + self.replaygain.setChecked(replaygain) + self.replaygain.setToolTip("Write ReplayGain tags instead of destructively normalizing the audio.\n" + "Only effective if your player supports ReplayGain.") + self.replaygain.setEnabled(self.apply_rules.isChecked()) + self.apply_rules.toggled.connect(self.replaygain.setEnabled) self.workers = QSpinBox() self.workers.setRange(1, 16) self.workers.setValue(workers) @@ -483,6 +489,7 @@ class ImportDialog(QDialog): opts.addWidget(self.apply_rules) opts.addWidget(self.organize) opts.addWidget(self.nolrc) + opts.addWidget(self.replaygain) opts.addStretch() opts.addWidget(QLabel("Workers:")) opts.addWidget(self.workers) @@ -554,7 +561,7 @@ class ImportDialog(QDialog): self.table.fit_columns() def _import_one(self, track: Track, dest_root: Path, layout: FolderLayout | None, - apply_rules: bool, nolrc: bool) -> str: + apply_rules: bool, nolrc: bool, replaygain: bool) -> str: with self._copy_lock: dest_dir = layout.folder_for(track.tags) if layout else dest_root dest_dir.mkdir(parents=True, exist_ok=True) @@ -568,7 +575,7 @@ class ImportDialog(QDialog): shutil.copy2(lrc, target.with_suffix(".lrc")) write_tags(target, track) if apply_rules: - return f"Copied to {dest_dir}" + core.process_file(target.resolve(), nolrc) + return f"Copied to {dest_dir}" + core.process_file(target.resolve(), nolrc, replaygain) lines = [f"Copied {target.name} to {dest_dir}"] title, artist = track.tags["TITLE"].strip(), track.tags["ARTIST"].strip() @@ -583,14 +590,15 @@ class ImportDialog(QDialog): if not self.dest.text().strip(): QMessageBox.warning(self, "Add Songs", "Choose a destination folder first.") return - apply_rules, nolrc = self.apply_rules.isChecked(), self.nolrc.isChecked() + apply_rules, nolrc, replaygain = (self.apply_rules.isChecked(), self.nolrc.isChecked(), + self.replaygain.isChecked()) if apply_rules and not tools_ok(self): return core._claimed_names.clear() self.import_btn.setEnabled(False) dest_root = Path(self.dest.text().strip()).expanduser().resolve() layout = FolderLayout(dest_root) if self.organize.isChecked() else None - fn = lambda t: self._import_one(t, dest_root, layout, apply_rules, nolrc) + fn = lambda t: self._import_one(t, dest_root, layout, apply_rules, nolrc, replaygain) self.worker = Worker(self.model.tracks, fn, self.workers.value(), self) self.worker.log.connect(self.log.appendPlainText) self.worker.progress.connect(self.progress.set_progress) @@ -984,6 +992,9 @@ class MainWindow(QMainWindow): # Options self.nolrc_action = action("Skip LRC Download", None, checkable=True, tip="Don't fetch lyrics when applying the Echo rules or importing") + self.replaygain_action = action("Use ReplayGain Instead of Normalizing", None, checkable=True, + tip="Write ReplayGain tags instead of destructively normalizing the audio\n" + "(only effective if your player supports ReplayGain)") self.worker_group = QActionGroup(self) for n in (1, 2, 4, 8): act = action(f"{n} Worker{'s' if n > 1 else ''}", None, checkable=True) @@ -1007,6 +1018,7 @@ class MainWindow(QMainWindow): tracks_menu.addAction(self.stop_action) options_menu = QMenu("&Options", self) options_menu.addAction(self.nolrc_action) + options_menu.addAction(self.replaygain_action) options_menu.addSeparator() options_menu.addSection("Parallel Workers") options_menu.addActions(self.worker_group.actions()) @@ -1176,18 +1188,21 @@ class MainWindow(QMainWindow): tracks = self.table.target_tracks() if not tracks or not tools_ok(self): return + replaygain = self.replaygain_action.isChecked() + loudness = "tagged with ReplayGain" if replaygain else "loudness-normalized" answer = QMessageBox.question( self, "Apply Echo rules", f"Apply Echo formatting rules to {len(tracks)} file(s)?\n\n" - "Files are renamed, re-encoded, loudness-normalized and have their album art resized IN PLACE.\n" + f"Files are renamed, re-encoded, {loudness} and have their album art resized IN PLACE.\n" "Unsaved edits on these rows are saved first.", ) if answer != QMessageBox.StandardButton.Yes or not self.save_changes(tracks): return core._claimed_names.clear() nolrc = self.nolrc_action.isChecked() - self.run_worker("Applying Echo rules", [t.path for t in tracks], lambda p: core.process_file(p, nolrc), + self.run_worker("Applying Echo rules", [t.path for t in tracks], + lambda p: core.process_file(p, nolrc, replaygain), lambda _: self.rescan()) def fetch_missing_lrc(self): @@ -1297,7 +1312,8 @@ class MainWindow(QMainWindow): self.run_worker("Reorganizing folders", moves, move_track, done, workers=1) def add_songs(self): - dialog = ImportDialog(self.folder, self.worker_count(), self.nolrc_action.isChecked(), self) + dialog = ImportDialog(self.folder, self.worker_count(), self.nolrc_action.isChecked(), + self.replaygain_action.isChecked(), self) dialog.imported.connect(self.rescan) dialog.exec() dialog.deleteLater() -- cgit v1.2.3