diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 0841723364..80934c1ecb 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -18,6 +18,7 @@ import subprocess import os import collections +import math import sys import warnings import xml.parsers.expat @@ -64,9 +65,22 @@ def call(args, **kwargs): raise ReplayGainError(u"argument encoding failed") +def db_to_lufs(db): + """Convert db to LUFS. + + According to https://wiki.hydrogenaud.io/index.php?title= + ReplayGain_2.0_specification#Reference_level + """ + return db - 107 + + # Backend base and plumbing classes. +# gain: in LU to reference level +# peak: part of full scale (FS is 1.0) Gain = collections.namedtuple("Gain", "gain peak") +# album_gain: Gain object +# track_gains: list of Gain objects AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains") @@ -81,11 +95,15 @@ def __init__(self, config, log): self._log = log def compute_track_gain(self, items): + """Computes the track gain of the given tracks, returns a list + of Gain objects. + """ raise NotImplementedError() def compute_album_gain(self, items): - # TODO: implement album gain in terms of track gain of the - # individual tracks which can be used for any backend. + """Computes the album gain of the given album, returns an + AlbumGain object. + """ raise NotImplementedError() def use_ebu_r128(self): @@ -286,6 +304,257 @@ def use_ebu_r128(self): self.method = '--ebu' +# ffmpeg backend +class FfmpegBackend(Backend): + """A replaygain backend using ffmpegs ebur128 filter. + """ + def __init__(self, config, log): + super(FfmpegBackend, self).__init__(config, log) + config.add({ + "peak": "true" + }) + self._peak_method = config["peak"].as_str() + self._target_level = db_to_lufs(config['targetlevel'].as_number()) + self._ffmpeg_path = "ffmpeg" + + # check that ffmpeg is installed + try: + ffmpeg_version_out = call([self._ffmpeg_path, "-version"]) + except OSError: + raise FatalReplayGainError( + u"could not find ffmpeg at {0}".format(self._ffmpeg_path) + ) + incompatible_ffmpeg = True + for line in ffmpeg_version_out.stdout.splitlines(): + if line.startswith(b"configuration:"): + if b"--enable-libebur128" in line: + incompatible_ffmpeg = False + if line.startswith(b"libavfilter"): + version = line.split(b" ", 1)[1].split(b"/", 1)[0].split(b".") + version = tuple(map(int, version)) + if version >= (6, 67, 100): + incompatible_ffmpeg = False + if incompatible_ffmpeg: + raise FatalReplayGainError( + u"Installed FFmpeg version does not support ReplayGain." + u"calculation. Either libavfilter version 6.67.100 or above or" + u"the --enable-libebur128 configuration option is required." + ) + + # check that peak_method is valid + valid_peak_method = ("true", "sample") + if self._peak_method not in valid_peak_method: + raise ui.UserError( + u"Selected ReplayGain peak method {0} is not supported. " + u"Please select one of: {1}".format( + self._peak_method, + u', '.join(valid_peak_method) + ) + ) + + def compute_track_gain(self, items): + """Computes the track gain of the given tracks, returns a list + of Gain objects (the track gains). + """ + gains = [] + for item in items: + gains.append( + self._analyse_item( + item, + count_blocks=False, + )[0] # take only the gain, discarding number of gating blocks + ) + return gains + + def compute_album_gain(self, items): + """Computes the album gain of the given album, returns an + AlbumGain object. + """ + # analyse tracks + # list of track Gain objects + track_gains = [] + # maximum peak + album_peak = 0 + # sum of BS.1770 gating block powers + sum_powers = 0 + # total number of BS.1770 gating blocks + n_blocks = 0 + + for item in items: + track_gain, track_n_blocks = self._analyse_item(item) + + track_gains.append(track_gain) + + # album peak is maximum track peak + album_peak = max(album_peak, track_gain.peak) + + # prepare album_gain calculation + # total number of blocks is sum of track blocks + n_blocks += track_n_blocks + + # convert `LU to target_level` -> LUFS + track_loudness = self._target_level - track_gain.gain + # This reverses ITU-R BS.1770-4 p. 6 equation (5) to convert + # from loudness to power. The result is the average gating + # block power. + track_power = 10**((track_loudness + 0.691) / 10) + + # Weight that average power by the number of gating blocks to + # get the sum of all their powers. Add that to the sum of all + # block powers in this album. + sum_powers += track_power * track_n_blocks + + # calculate album gain + if n_blocks > 0: + # compare ITU-R BS.1770-4 p. 6 equation (5) + # Album gain is the replaygain of the concatenation of all tracks. + album_gain = -0.691 + 10 * math.log10(sum_powers / n_blocks) + else: + album_gain = -70 + # convert LUFS -> `LU to target_level` + album_gain = self._target_level - album_gain + + self._log.debug( + u"{0}: gain {1} LU, peak {2}" + .format(items, album_gain, album_peak) + ) + + return AlbumGain(Gain(album_gain, album_peak), track_gains) + + def _construct_cmd(self, item, peak_method): + """Construct the shell command to analyse items.""" + return [ + self._ffmpeg_path, + "-nostats", + "-hide_banner", + "-i", + item.path, + "-filter", + "ebur128=peak={0}".format(peak_method), + "-f", + "null", + "-", + ] + + def _analyse_item(self, item, count_blocks=True): + """Analyse item. Returns a Pair (Gain object, number of gating + blocks above threshold). + + If `count_blocks` is False, the number of gating blocks returned + will be 0. + """ + # call ffmpeg + self._log.debug(u"analyzing {0}".format(item)) + cmd = self._construct_cmd(item, self._peak_method) + self._log.debug( + u'executing {0}', u' '.join(map(displayable_path, cmd)) + ) + output = call(cmd).stderr.splitlines() + + # parse output + + if self._peak_method == "none": + peak = 0 + else: + line_peak = self._find_line( + output, + " {0} peak:".format(self._peak_method.capitalize()).encode(), + len(output) - 1, -1, + ) + peak = self._parse_float( + output[self._find_line( + output, b" Peak:", + line_peak, + )]) + # convert TPFS -> part of FS + peak = 10**(peak / 20) + + line_integrated_loudness = self._find_line( + output, b" Integrated loudness:", + len(output) - 1, -1, + ) + gain = self._parse_float( + output[self._find_line( + output, b" I:", + line_integrated_loudness, + )]) + # convert LUFS -> LU from target level + gain = self._target_level - gain + + # count BS.1770 gating blocks + n_blocks = 0 + if count_blocks: + gating_threshold = self._parse_float( + output[self._find_line( + output, b" Threshold:", + line_integrated_loudness, + )]) + for line in output: + if not line.startswith(b"[Parsed_ebur128"): + continue + if line.endswith(b"Summary:"): + continue + line = line.split(b"M:", 1) + if len(line) < 2: + continue + if self._parse_float(b"M: " + line[1]) >= gating_threshold: + n_blocks += 1 + self._log.debug( + u"{0}: {1} blocks over {2} LUFS" + .format(item, n_blocks, gating_threshold) + ) + + self._log.debug( + u"{0}: gain {1} LU, peak {2}" + .format(item, gain, peak) + ) + + return Gain(gain, peak), n_blocks + + def _find_line(self, output, search, start_index=0, step_size=1): + """Return index of line beginning with `search`. + + Begins searching at index `start_index` in `output`. + """ + end_index = len(output) if step_size > 0 else -1 + for i in range(start_index, end_index, step_size): + if output[i].startswith(search): + return i + raise ReplayGainError( + u"ffmpeg output: missing {0} after line {1}" + .format(repr(search), start_index) + ) + + def _parse_float(self, line): + """Extract a float. + + Extract a float from a key value pair in `line`. + """ + # extract value + value = line.split(b":", 1) + if len(value) < 2: + raise ReplayGainError( + u"ffmpeg ouput: expected key value pair, found {0}" + .format(line) + ) + value = value[1].lstrip() + # strip unit + value = value.split(b" ", 1)[0] + # cast value to as_type + try: + return float(value) + except ValueError: + raise ReplayGainError( + u"ffmpeg output: expected float value, found {1}" + .format(value) + ) + + def use_ebu_r128(self): + """Set this Backend up to use EBU R128.""" + self._target_level = -23 + self._peak_method = "none" # R128 tags do not need peak + + # mpgain/aacgain CLI tool backend. class CommandBackend(Backend): @@ -836,9 +1105,10 @@ class ReplayGainPlugin(BeetsPlugin): "gstreamer": GStreamerBackend, "audiotools": AudioToolsBackend, "bs1770gain": Bs1770gainBackend, + "ffmpeg": FfmpegBackend, } - r128_backend_names = ["bs1770gain"] + r128_backend_names = ["bs1770gain", "ffmpeg"] def __init__(self): super(ReplayGainPlugin, self).__init__() diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index 57630f1d60..6b3dc153f5 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -10,10 +10,10 @@ playback levels. Installation ------------ -This plugin can use one of three backends to compute the ReplayGain values: -GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools. mp3gain -can be easier to install but GStreamer and Audio Tools support more audio -formats. +This plugin can use one of many backends to compute the ReplayGain values: +GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools or ffmpeg. +mp3gain can be easier to install but GStreamer, Audio Tools and ffmpeg support +more audio formats. 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 @@ -75,6 +75,15 @@ On OS X, most of the dependencies can be installed with `Homebrew`_:: .. _Python Audio Tools: http://audiotools.sourceforge.net +ffmpeg +`````` + +This backend uses ffmpeg to calculate EBU R128 gain values. +To use it, install the `ffmpeg`_ command-line tool and select the +``ffmpeg`` backend in your config file. + +.. _ffmpeg: https://ffmpeg.org + Configuration ------------- @@ -106,6 +115,11 @@ These options only work with the "command" backend: would keep clipping from occurring. Default: ``yes``. +This option only works with the "ffmpeg" backend: + +- **peak**: Either ``true`` (the default) or ``sample``. ``true`` is + more accurate but slower. + Manual Analysis --------------- diff --git a/test/test_replaygain.py b/test/test_replaygain.py index b482da14e4..746a01355a 100644 --- a/test/test_replaygain.py +++ b/test/test_replaygain.py @@ -45,6 +45,8 @@ else: LOUDNESS_PROG_AVAILABLE = False +FFMPEG_AVAILABLE = has_program('ffmpeg', ['-version']) + def reset_replaygain(item): item['rg_track_peak'] = None @@ -205,6 +207,11 @@ def test_malformed_output(self, call_patch): self.assertEqual(len(matching), 2) +@unittest.skipIf(not FFMPEG_AVAILABLE, u'ffmpeg cannot be found') +class ReplayGainFfmpegTest(ReplayGainCliTestBase, unittest.TestCase): + backend = u'ffmpeg' + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__)