mirror of
https://github.com/beetbox/beets.git
synced 2026-07-16 14:07:40 -04:00
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:
1
.github/workflows/ci.yaml
vendored
1
.github/workflows/ci.yaml
vendored
@@ -79,6 +79,7 @@ jobs:
|
||||
sudo apt update
|
||||
sudo apt install --yes --no-install-recommends \
|
||||
ffmpeg \
|
||||
flac \
|
||||
gobject-introspection \
|
||||
gstreamer1.0-plugins-base \
|
||||
imagemagick \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
~~~~~~~~~
|
||||
|
||||
@@ -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
|
||||
---------------
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user