diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 9d6fa23c4..5060c8efe 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -15,26 +15,23 @@ from __future__ import division, absolute_import, print_function -import subprocess -import os import collections +import enum import math +import os +import signal +import six +import subprocess import sys import warnings -import enum -import re -import xml.parsers.expat -from six.moves import zip, queue -import six - from multiprocessing.pool import ThreadPool, RUN +from six.moves import zip, queue from threading import Thread, Event -import signal from beets import ui from beets.plugins import BeetsPlugin -from beets.util import (syspath, command_output, bytestring_path, - displayable_path, py3_path, cpu_count) +from beets.util import (syspath, command_output, displayable_path, + py3_path, cpu_count) # Utilities. @@ -136,252 +133,6 @@ 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. - """ - - methods = { - -24: "atsc", - -23: "ebu", - -18: "replaygain", - } - - do_parallel = True - - def __init__(self, config, log): - super(Bs1770gainBackend, self).__init__(config, log) - config.add({ - 'chunk_at': 5000, - 'method': '', - }) - self.chunk_at = config['chunk_at'].as_number() - # backward compatibility to `method` config option - self.__method = config['method'].as_str() - - cmd = 'bs1770gain' - try: - version_out = call([cmd, '--version']) - self.command = cmd - self.version = re.search( - 'bs1770gain ([0-9]+.[0-9]+.[0-9]+), ', - version_out.stdout.decode('utf-8') - ).group(1) - except OSError: - raise FatalReplayGainError( - u'Is bs1770gain installed?' - ) - if not self.command: - raise FatalReplayGainError( - u'no replaygain command found: install bs1770gain' - ) - - 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, target_level, False) - return output - - 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, target_level, True) - - if not output: - raise ReplayGainError(u'no output from bs1770gain') - return AlbumGain(output[-1], output[:-1]) - - def isplitter(self, items, chunk_at): - """Break an iterable into chunks of at most size `chunk_at`, - generating lists for each chunk. - """ - iterable = iter(items) - while True: - result = [] - for i in range(chunk_at): - try: - a = next(iterable) - except StopIteration: - break - else: - result.append(a) - if result: - yield result - else: - break - - 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 - the album gain - """ - - if len(items) == 0: - return [] - - albumgaintot = 0.0 - albumpeaktot = 0.0 - returnchunks = [] - - # In the case of very large sets of music, we break the tracks - # into smaller chunks and process them one at a time. This - # avoids running out of memory. - if len(items) > self.chunk_at: - i = 0 - for chunk in self.isplitter(items, self.chunk_at): - i += 1 - 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, target_level) - - 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 - gain_adjustment = target_level \ - - [k for k, v in self.methods.items() if v == method][0] - elif target_level in self.methods: - method = self.methods[target_level] - gain_adjustment = 0 - else: - lufs_target = -23 - method = self.methods[lufs_target] - gain_adjustment = target_level - lufs_target - - # Construct shell command. - cmd = [self.command] - cmd += ["--" + method] - cmd += ['--xml', '-p'] - if after_version(self.version, '0.6.0'): - cmd += ['--unit=ebu'] # set units to LU - cmd += ['--suppress-progress'] # don't print % to XML output - - # Workaround for Windows: the underlying tool fails on paths - # with the \\?\ prefix, so we don't use it here. This - # prevents the backend from working with long paths. - args = cmd + [syspath(i.path, prefix=False) for i in items] - path_list = [i.path for i in items] - - # Invoke the command. - self._log.debug( - u'executing {0}', u' '.join(map(displayable_path, args)) - ) - output = call(args).stdout - - self._log.debug(u'analysis finished: {0}', output) - results = self.parse_tool_output(output, path_list, is_album) - - if gain_adjustment: - results = [ - Gain(res.gain + gain_adjustment, res.peak) - for res in results - ] - - self._log.debug(u'{0} items, {1} results', len(items), len(results)) - return results - - def parse_tool_output(self, text, path_list, is_album): - """Given the output from bs1770gain, parse the text and - return a list of dictionaries - containing information about each analyzed file. - """ - per_file_gain = {} - album_gain = {} # mutable variable so it can be set from handlers - parser = xml.parsers.expat.ParserCreate(encoding='utf-8') - state = {'file': None, 'gain': None, 'peak': None} - album_state = {'gain': None, 'peak': None} - - def start_element_handler(name, attrs): - if name == u'track': - state['file'] = bytestring_path(attrs[u'file']) - if state['file'] in per_file_gain: - raise ReplayGainError( - u'duplicate filename in bs1770gain output') - elif name == u'integrated': - if 'lu' in attrs: - state['gain'] = float(attrs[u'lu']) - elif name == u'sample-peak': - if 'factor' in attrs: - state['peak'] = float(attrs[u'factor']) - elif 'amplitude' in attrs: - state['peak'] = float(attrs[u'amplitude']) - - def end_element_handler(name): - if name == u'track': - if state['gain'] is None or state['peak'] is None: - raise ReplayGainError(u'could not parse gain or peak from ' - 'the output of bs1770gain') - per_file_gain[state['file']] = Gain(state['gain'], - state['peak']) - state['gain'] = state['peak'] = None - elif name == u'summary': - if state['gain'] is None or state['peak'] is None: - raise ReplayGainError(u'could not parse gain or peak from ' - 'the output of bs1770gain') - album_gain["album"] = Gain(state['gain'], state['peak']) - state['gain'] = state['peak'] = None - elif len(per_file_gain) == len(path_list): - if state['gain'] is not None: - album_state['gain'] = state['gain'] - if state['peak'] is not None: - album_state['peak'] = state['peak'] - if album_state['gain'] is not None \ - and album_state['peak'] is not None: - album_gain["album"] = Gain( - album_state['gain'], album_state['peak']) - state['gain'] = state['peak'] = None - - parser.StartElementHandler = start_element_handler - parser.EndElementHandler = end_element_handler - - try: - parser.Parse(text, True) - except xml.parsers.expat.ExpatError: - raise ReplayGainError( - u'The bs1770gain tool produced malformed XML. ' - u'Using version >=0.4.10 may solve this problem.') - - if len(per_file_gain) != len(path_list): - raise ReplayGainError( - u'the number of results returned by bs1770gain does not match ' - 'the number of files passed to it') - - # bs1770gain does not return the analysis results in the order that - # files are passed on the command line, because it is sorting the files - # internally. We must recover the order from the filenames themselves. - try: - out = [per_file_gain[os.path.basename(p)] for p in path_list] - except KeyError: - raise ReplayGainError( - u'unrecognized filename in bs1770gain output ' - '(bs1770gain can only deal with utf-8 file names)') - if is_album: - out.append(album_gain["album"]) - return out - - # ffmpeg backend class FfmpegBackend(Backend): """A replaygain backend using ffmpeg's ebur128 filter. @@ -1216,7 +967,6 @@ class ReplayGainPlugin(BeetsPlugin): "command": CommandBackend, "gstreamer": GStreamerBackend, "audiotools": AudioToolsBackend, - "bs1770gain": Bs1770gainBackend, "ffmpeg": FfmpegBackend, } diff --git a/docs/changelog.rst b/docs/changelog.rst index 7e01d0e67..e2a62e6d8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -64,14 +64,12 @@ New features: Thanks to :user:`samuelnilsson` :bug:`293` * :doc:`/plugins/replaygain`: The new ``ffmpeg`` ReplayGain backend supports - ``R128_`` tags, just like the ``bs1770gain`` backend. + ``R128_`` tags. :bug:`3056` * :doc:`plugins/replaygain`: ``r128_targetlevel`` is a new configuration option for the ReplayGain plugin: It defines the reference volume for files using ``R128_`` tags. ``targetlevel`` only configures the reference volume for ``REPLAYGAIN_`` files. - This also deprecates the ``bs1770gain`` ReplayGain backend's ``method`` - option. Use ``targetlevel`` and ``r128_targetlevel`` instead. :bug:`3065` * A new :doc:`/plugins/parentwork` gets information about the original work, which is useful for classical music. @@ -176,8 +174,10 @@ New features: https://github.com/alastair/python-musicbrainzngs/pull/266 . Thanks to :user:`aereaux`. * :doc:`/plugins/replaygain` now does its analysis in parallel when using - the ``command``, ``ffmpeg`` or ``bs1770gain`` backends. + the ``command`` or ``ffmpeg`` backends. :bug:`3478` +* Removes usage of the bs1770gain replaygain backend. + Thanks to :user:`SamuelCook`. Fixes: @@ -239,8 +239,6 @@ Fixes: :bug:`3437` * :doc:`/plugins/lyrics`: Fix a corner-case with Genius lowercase artist names :bug:`3446` -* :doc:`/plugins/replaygain`: Support ``bs1770gain`` v0.6.0 and up - :bug:`3480` * :doc:`/plugins/parentwork`: Don't save tracks when nothing has changed. :bug:`3492` * Added a warning when configuration files defined in the `include` directive @@ -352,6 +350,7 @@ For packagers: or `repair `_ the test may no longer be necessary. * This version drops support for Python 3.4. +* Removes the optional dependency on bs1770gain. .. _Fish shell: https://fishshell.com/ .. _MediaFile: https://github.com/beetbox/mediafile diff --git a/test/test_replaygain.py b/test/test_replaygain.py index 0100b520e..53daafbb9 100644 --- a/test/test_replaygain.py +++ b/test/test_replaygain.py @@ -16,18 +16,14 @@ from __future__ import division, absolute_import, print_function -import unittest import six - -from mock import patch -from test.helper import TestHelper, capture_log, has_program +import unittest +from mediafile import MediaFile from beets import config -from beets.util import CommandOutput -from mediafile import MediaFile from beetsplug.replaygain import (FatalGstreamerPluginReplayGainError, GStreamerBackend) - +from test.helper import TestHelper, has_program try: import gi @@ -41,11 +37,6 @@ if any(has_program(cmd, ['-v']) for cmd in ['mp3gain', 'aacgain']): else: GAIN_PROG_AVAILABLE = False -if has_program('bs1770gain'): - LOUDNESS_PROG_AVAILABLE = True -else: - LOUDNESS_PROG_AVAILABLE = False - FFMPEG_AVAILABLE = has_program('ffmpeg', ['-version']) @@ -153,9 +144,7 @@ class ReplayGainCliTestBase(TestHelper): self.assertEqual(max(gains), min(gains)) self.assertNotEqual(max(gains), 0.0) - if not self.backend == "bs1770gain": - # Actually produces peaks == 0.0 ~ self.add_album_fixture - self.assertNotEqual(max(peaks), 0.0) + self.assertNotEqual(max(peaks), 0.0) def test_cli_writes_only_r128_tags(self): if self.backend == "command": @@ -219,62 +208,6 @@ class ReplayGainCmdCliTest(ReplayGainCliTestBase, unittest.TestCase): backend = u'command' -@unittest.skipIf(not LOUDNESS_PROG_AVAILABLE, u'bs1770gain cannot be found') -class ReplayGainLdnsCliTest(ReplayGainCliTestBase, unittest.TestCase): - backend = u'bs1770gain' - - -class ReplayGainLdnsCliMalformedTest(TestHelper, unittest.TestCase): - @patch('beetsplug.replaygain.call') - def setUp(self, call_patch): - self.setup_beets() - self.config['replaygain']['backend'] = 'bs1770gain' - - # Patch call to return nothing, bypassing the bs1770gain installation - # check. - call_patch.return_value = CommandOutput( - stdout=b'bs1770gain 0.0.0, ', stderr=b'' - ) - try: - self.load_plugins('replaygain') - except Exception: - import sys - exc_info = sys.exc_info() - try: - self.tearDown() - except Exception: - pass - six.reraise(exc_info[1], None, exc_info[2]) - - for item in self.add_album_fixture(2).items(): - reset_replaygain(item) - - def tearDown(self): - self.teardown_beets() - self.unload_plugins() - - @patch('beetsplug.replaygain.call') - def test_malformed_output(self, call_patch): - # Return malformed XML (the ampersand should be &) - call_patch.return_value = CommandOutput(stdout=b""" - - - - - - - """, stderr="") - - with capture_log('beets.replaygain') as logs: - self.run_command('replaygain') - - # Count how many lines match the expected error. - matching = [line for line in logs if - 'malformed XML' in line] - - self.assertEqual(len(matching), 2) - - @unittest.skipIf(not FFMPEG_AVAILABLE, u'ffmpeg cannot be found') class ReplayGainFfmpegTest(ReplayGainCliTestBase, unittest.TestCase): backend = u'ffmpeg'