diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f3652b82e9..30b193d0e8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -58,6 +58,7 @@ jobs: sudo apt update sudo apt install --yes --no-install-recommends \ ffmpeg \ + flac \ gobject-introspection \ gstreamer1.0-plugins-base \ imagemagick \ diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index ca4390cca8..b1f7ccfa6f 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -653,6 +653,101 @@ def parse_tool_output(self, text: bytes, num_lines: int) -> list[Gain]: return out +# metaflac CLI tool backend. + + +class MetaflacBackend(Backend): + """A replaygain backend using the ``metaflac`` command-line tool.""" + + NAME = "metaflac" + do_parallel = True + + SUPPORTED_FORMATS: ClassVar[set[str]] = {"FLAC"} + + def __init__(self, config: ConfigView, log: Logger): + super().__init__(config, log) + config.add({"metaflac": "metaflac"}) + + command = config["metaflac"].as_str() + path = shutil.which(command) + if path is None: + raise FatalReplayGainError( + f"replaygain metaflac command not found: {command!r}" + ) + self.command = path + + def format_supported(self, item: Item) -> bool: + """Return whether metaflac can process this item.""" + return item.format in self.SUPPORTED_FORMATS + + def compute_track_gain(self, task: AnyRgTask) -> AnyRgTask: + """Compute the track gain for each FLAC item in the task.""" + track_gains = [] + for item in filter(self.format_supported, task.items): + self._add_replay_gain([item]) + track_gains.append( + self._read_gain(item, "TRACK", task.target_level) + ) + task.track_gains = track_gains + return task + + def compute_album_gain(self, task: AnyRgTask) -> AnyRgTask: + """Compute the album gain and per-track gains for the FLAC items.""" + items = list(task.items) + if not items or not all(self.format_supported(i) for i in items): + task.album_gain = None + task.track_gains = None + return task + + self._add_replay_gain(items) + task.track_gains = [ + self._read_gain(item, "TRACK", task.target_level) for item in items + ] + task.album_gain = self._read_gain(items[0], "ALBUM", task.target_level) + return task + + def _add_replay_gain(self, items: Sequence[Item]) -> None: + """Run ``metaflac --add-replay-gain`` on the given files.""" + paths = [str(item.filepath) for item in items] + call([self.command, "--add-replay-gain", *paths], self._log) + + def _read_gain(self, item: Item, kind: str, target_level: float) -> Gain: + """Read the REPLAYGAIN gain and peak tags back from a file.""" + gain_tag = f"REPLAYGAIN_{kind}_GAIN" + peak_tag = f"REPLAYGAIN_{kind}_PEAK" + command = [ + self.command, + f"--show-tag={gain_tag}", + f"--show-tag={peak_tag}", + str(item.filepath), + ] + tags = self._parse_tags(call(command, self._log).stdout) + try: + gain = self._parse_gain(tags[gain_tag]) + peak = float(tags[peak_tag]) + except (KeyError, IndexError, ValueError) as exc: + raise ReplayGainError( + f"could not read metaflac replaygain tags for {item}: {exc!r}" + ) + # metaflac uses an 89 dB reference, like the other backends + return Gain(gain=gain + (target_level - 89.0), peak=peak) + + @staticmethod + def _parse_tags(output: bytes) -> dict[str, str]: + """Turn metaflac's NAME=VALUE output into a dict.""" + tags: dict[str, str] = {} + for line in output.decode("utf-8", "ignore").splitlines(): + name, sep, value = line.partition("=") + if sep: + tags[name.strip().upper()] = value.strip() + return tags + + @staticmethod + def _parse_gain(value: str) -> float: + """Turn a '-7.89 dB' tag value into a float.""" + return float(value.split()[0]) + + # GStreamer-based backend. @@ -1146,6 +1241,7 @@ def join(self, timeout: float | None = None): BACKEND_CLASSES: list[type[Backend]] = [ CommandBackend, + MetaflacBackend, GStreamerBackend, AudioToolsBackend, FfmpegBackend, diff --git a/docs/changelog.rst b/docs/changelog.rst index 32d2b44361..a7207375ee 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,8 @@ New features - :ref:`modify-cmd`: Support ``+=`` and ``-=`` operators to add or remove individual values from multi-valued fields without replacing the whole field. :bug:`6587` +- :doc:`plugins/replaygain`: Add a ``metaflac`` backend that computes ReplayGain + for FLAC files using the ``metaflac`` command-line tool. :bug:`1203` Bug fixes ~~~~~~~~~ diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index 191b37f243..07f11b1cfe 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -10,9 +10,10 @@ Installation ------------ This plugin can use one of many backends to compute the ReplayGain values: -GStreamer, mp3gain (and its cousins, aacgain and mp3rgain), Python Audio Tools -or ffmpeg. ffmpeg and mp3gain can be easier to install. mp3gain supports fewer -audio formats than the other backends. +GStreamer, mp3gain (and its cousins, aacgain and mp3rgain), Python Audio Tools, +ffmpeg or metaflac. ffmpeg and mp3gain can be easier to install. mp3gain +supports fewer audio formats than the other backends, and metaflac only supports +FLAC. Once installed, this plugin analyzes all files during the import process. This can be a slow process; to instead analyze after the fact, disable automatic @@ -139,6 +140,24 @@ file. .. _ffmpeg: https://ffmpeg.org +metaflac +~~~~~~~~ + +This backend uses the metaflac_ command-line tool (part of the FLAC tools) to +compute ReplayGain values for FLAC files. It only supports FLAC; files in other +formats are skipped. To use it, install the ``flac`` package, which provides +``metaflac``, and select the ``metaflac`` backend in your configuration file: + +:: + + replaygain: + backend: metaflac + +metaflac scans every file of an album in a single pass, so the files of an album +need to share the same sample rate and channel layout. + +.. _metaflac: https://xiph.org/flac/documentation_tools_metaflac.html + Configuration ------------- @@ -154,7 +173,7 @@ file. The available options are: write`` after importing to actually write to the imported files. Default: ``no`` - **backend**: The analysis backend; either ``gstreamer``, ``command``, - ``audiotools`` or ``ffmpeg``. Default: ``command``. + ``audiotools``, ``ffmpeg`` or ``metaflac``. Default: ``command``. - **overwrite**: On import, re-analyze files that already have ReplayGain tags. Note that, for historical reasons, the name of this option is somewhat unfortunate: It does not decide whether tags are written to the files (which @@ -183,6 +202,11 @@ This option only works with the "ffmpeg" backend: - **peak**: Either ``true`` (the default) or ``sample``. ``true`` is more accurate but slower. +This option only works with the "metaflac" backend: + +- **metaflac**: Name or path to the ``metaflac`` executable. Default: + ``metaflac``. + Manual Analysis --------------- diff --git a/test/plugins/test_replaygain.py b/test/plugins/test_replaygain.py index f0232d2340..33464860a8 100644 --- a/test/plugins/test_replaygain.py +++ b/test/plugins/test_replaygain.py @@ -13,6 +13,7 @@ from beetsplug.replaygain import ( FatalGstreamerPluginReplayGainError, GStreamerBackend, + MetaflacBackend, ) try: @@ -35,6 +36,8 @@ FFMPEG_AVAILABLE = has_program("ffmpeg", ["-version"]) +METAFLAC_AVAILABLE = has_program("metaflac", ["--version"]) + def reset_replaygain(item): item["rg_track_peak"] = None @@ -112,6 +115,16 @@ class FfmpegBackendMixin(BackendMixin): has_r128_support = True +class MetaflacBackendMixin(BackendMixin): + plugin_config: ClassVar[dict[str, Any]] = {"backend": "metaflac"} + has_r128_support = False + + def test_backend(self): + """Skip the test when the metaflac tool is not installed.""" + if not METAFLAC_AVAILABLE: + pytest.skip("metaflac cannot be found") + + class ReplayGainCliTest: FNAME: str @@ -382,6 +395,29 @@ class TestReplayGainFfmpegNoiseCli( FNAME = "whitenoise" +@pytest.mark.skipif(not METAFLAC_AVAILABLE, reason="metaflac cannot be found") +class TestReplayGainMetaflacCli( + ReplayGainCliTest, ReplayGainPluginHelper, MetaflacBackendMixin +): + FNAME = "whitenoise" + + def _add_album(self, *args, **kwargs): + kwargs.setdefault("ext", "flac") + return super()._add_album(*args, **kwargs) + + +def test_metaflac_backend_parses_replaygain_tags(): + output = ( + b"REPLAYGAIN_TRACK_GAIN=-11.55 dB\nREPLAYGAIN_TRACK_PEAK=0.99998772\n" + ) + tags = MetaflacBackend._parse_tags(output) + assert MetaflacBackend._parse_gain(tags["REPLAYGAIN_TRACK_GAIN"]) == ( + pytest.approx(-11.55) + ) + assert float(tags["REPLAYGAIN_TRACK_PEAK"]) == pytest.approx(0.99998772) + assert MetaflacBackend._parse_gain("+4.56 dB") == pytest.approx(4.56) + + class ImportTest(AsIsImporterMixin): def test_import_converted(self): self.run_asis_importer()