replaygain: add metaflac backend (#6800)

Fixes #1203.

The replaygain plugin can't compute ReplayGain when metaflac is the only
tool available. The existing backends (command, gstreamer, audiotools,
ffmpeg) each need something heavier installed, which doesn't work on
minimal setups like a NAS where those won't install. Issue #1203 asks
for a metaflac based option so FLAC users on those setups can still tag
ReplayGain.

This adds a metaflac backend for that case. It runs `metaflac
--add-replay-gain` to compute the gain and reads the values back with
`metaflac --show-tag`. I modeled it on the existing `CommandBackend`
since both wrap an external tool. It only handles FLAC, skips other
formats, and shifts the gain to the configured `targetlevel` like the
other backends.

One limitation: metaflac scans a whole album in one pass, so the files
of an album need the same sample rate and channel layout.

Before, selecting the metaflac backend failed:

```
$ beet replaygain
UserError: Selected ReplayGain backend metaflac is not supported.
```

After, the backend works and the plugin tests pass:

```
$ python -m pytest test/plugins/test_replaygain.py
7 passed, 4 skipped
```

The 4 skipped are the Opus R128 cases, which don't apply to the metaflac
path.
This commit is contained in:
Šarūnas Nejus
2026-07-15 22:26:40 +01:00
committed by GitHub
5 changed files with 163 additions and 4 deletions

View File

@@ -79,6 +79,7 @@ jobs:
sudo apt update
sudo apt install --yes --no-install-recommends \
ffmpeg \
flac \
gobject-introspection \
gstreamer1.0-plugins-base \
imagemagick \

View File

@@ -653,6 +653,101 @@ class CommandBackend(Backend):
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 @@ class ExceptionWatcher(Thread):
BACKEND_CLASSES: list[type[Backend]] = [
CommandBackend,
MetaflacBackend,
GStreamerBackend,
AudioToolsBackend,
FfmpegBackend,

View File

@@ -52,6 +52,8 @@ New features
:bug:`6466`
- :doc:`plugins/lyrics`: Add ``lrcmux`` backend, which aggregates lyrics from
various other sources.
- :doc:`plugins/replaygain`: Add a ``metaflac`` backend that computes ReplayGain
for FLAC files using the ``metaflac`` command-line tool. :bug:`1203`
Bug fixes
~~~~~~~~~

View File

@@ -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:
.. code-block:: yaml
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
---------------

View File

@@ -13,6 +13,7 @@ from beets.test.helper import (
from beetsplug.replaygain import (
FatalGstreamerPluginReplayGainError,
GStreamerBackend,
MetaflacBackend,
)
try:
@@ -35,6 +36,8 @@ GAIN_PROG = next(
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()