Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:
sudo apt update
sudo apt install --yes --no-install-recommends \
ffmpeg \
flac \
gobject-introspection \
gstreamer1.0-plugins-base \
imagemagick \
Expand Down
96 changes: 96 additions & 0 deletions beetsplug/replaygain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.


Expand Down Expand Up @@ -1146,6 +1241,7 @@ def join(self, timeout: float | None = None):

BACKEND_CLASSES: list[type[Backend]] = [
CommandBackend,
MetaflacBackend,
GStreamerBackend,
AudioToolsBackend,
FfmpegBackend,
Expand Down
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~
Expand Down
32 changes: 28 additions & 4 deletions docs/plugins/replaygain.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
-------------

Expand All @@ -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
Expand Down Expand Up @@ -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
---------------

Expand Down
36 changes: 36 additions & 0 deletions test/plugins/test_replaygain.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from beetsplug.replaygain import (
FatalGstreamerPluginReplayGainError,
GStreamerBackend,
MetaflacBackend,
)

try:
Expand All @@ -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
Expand Down Expand Up @@ -112,6 +115,16 @@ class FfmpegBackendMixin(BackendMixin):
has_r128_support = True


class MetaflacBackendMixin(BackendMixin):
plugin_config: ClassVar[dict[str, Any]] = {"backend": "metaflac"}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind installing flac in this part of the workflow, in ci.yaml?

      - name: Install system dependencies on Ubuntu

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @snejus for the review. I updated the track loop to use filter like you suggested and also added flac to the Ubuntu deps so the metaflac tests run on CI. All clear now, both pushed.

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

Expand Down Expand Up @@ -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()
Expand Down
Loading