diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index febacbc1b..ea0d70aa5 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -21,6 +21,7 @@ import collections import math import sys import warnings +import enum import xml.parsers.expat from six.moves import zip @@ -74,6 +75,15 @@ def db_to_lufs(db): return db - 107 +def lufs_to_db(db): + """Convert LUFS to db. + + 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 @@ -84,6 +94,12 @@ Gain = collections.namedtuple("Gain", "gain peak") AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains") +class Peak(enum.Enum): + none = 0 + true = 1 + sample = 2 + + class Backend(object): """An abstract class representing engine for calculating RG values. """ @@ -94,22 +110,18 @@ class Backend(object): """ self._log = log - def compute_track_gain(self, items): + def compute_track_gain(self, items, target_level, peak): """Computes the track gain of the given tracks, returns a list of Gain objects. """ raise NotImplementedError() - def compute_album_gain(self, items): + def compute_album_gain(self, items, target_level, peak): """Computes the album gain of the given album, returns an AlbumGain object. """ raise NotImplementedError() - def use_ebu_r128(self): - """Set this Backend up to use EBU R128.""" - pass - # bsg1770gain backend class Bs1770gainBackend(Backend): @@ -117,44 +129,51 @@ class Bs1770gainBackend(Backend): its flavors EBU R128, ATSC A/85 and Replaygain 2.0. """ + methods = { + -24: "atsc", + -23: "ebu", + -18: "replaygain", + } + def __init__(self, config, log): super(Bs1770gainBackend, self).__init__(config, log) config.add({ 'chunk_at': 5000, - 'method': 'replaygain', + 'method': '', }) self.chunk_at = config['chunk_at'].as_number() - self.method = '--' + config['method'].as_str() + # backward compatibility to `method` config option + self.__method = config['method'].as_str() cmd = 'bs1770gain' try: - call([cmd, self.method]) + call([cmd, "--help"]) self.command = cmd except OSError: raise FatalReplayGainError( - u'Is bs1770gain installed? Is your method in config correct?' + u'Is bs1770gain installed?' ) if not self.command: raise FatalReplayGainError( u'no replaygain command found: install bs1770gain' ) - def compute_track_gain(self, items): + def compute_track_gain(self, items, target_level, peak): """Computes the track gain of the given tracks, returns a list of TrackGain objects. """ - output = self.compute_gain(items, False) + output = self.compute_gain(items, target_level, False) return output - def compute_album_gain(self, items): + def compute_album_gain(self, items, target_level, peak): """Computes the album gain of the given album, returns an AlbumGain object. """ # TODO: What should be done when not all tracks in the album are # supported? - output = self.compute_gain(items, True) + output = self.compute_gain(items, target_level, True) if not output: raise ReplayGainError(u'no output from bs1770gain') @@ -179,7 +198,7 @@ class Bs1770gainBackend(Backend): else: break - def compute_gain(self, items, is_album): + def compute_gain(self, items, target_level, is_album): """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 @@ -200,22 +219,38 @@ class Bs1770gainBackend(Backend): i = 0 for chunk in self.isplitter(items, self.chunk_at): i += 1 - returnchunk = self.compute_chunk_gain(chunk, is_album) + returnchunk = self.compute_chunk_gain( + chunk, + is_album, + target_level + ) albumgaintot += returnchunk[-1].gain albumpeaktot = max(albumpeaktot, returnchunk[-1].peak) returnchunks = returnchunks + returnchunk[0:-1] returnchunks.append(Gain(albumgaintot / i, albumpeaktot)) return returnchunks else: - return self.compute_chunk_gain(items, is_album) + return self.compute_chunk_gain(items, is_album, target_level) - def compute_chunk_gain(self, items, is_album): + def compute_chunk_gain(self, items, is_album, target_level): """Compute ReplayGain values and return a list of results dictionaries as given by `parse_tool_output`. """ + # choose method + target_level = db_to_lufs(target_level) + if self.__method != "": + # backward compatibility to `method` option + method = self.__method + elif target_level in self.methods: + method = self.methods[target_level] + gain_adjustment = 0 + else: + method = self.methods[-23] + gain_adjustment = target_level - lufs_to_db(-23) + # Construct shell command. cmd = [self.command] - cmd += [self.method] + cmd += ["--" + method] cmd += ['--xml', '-p'] # Workaround for Windows: the underlying tool fails on paths @@ -232,6 +267,12 @@ class Bs1770gainBackend(Backend): self._log.debug(u'analysis finished: {0}', output) results = self.parse_tool_output(output, path_list, is_album) + + if gain_adjustment != 0: + for i in range(len(results)): + orig = results[i] + results[i] = Gain(orig.gain + gain_adjustment, orig.peak) + self._log.debug(u'{0} items, {1} results', len(items), len(results)) return results @@ -299,10 +340,6 @@ 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' - # ffmpeg backend class FfmpegBackend(Backend): @@ -310,11 +347,6 @@ class FfmpegBackend(Backend): """ 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 @@ -341,18 +373,7 @@ class FfmpegBackend(Backend): 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): + def compute_track_gain(self, items, target_level, peak): """Computes the track gain of the given tracks, returns a list of Gain objects (the track gains). """ @@ -361,15 +382,19 @@ class FfmpegBackend(Backend): gains.append( self._analyse_item( item, + target_level, + peak, count_blocks=False, )[0] # take only the gain, discarding number of gating blocks ) return gains - def compute_album_gain(self, items): + def compute_album_gain(self, items, target_level, peak): """Computes the album gain of the given album, returns an AlbumGain object. """ + target_level_lufs = db_to_lufs(target_level) + # analyse tracks # list of track Gain objects track_gains = [] @@ -381,8 +406,9 @@ class FfmpegBackend(Backend): n_blocks = 0 for item in items: - track_gain, track_n_blocks = self._analyse_item(item) - + track_gain, track_n_blocks = self._analyse_item( + item, target_level, peak + ) track_gains.append(track_gain) # album peak is maximum track peak @@ -393,7 +419,7 @@ class FfmpegBackend(Backend): n_blocks += track_n_blocks # convert `LU to target_level` -> LUFS - track_loudness = self._target_level - track_gain.gain + track_loudness = target_level_lufs - 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. @@ -412,7 +438,7 @@ class FfmpegBackend(Backend): else: album_gain = -70 # convert LUFS -> `LU to target_level` - album_gain = self._target_level - album_gain + album_gain = target_level_lufs - album_gain self._log.debug( u"{0}: gain {1} LU, peak {2}" @@ -436,16 +462,23 @@ class FfmpegBackend(Backend): "-", ] - def _analyse_item(self, item, count_blocks=True): + def _analyse_item(self, item, target_level, peak, count_blocks=True): """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. """ + target_level_lufs = db_to_lufs(target_level) + peak_method = { + Peak.none: "none", + Peak.true: "true", + Peak.sample: "sample", + }[peak] + # call ffmpeg self._log.debug(u"analyzing {0}".format(item)) - cmd = self._construct_cmd(item, self._peak_method) + cmd = self._construct_cmd(item, peak_method) self._log.debug( u'executing {0}', u' '.join(map(displayable_path, cmd)) ) @@ -453,12 +486,12 @@ class FfmpegBackend(Backend): # parse output - if self._peak_method == "none": + if peak is Peak.none: peak = 0 else: line_peak = self._find_line( output, - " {0} peak:".format(self._peak_method.capitalize()).encode(), + " {0} peak:".format(peak_method.capitalize()).encode(), start_line=len(output) - 1, step_size=-1, ) peak = self._parse_float( @@ -481,7 +514,7 @@ class FfmpegBackend(Backend): )] ) # convert LUFS -> LU from target level - gain = self._target_level - gain + gain = target_level_lufs - gain # count BS.1770 gating blocks n_blocks = 0 @@ -553,11 +586,6 @@ class FfmpegBackend(Backend): .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): @@ -592,18 +620,16 @@ class CommandBackend(Backend): ) self.noclip = config['noclip'].get(bool) - target_level = config['targetlevel'].as_number() - self.gain_offset = int(target_level - 89) - def compute_track_gain(self, items): + def compute_track_gain(self, items, target_level, peak): """Computes the track gain of the given tracks, returns a list of TrackGain objects. """ supported_items = list(filter(self.format_supported, items)) - output = self.compute_gain(supported_items, False) + output = self.compute_gain(supported_items, target_level, False) return output - def compute_album_gain(self, items): + def compute_album_gain(self, items, target_level, peak): """Computes the album gain of the given album, returns an AlbumGain object. """ @@ -615,7 +641,7 @@ class CommandBackend(Backend): self._log.debug(u'tracks are of unsupported format') return AlbumGain(None, []) - output = self.compute_gain(supported_items, True) + output = self.compute_gain(supported_items, target_level, True) return AlbumGain(output[-1], output[:-1]) def format_supported(self, item): @@ -627,7 +653,7 @@ class CommandBackend(Backend): return False return True - def compute_gain(self, items, is_album): + def compute_gain(self, items, target_level, is_album): """Computes the track or album gain of a list of items, returns a list of TrackGain objects. @@ -654,7 +680,7 @@ class CommandBackend(Backend): else: # Disable clipping warning. cmd = cmd + ['-c'] - cmd = cmd + ['-d', str(self.gain_offset)] + cmd = cmd + ['-d', str(int(target_level - 89))] cmd = cmd + [syspath(i.path) for i in items] self._log.debug(u'analyzing {0} files', len(items)) @@ -717,8 +743,6 @@ class GStreamerBackend(Backend): # to rganalsys should have their gain computed, even if it # already exists. self._rg.set_property("forced", True) - self._rg.set_property("reference-level", - config["targetlevel"].as_number()) self._sink = self.Gst.ElementFactory.make("fakesink", "sink") self._pipe = self.Gst.Pipeline() @@ -779,7 +803,7 @@ class GStreamerBackend(Backend): self.GLib = GLib self.Gst = Gst - def compute(self, files, album): + def compute(self, files, target_level, album): self._error = None self._files = list(files) @@ -788,6 +812,8 @@ class GStreamerBackend(Backend): self._file_tags = collections.defaultdict(dict) + self._rg.set_property("reference-level", target_level) + if album: self._rg.set_property("num-tracks", len(self._files)) @@ -796,8 +822,8 @@ class GStreamerBackend(Backend): if self._error is not None: raise self._error - def compute_track_gain(self, items): - self.compute(items, False) + def compute_track_gain(self, items, target_level, peak): + self.compute(items, target_level, False) if len(self._file_tags) != len(items): raise ReplayGainError(u"Some tracks did not receive tags") @@ -808,9 +834,9 @@ class GStreamerBackend(Backend): return ret - def compute_album_gain(self, items): + def compute_album_gain(self, items, target_level, peak): items = list(items) - self.compute(items, True) + self.compute(items, target_level, True) if len(self._file_tags) != len(items): raise ReplayGainError(u"Some items in album did not receive tags") @@ -1024,14 +1050,21 @@ class AudioToolsBackend(Backend): return return rg - def compute_track_gain(self, items): + def compute_track_gain(self, items, target_level, peak): """Compute ReplayGain values for the requested items. :return list: list of :class:`Gain` objects """ - return [self._compute_track_gain(item) for item in items] + return [self._compute_track_gain(item, target_level) for item in items] - def _title_gain(self, rg, audiofile): + def _with_target_level(self, gain, target_level): + """Return `gain` relative to `target_level`. + + Assumes `gain` is relative to 89 db. + """ + return gain + (target_level - 89) + + def _title_gain(self, rg, audiofile, target_level): """Get the gain result pair from PyAudioTools using the `ReplayGain` instance `rg` for the given `audiofile`. @@ -1041,14 +1074,15 @@ class AudioToolsBackend(Backend): try: # The method needs an audiotools.PCMReader instance that can # be obtained from an audiofile instance. - return rg.title_gain(audiofile.to_pcm()) + gain, peak = rg.title_gain(audiofile.to_pcm()) except ValueError as exc: # `audiotools.replaygain` can raise a `ValueError` if the sample # rate is incorrect. self._log.debug(u'error in rg.title_gain() call: {}', exc) raise ReplayGainError(u'audiotools audio data error') + return self._with_target_level(gain, target_level), peak - def _compute_track_gain(self, item): + def _compute_track_gain(self, item, target_level): """Compute ReplayGain value for the requested item. :rtype: :class:`Gain` @@ -1058,13 +1092,15 @@ class AudioToolsBackend(Backend): # Each call to title_gain on a ReplayGain object returns peak and gain # of the track. - rg_track_gain, rg_track_peak = self._title_gain(rg, audiofile) + rg_track_gain, rg_track_peak = self._title_gain( + rg, audiofile, target_level + ) self._log.debug(u'ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}', item.artist, item.title, rg_track_gain, rg_track_peak) return Gain(gain=rg_track_gain, peak=rg_track_peak) - def compute_album_gain(self, items): + def compute_album_gain(self, items, target_level, peak): """Compute ReplayGain values for the requested album and its items. :rtype: :class:`AlbumGain` @@ -1079,7 +1115,9 @@ class AudioToolsBackend(Backend): track_gains = [] for item in items: audiofile = self.open_audio_file(item) - rg_track_gain, rg_track_peak = self._title_gain(rg, audiofile) + rg_track_gain, rg_track_peak = self._title_gain( + rg, audiofile, target_level + ) track_gains.append( Gain(gain=rg_track_gain, peak=rg_track_peak) ) @@ -1089,6 +1127,7 @@ class AudioToolsBackend(Backend): # After getting the values for all tracks, it's possible to get the # album values. rg_album_gain, rg_album_peak = rg.album_gain() + rg_album_gain = self._with_target_level(rg_album_gain, target_level) self._log.debug(u'ReplayGain for album {0}: {1:.2f}, {2:.2f}', items[0].album, rg_album_gain, rg_album_peak) @@ -1112,7 +1151,10 @@ class ReplayGainPlugin(BeetsPlugin): "ffmpeg": FfmpegBackend, } - r128_backend_names = ["bs1770gain", "ffmpeg"] + peak_methods = { + "true": Peak.true, + "sample": Peak.sample, + } def __init__(self): super(ReplayGainPlugin, self).__init__() @@ -1124,7 +1166,8 @@ class ReplayGainPlugin(BeetsPlugin): 'backend': u'command', 'targetlevel': 89, 'r128': ['Opus'], - 'per_disc': False + 'per_disc': False, + "peak": "true", }) self.overwrite = self.config['overwrite'].get(bool) @@ -1138,6 +1181,16 @@ class ReplayGainPlugin(BeetsPlugin): u', '.join(self.backends.keys()) ) ) + peak_method = self.config["peak"].as_str() + if peak_method not in self.peak_methods: + raise ui.UserError( + u"Selected ReplayGain peak method {0} is not supported. " + u"Please select one of: {1}".format( + peak_method, + u', '.join(self.peak_methods.keys()) + ) + ) + self._peak_method = self.peak_methods[peak_method] # On-import analysis. if self.config['auto']: @@ -1154,8 +1207,6 @@ class ReplayGainPlugin(BeetsPlugin): raise ui.UserError( u'replaygain initialization failed: {0}'.format(e)) - self.r128_backend_instance = '' - def should_use_r128(self, item): """Checks the plugin setting to decide whether the calculation should be done using the EBU R128 standard and use R128_ tags instead. @@ -1208,6 +1259,20 @@ class ReplayGainPlugin(BeetsPlugin): self._log.debug(u'applied r128 album gain {0} LU', item.r128_album_gain) + def tag_specific_values(self, items): + """Return some tag specific values. + + Returns a tuple (store_track_gain, store_album_gain). + """ + if any([self.should_use_r128(item) for item in items]): + store_track_gain = self.store_track_r128_gain + store_album_gain = self.store_album_r128_gain + else: + store_track_gain = self.store_track_gain + store_album_gain = self.store_album_gain + + return store_track_gain, store_album_gain + def handle_album(self, album, write, force=False): """Compute album and track replay gain store it in all of the album's items. @@ -1229,16 +1294,8 @@ class ReplayGainPlugin(BeetsPlugin): u" for some tracks in album {0}".format(album) ) - if any([self.should_use_r128(item) for item in album.items()]): - if self.r128_backend_instance == '': - self.init_r128_backend() - backend_instance = self.r128_backend_instance - store_track_gain = self.store_track_r128_gain - store_album_gain = self.store_album_r128_gain - else: - backend_instance = self.backend_instance - store_track_gain = self.store_track_gain - store_album_gain = self.store_album_gain + tag_vals = self.tag_specific_values(album.items()) + store_track_gain, store_album_gain = tag_vals discs = dict() if self.per_disc: @@ -1251,7 +1308,11 @@ class ReplayGainPlugin(BeetsPlugin): for discnumber, items in discs.items(): try: - album_gain = backend_instance.compute_album_gain(items) + album_gain = self.backend_instance.compute_album_gain( + items, + self.config['targetlevel'].as_number(), + self._peak_method, + ) if len(album_gain.track_gains) != len(items): raise ReplayGainError( u"ReplayGain backend failed " @@ -1282,17 +1343,15 @@ class ReplayGainPlugin(BeetsPlugin): self._log.info(u'analyzing {0}', item) - if self.should_use_r128(item): - if self.r128_backend_instance == '': - self.init_r128_backend() - backend_instance = self.r128_backend_instance - store_track_gain = self.store_track_r128_gain - else: - backend_instance = self.backend_instance - store_track_gain = self.store_track_gain + tag_vals = self.tag_specific_values([item]) + store_track_gain, store_album_gain = tag_vals try: - track_gains = backend_instance.compute_track_gain([item]) + track_gains = self.backend_instance.compute_track_gain( + [item], + self.config['targetlevel'].as_number(), + self._peak_method, + ) if len(track_gains) != 1: raise ReplayGainError( u"ReplayGain backend failed for track {0}".format(item) @@ -1307,21 +1366,6 @@ class ReplayGainPlugin(BeetsPlugin): raise ui.UserError( u"Fatal replay gain error: {0}".format(e)) - def init_r128_backend(self): - 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]( - self.config, self._log - ) - except (ReplayGainError, FatalReplayGainError) as e: - raise ui.UserError( - u'replaygain initialization failed: {0}'.format(e)) - - self.r128_backend_instance.use_ebu_r128() - def imported(self, session, task): """Add replay gain info to items or albums of ``task``. """