mirror of
https://github.com/beetbox/beets.git
synced 2026-07-16 14:07:40 -04:00
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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
::
|
||||
|
||||
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