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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 140 additions & 1 deletion beetsplug/replaygain.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,15 @@ def __init__(
album: Album | None,
target_level: float,
peak_method: PeakMethod | None,
max_peak: float | None,
backend_name: str,
log: Logger,
) -> None:
self.items = items
self.album = album
self.target_level = target_level
self.peak_method = peak_method
self.max_peak = max_peak
self.backend_name = backend_name
self._log = log
self.album_gain: Gain | None = None
Expand Down Expand Up @@ -221,7 +223,9 @@ def __init__(
log: Logger,
) -> None:
# R128_* tags do not store the track/album peak
super().__init__(items, album, target_level, None, backend_name, log)
super().__init__(
items, album, target_level, None, None, backend_name, log
)

def _store_track_gain(self, item: Item, track_gain: Gain):
item.rg_track_gain = None
Expand Down Expand Up @@ -525,6 +529,130 @@ def _parse_float(self, line: bytes) -> float:
)


# rsgain backend
class RSGainBackend(Backend):
"""A replaygain backend using rsgain's custom mode"""

NAME = "rsgain"
do_parallel = True

def __init__(self, config: ConfigView, log: Logger) -> None:
super().__init__(config, log)
self._rsgain_path = "rsgain"

# check that rsgain is installed
try:
call([self._rsgain_path, "--version"], log)
except OSError:
raise FatalReplayGainError(
f"could not find rsgain at {self._rsgain_path}"
)
self.noclip = config["noclip"].get(bool)
self.max_peak = config["max_peak"].get(float)

def compute_track_gain(self, task: AnyRgTask) -> AnyRgTask:
"""Computes the track gain for the tracks belonging to `task`, and sets
the `track_gains` attribute on the task. Returns `task`.
"""
task.track_gains = self.compute_gain(
task.items,
task.target_level,
task.peak_method,
task.max_peak,
False,
)
return task

def compute_album_gain(self, task: AnyRgTask) -> AnyRgTask:
"""Computes the album gain for the album belonging to `task`, and sets
the `album_gain` attribute on the task. Returns `task`.
"""

output = self.compute_gain(
task.items, task.target_level, task.peak_method, task.max_peak, True
)
task.album_gain = output[-1]
task.track_gains = output[:-1]
return task

def compute_gain(
self,
items: Sequence[Item],
target_level: float,
peak_method: PeakMethod | None,
max_peak: float | None,
is_album: bool,
) -> list[Gain]:
"""Computes the track or album gain of a list of items, returns
a list of TrackGain objects.

When computing album gain, the last TrackGain object returned is
the album gain
"""
if not items:
self._log.debug("no supported tracks to analyze")
return []

"""Compute ReplayGain values and return a list of results
dictionaries as given by `parse_tool_output`.
"""
# Construct shell command. The "-O" option makes the output
# easily parseable (tab-delimited). "-s s" forces gain
# recalculation even if tags are already present and disables
# tag-writing. "-c p" enables clipping protection for positive
# values, unless disabled. "-m -1.0" option sets the max peak
# level for clipping protection to -1db, the EBU R128 standard.
target_lufs = db_to_lufs(target_level)
cmd = [
self._rsgain_path,
"custom",
"--output",
"--tagmode=s",
f"--clip-mode={'p' if self.noclip else 'n'}",
f"--loudness={int(target_lufs)!s}",
]

if is_album:
cmd.append("--album")
if self.noclip:
max_peak_str = "-1.0" if max_peak is None else str(max_peak)
cmd.append(f"--max-peak={max_peak_str}")
if peak_method == PeakMethod.true:
cmd.append("--true-peak")
cmd.extend([str(i.filepath) for i in items])

self._log.debug("analyzing {} files", len(items))
self._log.debug("executing {}", " ".join(cmd))
output = call(cmd, self._log).stdout
self._log.debug("analysis finished")
return self.parse_tool_output(
output, len(items) + (1 if is_album else 0)
)

def parse_tool_output(self, text: bytes, num_lines: int) -> list[Gain]:
"""Given the tab-delimited output from an invocation of rsgain,
parse the text and return a list of Gains containing
information about each analyzed file.
"""
out = []
for line in text.split(b"\n")[1 : num_lines + 1]:
parts = line.split(b"\t")
if len(parts) != 7 or parts[0] == b"Filename":
self._log.debug("bad tool output: {}", text)
raise ReplayGainError("rsgain failed")

# _file_name = parts[0]
# _loudness_lufs = int(parts[1])
gain = float(parts[2])
peak = float(parts[3])
# _peak_db = int(parts[4])
# _peak_type = int(parts[5])
# _clip_adjustment = int(parts[6])

out.append(Gain(gain, peak))
return out


# mpgain/aacgain CLI tool backend.
Tool = Literal["mp3rgain", "aacgain", "mp3gain"]

Expand Down Expand Up @@ -1252,6 +1380,7 @@ def join(self, timeout: float | None = None):
GStreamerBackend,
AudioToolsBackend,
FfmpegBackend,
RSGainBackend,
]
BACKENDS: dict[str, type[Backend]] = {b.NAME: b for b in BACKEND_CLASSES}

