From 7a7314ee3f921805fac3d4bc55e59e29d81526a8 Mon Sep 17 00:00:00 2001 From: Zsin Skri Date: Wed, 17 Oct 2018 22:27:02 +0200 Subject: [PATCH 1/6] Allow other ReplayGain backends to support R128. Previously using EBU R128 forced the use of the bs1770gain backend. This change adds a whitelist of backends supporting R128. When the configured backend is in that list it will also be used for R128 calculations. Otherwise bs1770gain is still used as a default. This should not change the overall behaviour of the program at all, but allow for further R128-supporting backends to be added. --- beetsplug/replaygain.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 58a4df83c..084172336 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -88,6 +88,10 @@ class Backend(object): # individual tracks which can be used for any backend. raise NotImplementedError() + def use_ebu_r128(self): + """Set this Backend up to use EBU R128.""" + pass + # bsg1770gain backend class Bs1770gainBackend(Backend): @@ -277,6 +281,10 @@ class Bs1770gainBackend(Backend): out.append(album_gain["album"]) return out + def use_ebu_r128(self): + """Set this Backend up to use EBU R128.""" + self.method = '--ebu' + # mpgain/aacgain CLI tool backend. class CommandBackend(Backend): @@ -830,6 +838,8 @@ class ReplayGainPlugin(BeetsPlugin): "bs1770gain": Bs1770gainBackend, } + r128_backend_names = ["bs1770gain"] + def __init__(self): super(ReplayGainPlugin, self).__init__() @@ -1024,7 +1034,9 @@ class ReplayGainPlugin(BeetsPlugin): u"Fatal replay gain error: {0}".format(e)) def init_r128_backend(self): - backend_name = 'bs1770gain' + backend_name = self.config["backend"].as_str() + if backend_name not in self.r128_backend_names: + backend_name = "bs1770gain" try: self.r128_backend_instance = self.backends[backend_name]( @@ -1034,7 +1046,7 @@ class ReplayGainPlugin(BeetsPlugin): raise ui.UserError( u'replaygain initialization failed: {0}'.format(e)) - self.r128_backend_instance.method = '--ebu' + self.r128_backend_instance.use_ebu_r128() def imported(self, session, task): """Add replay gain info to items or albums of ``task``. From c3af5b3763b6bdafe66d53cb493660720eb93192 Mon Sep 17 00:00:00 2001 From: Zsin Skri Date: Thu, 18 Oct 2018 20:50:19 +0200 Subject: [PATCH 2/6] replaygain: add ffmpeg backend Add replaygain backend using ffmpeg's ebur128 filter. The album gain is calculated as the mean of all BS.1770 gating block powers. Besides differences in gating block offset, this should be equivalent to a BS.1770 analysis of a proper concatenation of all tracks. Just calculating the mean of all track gains (as implemented by the bs1770gain backend) yields incorrect results as that would: - completely ignore track lengths - just using length in seconds won't work either (e.g. BS.1770 ignores passages below a threshold) - take the mean of track loudness, not power When using the ffmpeg replaygain backend to create R128_*_GAIN tags, the targetlevel will be set to -23 LUFS. GitHub PullRequest #3065 will make this configurable. It will also skip peak calculation, as there is no R128_*_PEAK tag. It is checked if the libavfilter library supports replaygain calculation. Before version 6.67.100 that did require the `--enable-libebur128` compile-time-option, after that the ebur128 library is included in libavfilter itself. Thus we require either a recent enough libavfilter version or the `--enable-libebur128` option. --- beetsplug/replaygain.py | 276 +++++++++++++++++++++++++++++++++++- docs/plugins/replaygain.rst | 22 ++- test/test_replaygain.py | 7 + 3 files changed, 298 insertions(+), 7 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 084172336..80934c1ec 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -18,6 +18,7 @@ from __future__ import division, absolute_import, print_function 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 @@ class Backend(object): 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 @@ class Bs1770gainBackend(Backend): 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 57630f1d6..6b3dc153f 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 b482da14e..746a01355 100644 --- a/test/test_replaygain.py +++ b/test/test_replaygain.py @@ -45,6 +45,8 @@ if has_program('bs1770gain', ['--replaygain']): else: LOUDNESS_PROG_AVAILABLE = False +FFMPEG_AVAILABLE = has_program('ffmpeg', ['-version']) + def reset_replaygain(item): item['rg_track_peak'] = None @@ -205,6 +207,11 @@ class ReplayGainLdnsCliMalformedTest(TestHelper, unittest.TestCase): 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__) From b589521755daf5a670b65c4c5b1afe7c605c1903 Mon Sep 17 00:00:00 2001 From: Zsin Skri Date: Wed, 7 Nov 2018 19:30:30 +0100 Subject: [PATCH 3/6] changelog entry: ffmpeg replaygain backend Add changelog entry for the new ffmpeg replaygain backend. --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 001509272..2b6740d48 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,9 @@ New features: level. Thanks to :user:`samuelnilsson` :bug:`293` +* :doc:`/plugins/replaygain`: The new ``ffmpeg`` ReplayGain backend supports + ``R128_`` tags, just like the ``bs1770gain`` backend. + :bug:`3056` * A new :doc:`/plugins/parentwork` gets information about the original work, which is useful for classical music. Thanks to :user:`dosoe`. From 271a3c980ca3f461028979b3ddf832d31c5e48e4 Mon Sep 17 00:00:00 2001 From: Zsin Skri Date: Wed, 17 Jul 2019 19:50:43 +0200 Subject: [PATCH 4/6] replaygain: ffmpeg: increase parser readability Use keyword arguments to make the ffmpeg parser more readable. --- beetsplug/replaygain.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 80934c1ec..5817a8a75 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -459,25 +459,27 @@ class FfmpegBackend(Backend): line_peak = self._find_line( output, " {0} peak:".format(self._peak_method.capitalize()).encode(), - len(output) - 1, -1, - ) + start_line=(len(output) - 1), step_size=-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, - ) + start_line=(len(output) - 1), step_size=-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 @@ -487,8 +489,9 @@ class FfmpegBackend(Backend): gating_threshold = self._parse_float( output[self._find_line( output, b" Threshold:", - line_integrated_loudness, - )]) + start_line=line_integrated_loudness, + )] + ) for line in output: if not line.startswith(b"[Parsed_ebur128"): continue @@ -502,27 +505,27 @@ class FfmpegBackend(Backend): 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): + def _find_line(self, output, search, start_line=0, step_size=1): """Return index of line beginning with `search`. - Begins searching at index `start_index` in `output`. + Begins searching at index `start_line` in `output`. """ end_index = len(output) if step_size > 0 else -1 - for i in range(start_index, end_index, step_size): + for i in range(start_line, 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) + .format(repr(search), start_line) ) def _parse_float(self, line): From f9ff56f4968ad951434aef97fcc6e4cac88d70b4 Mon Sep 17 00:00:00 2001 From: Zsin Skri Date: Sun, 21 Jul 2019 01:18:49 +0200 Subject: [PATCH 5/6] improve wording in the ffmpeg replaygain backend This commit mostly addresses feedback: - remove some unused parenthesis - fix a typo - expand some docstrings - document that ffmpeg is usually easy to install --- beetsplug/replaygain.py | 19 ++++++++++--------- docs/plugins/replaygain.rst | 4 ++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 5817a8a75..d9f8c02d9 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -306,7 +306,7 @@ class Bs1770gainBackend(Backend): # ffmpeg backend class FfmpegBackend(Backend): - """A replaygain backend using ffmpegs ebur128 filter. + """A replaygain backend using ffmpeg's ebur128 filter. """ def __init__(self, config, log): super(FfmpegBackend, self).__init__(config, log) @@ -342,7 +342,7 @@ class FfmpegBackend(Backend): ) # check that peak_method is valid - valid_peak_method = ("true", "sample") + 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. " @@ -437,8 +437,8 @@ class FfmpegBackend(Backend): ] def _analyse_item(self, item, count_blocks=True): - """Analyse item. Returns a Pair (Gain object, number of gating - blocks above threshold). + """Analyse item. Return a pair of a Gain object and the number + of gating blocks above the threshold. If `count_blocks` is False, the number of gating blocks returned will be 0. @@ -459,7 +459,7 @@ class FfmpegBackend(Backend): line_peak = self._find_line( output, " {0} peak:".format(self._peak_method.capitalize()).encode(), - start_line=(len(output) - 1), step_size=-1, + start_line=len(output) - 1, step_size=-1, ) peak = self._parse_float( output[self._find_line( @@ -472,7 +472,7 @@ class FfmpegBackend(Backend): line_integrated_loudness = self._find_line( output, b" Integrated loudness:", - start_line=(len(output) - 1), step_size=-1, + start_line=len(output) - 1, step_size=-1, ) gain = self._parse_float( output[self._find_line( @@ -529,9 +529,10 @@ class FfmpegBackend(Backend): ) def _parse_float(self, line): - """Extract a float. + """Extract a float from a key value pair in `line`. - Extract a float from a key value pair in `line`. + This format is expected: /[^:]:\s*value.*/, where `value` is + the float. """ # extract value value = line.split(b":", 1) @@ -543,7 +544,7 @@ class FfmpegBackend(Backend): value = value[1].lstrip() # strip unit value = value.split(b" ", 1)[0] - # cast value to as_type + # cast value to float try: return float(value) except ValueError: diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index 6b3dc153f..a68f3f7fc 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -12,8 +12,8 @@ Installation 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. +ffmpeg and mp3gain can be easier to install. mp3gain supports less audio formats +then the other backend. 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 From e5f2fe6fd322450c09f5ec175af91f3b4b9d4f90 Mon Sep 17 00:00:00 2001 From: Zsin Skri Date: Sun, 21 Jul 2019 01:28:16 +0200 Subject: [PATCH 6/6] avoid test failure Use the POSIX character class instead of `\s` to match all whitespace in a regular expression describing the language of valid inputs, in order to avoid a test failure for the invalid escape sequence `\s` in Python strings. --- beetsplug/replaygain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index d9f8c02d9..febacbc1b 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -531,7 +531,7 @@ class FfmpegBackend(Backend): def _parse_float(self, line): """Extract a float from a key value pair in `line`. - This format is expected: /[^:]:\s*value.*/, where `value` is + This format is expected: /[^:]:[[:space:]]*value.*/, where `value` is the float. """ # extract value