From 1385ce11cab8f7fc5c98ad313f0515ea1f660bfb Mon Sep 17 00:00:00 2001 From: jean-marie winters Date: Sat, 28 Feb 2015 15:35:48 +0100 Subject: [PATCH 1/6] Added support for bs1770gain, a loudness-scanner --- beetsplug/replaygain.py | 111 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 2 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index ce41cad57..655152f8e 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -21,6 +21,7 @@ import collections import itertools import sys import warnings +import re from beets import logging from beets import ui @@ -83,6 +84,109 @@ class Backend(object): raise NotImplementedError() +# BS1770GAIN CLI tool backend. +class bs1770gainBackend(Backend): + def __init__(self, config, log): + super(bs1770gainBackend, self).__init__(config, log) + self.command = 'bs1770gain' + self.method = config["method"].get(unicode) + if self.command: + # Check whether the program is in $PATH. + for cmd in ('bs1770gain'): + try: + call([cmd]) + self.command = cmd + except OSError: + pass + if not self.command: + raise FatalReplayGainError( + 'no bs1770gain command found: install bs1770gain' + ) + def compute_track_gain(self, items): + """Computes the track gain of the given tracks, returns a list + of TrackGain objects. + """ + #supported_items = filter(self.format_supported, items) + output = self.compute_gain(items, False) + return output + + def compute_album_gain(self, album): + """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? + + supported_items = album.items() + output = self.compute_gain(supported_items, True) + + return AlbumGain(output[-1], output[:-1]) + + def format_supported(self, item): + """Checks whether the given item is supported by the selected tool. + """ + if 'mp3gain' in self.command and item.format != 'MP3': + return False + elif 'aacgain' in self.command and item.format not in ('MP3', 'AAC'): + return False + return True + + def compute_gain(self, items, 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 + the album gain + """ + + if len(items) == 0: + return [] + + """Compute ReplayGain values and return a list of results + dictionaries as given by `parse_tool_output`. + """ + # Construct shell command. + cmd = [self.command] + cmd = cmd + [self.method] + cmd = cmd + ['-it'] + cmd = cmd + [syspath(i.path) for i in items] + + self._log.debug(u'analyzing {0} files', len(items)) + self._log.debug(u"executing {0}", " ".join(map(displayable_path, cmd))) + output = call(cmd) + self._log.debug(u'analysis finished') + results = self.parse_tool_output(output, + len(items) + (1 if is_album else 0)) + return results + + def parse_tool_output(self, text, num_lines): + """Given the output from bs1770gain, parse the text and + return a list of dictionaries + containing information about each analyzed file. + """ + out = [] + data = unicode(text,errors='ignore') + results=re.findall(r'(\s{2,2}\[\d+\/\d+\].*?|\[ALBUM\].*?)(?=\s{2,2}\[\d+\/\d+\]|\s{2,2}\[ALBUM\]:|done\.$)',data,re.S|re.M) + + for ll in results[0:num_lines]: + parts = ll.split(b'\n') + if len(parts) == 0: + self._log.debug(u'bad tool output: {0}', text) + raise ReplayGainError('bs1770gain failed') + + d = { + 'file': parts[0], + 'gain': float((parts[1].split('/'))[1].split('LU')[0]), + 'peak': float(parts[2].split('/')[1]), + } + + self._log.info('analysed {}gain={};peak={}', + d['file'].rstrip(), d['gain'], d['peak']) + out.append(Gain(d['gain'], d['peak'])) + return out + + +# GStreamer-based backend. + # mpgain/aacgain CLI tool backend. @@ -179,6 +283,7 @@ class CommandBackend(Backend): else: # Disable clipping warning. cmd = cmd + ['-c'] + cmd = cmd + ['-a' if is_album else '-r'] cmd = cmd + ['-d', bytes(self.gain_offset)] cmd = cmd + [syspath(i.path) for i in items] @@ -598,9 +703,10 @@ class ReplayGainPlugin(BeetsPlugin): """ backends = { - "command": CommandBackend, + "command": CommandBackend, "gstreamer": GStreamerBackend, - "audiotools": AudioToolsBackend + "audiotools": AudioToolsBackend, + "bs1770gain": bs1770gainBackend } def __init__(self): @@ -688,6 +794,7 @@ class ReplayGainPlugin(BeetsPlugin): ) self.store_album_gain(album, album_gain.album_gain) + for item, track_gain in itertools.izip(album.items(), album_gain.track_gains): self.store_track_gain(item, track_gain) From 72c5db88765bafb4e43ce072e5bdd21c37467f45 Mon Sep 17 00:00:00 2001 From: jean-marie winters Date: Mon, 2 Mar 2015 15:38:33 +0100 Subject: [PATCH 2/6] add doc, clean-up code --- beetsplug/replaygain.py | 114 +++++++++++++++++++++++++++++++++++- docs/plugins/replaygain.rst | 31 +++++++++- 2 files changed, 139 insertions(+), 6 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index ce41cad57..cd0022846 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -21,6 +21,7 @@ import collections import itertools import sys import warnings +import re from beets import logging from beets import ui @@ -32,12 +33,14 @@ from beets import config # Utilities. class ReplayGainError(Exception): + """Raised when a local (to a track or an album) error occurs in one of the backends. """ class FatalReplayGainError(Exception): + """Raised when a fatal error occurs in one of the backends. """ @@ -66,8 +69,10 @@ AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains") class Backend(object): + """An abstract class representing engine for calculating RG values. """ + def __init__(self, config, log): """Initialize the backend with the configuration view for the plugin. @@ -83,10 +88,108 @@ class Backend(object): raise NotImplementedError() +# bsg1770gain backend + + +class Bs1770gainBackend(Backend): + + def __init__(self, config, log): + super(Bs1770gainBackend, self).__init__(config, log) + cmd = 'bs1770gain' + + try: + self.method = '--' + config['method'].get(unicode) + except: + self.method = '--replaygain' + + try: + call([cmd, self.method]) + self.command = cmd + except OSError: + pass + if not self.command: + raise FatalReplayGainError( + 'no replaygain command found: install bs1770gain' + ) + + def compute_track_gain(self, items): + """Computes the track gain of the given tracks, returns a list + of TrackGain objects. + """ + output = self.compute_gain(items, False) + return output + + def compute_album_gain(self, album): + """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? + + supported_items = album.items() + output = self.compute_gain(supported_items, True) + + return AlbumGain(output[-1], output[:-1]) + + def compute_gain(self, items, 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 + the album gain + """ + + if len(items) == 0: + return [] + + """Compute ReplayGain values and return a list of results + dictionaries as given by `parse_tool_output`. + """ + # Construct shell command. + cmd = [self.command] + cmd = cmd + [self.method] + cmd = cmd + ['-it'] + cmd = cmd + [syspath(i.path) for i in items] + + self._log.debug(u'analyzing {0} files', len(items)) + self._log.debug(u"executing {0}", " ".join(map(displayable_path, cmd))) + output = call(cmd) + self._log.debug(u'analysis finished') + results = self.parse_tool_output(output, + len(items) + is_album) + return results + + def parse_tool_output(self, text, num_lines): + """Given the output from bs1770gain, parse the text and + return a list of dictionaries + containing information about each analyzed file. + """ + out = [] + data = unicode(text, errors='ignore') + regex = ("(\s{2,2}\[\d+\/\d+\].*?|\[ALBUM\].*?)(?=\s{2,2}\[\d+\/\d+\]" + "|\s{2,2}\[ALBUM\]:|done\.$)") + + results = re.findall(regex, data, re.S | re.M) + for ll in results[0:num_lines]: + parts = ll.split(b'\n') + if len(parts) == 0: + self._log.debug(u'bad tool output: {0}', text) + raise ReplayGainError('bs1770gain failed') + + d = { + 'file': parts[0], + 'gain': float((parts[1].split('/'))[1].split('LU')[0]), + 'peak': float(parts[2].split('/')[1]), + } + + self._log.info('analysed {}gain={};peak={}', + d['file'].rstrip(), d['gain'], d['peak']) + out.append(Gain(d['gain'], d['peak'])) + return out + + # mpgain/aacgain CLI tool backend. - - class CommandBackend(Backend): + def __init__(self, config, log): super(CommandBackend, self).__init__(config, log) config.add({ @@ -218,6 +321,7 @@ class CommandBackend(Backend): # GStreamer-based backend. class GStreamerBackend(Backend): + def __init__(self, config, log): super(GStreamerBackend, self).__init__(config, log) self._import_gst() @@ -466,10 +570,12 @@ class GStreamerBackend(Backend): class AudioToolsBackend(Backend): + """ReplayGain backend that uses `Python Audio Tools `_ and its capabilities to read more file formats and compute ReplayGain values using it replaygain module. """ + def __init__(self, config, log): super(CommandBackend, self).__init__(config, log) self._import_audiotools() @@ -594,13 +700,15 @@ class AudioToolsBackend(Backend): # Main plugin logic. class ReplayGainPlugin(BeetsPlugin): + """Provides ReplayGain analysis. """ backends = { "command": CommandBackend, "gstreamer": GStreamerBackend, - "audiotools": AudioToolsBackend + "audiotools": AudioToolsBackend, + "bs1770gain": Bs1770gainBackend } def __init__(self): diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index d2584a648..8c0bf610d 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -10,9 +10,9 @@ playback levels. Installation ------------ -This plugin can use one of three backends to compute the ReplayGain values: -GStreamer, mp3gain (and its cousin, aacgain), and Python Audio Tools. mp3gain -can be easier to install but GStreamer and Audio Tools support more audio +This plugin can use one of four backends to compute the ReplayGain values: +GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools and bs1770gain. mp3gain +can be easier to install but GStreamer, Audio Tools and bs1770gain support more audio formats. Once installed, this plugin analyzes all files during the import process. This @@ -75,6 +75,22 @@ On OS X, most of the dependencies can be installed with `Homebrew`_:: .. _Python Audio Tools: http://audiotools.sourceforge.net +bs1770gain +`````````` + +In order to use this backend, you will need to install the bs1770gain command-line tool. Here are some hints: + +* goto `bs1770gain`_ and follow the download instructions +* make sure it is in your $PATH + +.. _bs1770gain: bs1770gain.sourceforge.net + +Then, enable the plugin (see :ref:`using-plugins`) and specify the +backend in your configuration file:: + + replaygain: + backend: bs1770gain + Configuration ------------- @@ -100,6 +116,15 @@ These options only work with the "command" backend: would keep clipping from occurring. Default: ``yes``. +This option only works with the "bs1770gain" backend: + +- **method**:either replaygain, ebu or atsc. Default: replaygain + + replaygain measures loudness with a reference level of -18 LUFS. + ebu measures loudness with a reference level of -23 LUFS. + atsc measures loudness with a reference level of -24 LUFS. + + Manual Analysis --------------- From 5bc8ef700910aa84753a0a1fdc87f88bb938a945 Mon Sep 17 00:00:00 2001 From: jean-marie winters Date: Mon, 2 Mar 2015 22:11:33 +0100 Subject: [PATCH 3/6] fix some formating --- beetsplug/replaygain.py | 19 ++++++++----------- docs/plugins/replaygain.rst | 8 ++++---- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index c52cbe053..a13b58985 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -88,12 +88,14 @@ class Backend(object): raise NotImplementedError() - # bsg1770gain backend - - class Bs1770gainBackend(Backend): + """bs1770gain is a loudness scanner compliant with ITU-R BS.1770 and its + flavors EBU R128,ATSC A/85 and Replaygain 2.0. It uses a special + designed algorithm to normalize audio to the same level. + """ + def __init__(self, config, log): super(Bs1770gainBackend, self).__init__(config, log) cmd = 'bs1770gain' @@ -107,19 +109,17 @@ class Bs1770gainBackend(Backend): call([cmd, self.method]) self.command = cmd except OSError: - pass + pass if not self.command: raise FatalReplayGainError( 'no replaygain command found: install bs1770gain' ) - def compute_track_gain(self, items): """Computes the track gain of the given tracks, returns a list of TrackGain objects. """ - output = self.compute_gain(items, False) return output @@ -135,8 +135,6 @@ class Bs1770gainBackend(Backend): return AlbumGain(output[-1], output[:-1]) - - def compute_gain(self, items, is_album): """Computes the track or album gain of a list of items, returns a list of TrackGain objects. @@ -147,7 +145,6 @@ class Bs1770gainBackend(Backend): if len(items) == 0: return [] - """Compute ReplayGain values and return a list of results dictionaries as given by `parse_tool_output`. """ @@ -171,7 +168,7 @@ class Bs1770gainBackend(Backend): containing information about each analyzed file. """ out = [] - data = unicode(text, errors='ignore') + data = text.decode('utf8', errors='ignore') regex = ("(\s{2,2}\[\d+\/\d+\].*?|\[ALBUM\].*?)(?=\s{2,2}\[\d+\/\d+\]" "|\s{2,2}\[ALBUM\]:|done\.$)") @@ -179,7 +176,7 @@ class Bs1770gainBackend(Backend): for ll in results[0:num_lines]: parts = ll.split(b'\n') if len(parts) == 0: - self._log.debug(u'bad tool output: {0}', text) + self._log.debug(u'bad tool output: {0!r}', text) raise ReplayGainError('bs1770gain failed') d = { diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index 8c0bf610d..f08a23953 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -118,11 +118,11 @@ These options only work with the "command" backend: This option only works with the "bs1770gain" backend: -- **method**:either replaygain, ebu or atsc. Default: replaygain +- **method**: The loudness scanning standard: either 'replaygain' for ReplayGain 2.0, + 'ebu' for EBU R128 or 'atsc' for ATSC A/85. + This dictates the reference level: -18, -23, or -24 LUFS respectively. Default: replaygain + - replaygain measures loudness with a reference level of -18 LUFS. - ebu measures loudness with a reference level of -23 LUFS. - atsc measures loudness with a reference level of -24 LUFS. Manual Analysis From 80c49ab36098398a4fbcb2a0ec920f4385e151ab Mon Sep 17 00:00:00 2001 From: jean-marie winters Date: Tue, 3 Mar 2015 11:04:03 +0100 Subject: [PATCH 4/6] removed line 288 --- beetsplug/replaygain.py | 1 - 1 file changed, 1 deletion(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index bbaa47d0b..dcb98ff3a 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -286,7 +286,6 @@ class CommandBackend(Backend): else: # Disable clipping warning. cmd = cmd + ['-c'] - cmd = cmd + ['-a' if is_album else '-r'] cmd = cmd + ['-d', bytes(self.gain_offset)] cmd = cmd + [syspath(i.path) for i in items] From a3e32fd410118e9f479cce53bc8e487d7d434b1d Mon Sep 17 00:00:00 2001 From: jean-marie winters Date: Tue, 3 Mar 2015 11:23:45 +0100 Subject: [PATCH 5/6] added fatalreplaygainerror --- beetsplug/replaygain.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index dcb98ff3a..39d1f0382 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -109,7 +109,9 @@ class Bs1770gainBackend(Backend): call([cmd, self.method]) self.command = cmd except OSError: - pass + raise FatalReplayGainError( + 'Is bs1770gain installed? Is your method in conifg correct?' + ) if not self.command: raise FatalReplayGainError( 'no replaygain command found: install bs1770gain' From 5d7d402adb5642e8604aa766b2efc088b2baa00e Mon Sep 17 00:00:00 2001 From: jmwatte Date: Tue, 3 Mar 2015 13:11:25 +0100 Subject: [PATCH 6/6] correct typing error --- beetsplug/replaygain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 39d1f0382..e9d71619f 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -110,7 +110,7 @@ class Bs1770gainBackend(Backend): self.command = cmd except OSError: raise FatalReplayGainError( - 'Is bs1770gain installed? Is your method in conifg correct?' + 'Is bs1770gain installed? Is your method in config correct?' ) if not self.command: raise FatalReplayGainError(