Expand All @@ -1274,6 +1403,8 @@ def __init__(self) -> None:
"parallel_on_import": False,
"per_disc": False,
"peak": "true",
"max_peak": -1.0,
"noclip": True,
"targetlevel": 89,
"r128": ["Opus"],
"r128_targetlevel": lufs_to_db(-23),
Expand Down Expand Up @@ -1306,6 +1437,13 @@ def __init__(self) -> None:
# values.
self.peak_method = PeakMethod[peak_method]

max_peak = self.config["max_peak"].get(float)
if max_peak > 0:
raise UserError(
f"Selected max peak value {max_peak!s} cannot be above zero"
)
self.max_peak = max_peak

# On-import analysis.
if self.config["auto"]:
self.register_listener("import_begin", self.import_begin)
Expand Down Expand Up @@ -1388,6 +1526,7 @@ def create_task(
album,
self.config["targetlevel"].as_number(),
self.peak_method,
self.max_peak,
self.backend_instance.NAME,
self._log,
)
Expand Down
35 changes: 29 additions & 6 deletions docs/plugins/replaygain.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ 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,
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.
ffmpeg, rsgain or metaflac. ffmpeg, rsgain 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 @@ -140,6 +140,19 @@ file.

.. _ffmpeg: https://ffmpeg.org

rsgain
~~~~~~

This backend uses the ``rsgain`` tool to calculate EBU R128 gain values. To use
it, install the rsgain_ command-line tool and select the ``rsgain`` backed in
your config file.

rsgain supports a wide variety of audio formats and is available on most
platforms. It provides the option of true peak sampling and can easily scan
entire directories recursively using its ``easy`` mode.

.. _rsgain: https://github.com/complexlogic/rsgain#installation

metaflac
~~~~~~~~

Expand Down Expand Up @@ -173,7 +186,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``, ``ffmpeg`` or ``metaflac``. Default: ``command``.
``audiotools``, ``ffmpeg``, ``rsgain`` 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 All @@ -190,18 +203,28 @@ file. The available options are:
- **per_disc**: Calculate album ReplayGain on disc level instead of album level.
Default: ``no``

These options only work with the "command" backend:
This option only works with the "command" backend:

- **command**: Name or path to your command backend of choice: either of
``mp3gain``, ``aacgain`` or ``mp3rgain``.

This option only works with the "command" and "rsgain" backend:

- **noclip**: Reduce the amount of ReplayGain adjustment to whatever amount
would keep clipping from occurring. Default: ``yes``.

This option only works with the "ffmpeg" backend:
This option only works with the "ffmpeg" and "rsgain" backend:

- **peak**: Either ``true`` (the default) or ``sample``. ``true`` is more
accurate but slower.

This option only works with the "rsgain" backend:

- **max_peak**: Maximum allowed peak audio level, in decibels. Only taken into
account when clipping protection using the ``noclip`` option is enabled. Must
be a negative value, default is ``-1.0`` as recommended by the EBU R128
standard.

This option only works with the "metaflac" backend:

- **metaflac**: Name or path to the ``metaflac`` executable. Default:
Expand Down
26 changes: 26 additions & 0 deletions test/plugins/test_replaygain.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@

FFMPEG_AVAILABLE = has_program("ffmpeg", ["-version"])

RSGAIN_AVAILABLE = has_program("rsgain", ["--version"])

METAFLAC_AVAILABLE = has_program("metaflac", ["--version"])


Expand Down Expand Up @@ -115,6 +117,16 @@ class FfmpegBackendMixin(BackendMixin):
has_r128_support = True


class RSGainBackendMixin(BackendMixin):
plugin_config: ClassVar[dict[str, Any]] = {"backend": "rsgain"}
has_r128_support = True

def test_backend(self):
"""Skip the test when the rsgain tool is not installed."""
if not RSGAIN_AVAILABLE:
pytest.skip("rsgain cannot be found")


class MetaflacBackendMixin(BackendMixin):
plugin_config: ClassVar[dict[str, Any]] = {"backend": "metaflac"}
has_r128_support = False
Expand Down Expand Up @@ -395,6 +407,13 @@ class TestReplayGainFfmpegNoiseCli(
FNAME = "whitenoise"


@pytest.mark.skipif(not RSGAIN_AVAILABLE, reason="rsgain cannot be found")
class TestReplayGainRSGainNoiseCli(
ReplayGainCliTest, ReplayGainPluginHelper, RSGainBackendMixin
):
FNAME = "whitenoise"


@pytest.mark.skipif(not METAFLAC_AVAILABLE, reason="metaflac cannot be found")
class TestReplayGainMetaflacCli(
ReplayGainCliTest, ReplayGainPluginHelper, MetaflacBackendMixin
Expand Down Expand Up @@ -455,3 +474,10 @@ class TestReplayGainFfmpegThreadedImport(
ThreadedImportMixin, ImportTest, ReplayGainPluginHelper, FfmpegBackendMixin
):
pass


@pytest.mark.skipif(not RSGAIN_AVAILABLE, reason="rsgain cannot be found")
class TestReplayGainRSGainImport(
ImportTest, ReplayGainPluginHelper, RSGainBackendMixin
):
pass
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading