replaygain: add metaflac backend

Add a MetaflacBackend that computes ReplayGain for FLAC files with metaflac --add-replay-gain and reads the values back with --show-tag. Only FLAC is supported. Includes docs, a changelog entry, and tests.
This commit is contained in:
Samad
2026-06-28 19:14:30 -04:00
committed by Šarūnas Nejus
parent 804dd34947
commit 4ccdcc90c9
4 changed files with 163 additions and 4 deletions

View File

@@ -653,6 +653,102 @@ 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 task.items:
if self.format_supported(item):
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 +1242,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:
::
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()