Merge pull request #3996 from wisp3rwind/pr_rg_restructure

Refactoring of the replaygain plugin
This commit is contained in:
Benedikt 2022-01-22 14:45:30 +01:00 committed by GitHub
commit 404229b845
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 562 additions and 266 deletions

View file

@ -94,21 +94,138 @@ def lufs_to_db(db):
# gain: in LU to reference level # gain: in LU to reference level
# peak: part of full scale (FS is 1.0) # peak: part of full scale (FS is 1.0)
Gain = collections.namedtuple("Gain", "gain peak") Gain = collections.namedtuple("Gain", "gain peak")
# album_gain: Gain object
# track_gains: list of Gain objects
AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains")
class Peak(enum.Enum): class PeakMethod(enum.Enum):
none = 0
true = 1 true = 1
sample = 2 sample = 2
class RgTask():
"""State and methods for a single replaygain calculation (rg version).
Bundles the state (parameters and results) of a single replaygain
calculation (either for one item, one disk, or one full album).
This class provides methods to store the resulting gains and peaks as plain
old rg tags.
"""
def __init__(self, items, album, target_level, peak_method, backend_name,
log):
self.items = items
self.album = album
self.target_level = target_level
self.peak_method = peak_method
self.backend_name = backend_name
self._log = log
self.album_gain = None
self.track_gains = None
def _store_track_gain(self, item, track_gain):
"""Store track gain for a single item in the database.
"""
item.rg_track_gain = track_gain.gain
item.rg_track_peak = track_gain.peak
item.store()
self._log.debug('applied track gain {0} LU, peak {1} of FS',
item.rg_track_gain, item.rg_track_peak)
def _store_album_gain(self, item):
"""Store album gain for a single item in the database.
The caller needs to ensure that `self.album_gain is not None`.
"""
item.rg_album_gain = self.album_gain.gain
item.rg_album_peak = self.album_gain.peak
item.store()
self._log.debug('applied album gain {0} LU, peak {1} of FS',
item.rg_album_gain, item.rg_album_peak)
def _store_track(self, write):
"""Store track gain for the first track of the task in the database.
"""
item = self.items[0]
if self.track_gains is None or len(self.track_gains) != 1:
# In some cases, backends fail to produce a valid
# `track_gains` without throwing FatalReplayGainError
# => raise non-fatal exception & continue
raise ReplayGainError(
"ReplayGain backend `{}` failed for track {}"
.format(self.backend_name, item)
)
self._store_track_gain(item, self.track_gains[0])
if write:
item.try_write()
self._log.debug('done analyzing {0}', item)
def _store_album(self, write):
"""Store track/album gains for all tracks of the task in the database.
"""
if (self.album_gain is None or self.track_gains is None
or len(self.track_gains) != len(self.items)):
# In some cases, backends fail to produce a valid
# `album_gain` without throwing FatalReplayGainError
# => raise non-fatal exception & continue
raise ReplayGainError(
"ReplayGain backend `{}` failed "
"for some tracks in album {}"
.format(self.backend_name, self.album)
)
for item, track_gain in zip(self.items, self.track_gains):
self._store_track_gain(item, track_gain)
self._store_album_gain(item)
if write:
item.try_write()
self._log.debug('done analyzing {0}', item)
def store(self, write):
"""Store computed gains for the items of this task in the database.
"""
if self.album is not None:
self._store_album(write)
else:
self._store_track(write)
class R128Task(RgTask):
"""State and methods for a single replaygain calculation (r128 version).
Bundles the state (parameters and results) of a single replaygain
calculation (either for one item, one disk, or one full album).
This class provides methods to store the resulting gains and peaks as R128
tags.
"""
def __init__(self, items, album, target_level, backend_name, log):
# R128_* tags do not store the track/album peak
super().__init__(items, album, target_level, None, backend_name,
log)
def _store_track_gain(self, item, track_gain):
item.r128_track_gain = track_gain.gain
item.store()
self._log.debug('applied r128 track gain {0} LU',
item.r128_track_gain)
def _store_album_gain(self, item):
"""
The caller needs to ensure that `self.album_gain is not None`.
"""
item.r128_album_gain = self.album_gain.gain
item.store()
self._log.debug('applied r128 album gain {0} LU',
item.r128_album_gain)
class Backend: class Backend:
"""An abstract class representing engine for calculating RG values. """An abstract class representing engine for calculating RG values.
""" """
NAME = ""
do_parallel = False do_parallel = False
def __init__(self, config, log): def __init__(self, config, log):
@ -117,15 +234,15 @@ class Backend:
""" """
self._log = log self._log = log
def compute_track_gain(self, items, target_level, peak): def compute_track_gain(self, task):
"""Computes the track gain of the given tracks, returns a list """Computes the track gain for the tracks belonging to `task`, and sets
of Gain objects. the `track_gains` attribute on the task. Returns `task`.
""" """
raise NotImplementedError() raise NotImplementedError()
def compute_album_gain(self, items, target_level, peak): def compute_album_gain(self, task):
"""Computes the album gain of the given album, returns an """Computes the album gain for the album belonging to `task`, and sets
AlbumGain object. the `album_gain` attribute on the task. Returns `task`.
""" """
raise NotImplementedError() raise NotImplementedError()
@ -135,6 +252,7 @@ class FfmpegBackend(Backend):
"""A replaygain backend using ffmpeg's ebur128 filter. """A replaygain backend using ffmpeg's ebur128 filter.
""" """
NAME = "ffmpeg"
do_parallel = True do_parallel = True
def __init__(self, config, log): def __init__(self, config, log):
@ -165,27 +283,28 @@ class FfmpegBackend(Backend):
"the --enable-libebur128 configuration option is required." "the --enable-libebur128 configuration option is required."
) )
def compute_track_gain(self, items, target_level, peak): def compute_track_gain(self, task):
"""Computes the track gain of the given tracks, returns a list """Computes the track gain for the tracks belonging to `task`, and sets
of Gain objects (the track gains). the `track_gains` attribute on the task. Returns `task`.
""" """
gains = [] gains = []
for item in items: for item in task.items:
gains.append( gains.append(
self._analyse_item( self._analyse_item(
item, item,
target_level, task.target_level,
peak, task.peak_method,
count_blocks=False, count_blocks=False,
)[0] # take only the gain, discarding number of gating blocks )[0] # take only the gain, discarding number of gating blocks
) )
return gains task.track_gains = gains
return task
def compute_album_gain(self, items, target_level, peak): def compute_album_gain(self, task):
"""Computes the album gain of the given album, returns an """Computes the album gain for the album belonging to `task`, and sets
AlbumGain object. the `album_gain` attribute on the task. Returns `task`.
""" """
target_level_lufs = db_to_lufs(target_level) target_level_lufs = db_to_lufs(task.target_level)
# analyse tracks # analyse tracks
# list of track Gain objects # list of track Gain objects
@ -197,9 +316,9 @@ class FfmpegBackend(Backend):
# total number of BS.1770 gating blocks # total number of BS.1770 gating blocks
n_blocks = 0 n_blocks = 0
for item in items: for item in task.items:
track_gain, track_n_blocks = self._analyse_item( track_gain, track_n_blocks = self._analyse_item(
item, target_level, peak item, task.target_level, task.peak_method
) )
track_gains.append(track_gain) track_gains.append(track_gain)
@ -234,10 +353,12 @@ class FfmpegBackend(Backend):
self._log.debug( self._log.debug(
"{}: gain {} LU, peak {}" "{}: gain {} LU, peak {}"
.format(items, album_gain, album_peak) .format(task.items, album_gain, album_peak)
) )
return AlbumGain(Gain(album_gain, album_peak), track_gains) task.album_gain = Gain(album_gain, album_peak)
task.track_gains = track_gains
return task
def _construct_cmd(self, item, peak_method): def _construct_cmd(self, item, peak_method):
"""Construct the shell command to analyse items.""" """Construct the shell command to analyse items."""
@ -250,13 +371,15 @@ class FfmpegBackend(Backend):
"-map", "-map",
"a:0", "a:0",
"-filter", "-filter",
f"ebur128=peak={peak_method}", "ebur128=peak={}".format(
"none" if peak_method is None else peak_method.name),
"-f", "-f",
"null", "null",
"-", "-",
] ]
def _analyse_item(self, item, target_level, peak, count_blocks=True): def _analyse_item(self, item, target_level, peak_method,
count_blocks=True):
"""Analyse item. Return a pair of a Gain object and the number """Analyse item. Return a pair of a Gain object and the number
of gating blocks above the threshold. of gating blocks above the threshold.
@ -264,7 +387,6 @@ class FfmpegBackend(Backend):
will be 0. will be 0.
""" """
target_level_lufs = db_to_lufs(target_level) target_level_lufs = db_to_lufs(target_level)
peak_method = peak.name
# call ffmpeg # call ffmpeg
self._log.debug(f"analyzing {item}") self._log.debug(f"analyzing {item}")
@ -276,12 +398,13 @@ class FfmpegBackend(Backend):
# parse output # parse output
if peak == Peak.none: if peak_method is None:
peak = 0 peak = 0
else: else:
line_peak = self._find_line( line_peak = self._find_line(
output, output,
f" {peak_method.capitalize()} peak:".encode(), # `peak_method` is non-`None` in this arm of the conditional
f" {peak_method.name.capitalize()} peak:".encode(),
start_line=len(output) - 1, step_size=-1, start_line=len(output) - 1, step_size=-1,
) )
peak = self._parse_float( peak = self._parse_float(
@ -379,6 +502,7 @@ class FfmpegBackend(Backend):
# mpgain/aacgain CLI tool backend. # mpgain/aacgain CLI tool backend.
class CommandBackend(Backend): class CommandBackend(Backend):
NAME = "command"
do_parallel = True do_parallel = True
def __init__(self, config, log): def __init__(self, config, log):
@ -412,28 +536,33 @@ class CommandBackend(Backend):
self.noclip = config['noclip'].get(bool) self.noclip = config['noclip'].get(bool)
def compute_track_gain(self, items, target_level, peak): def compute_track_gain(self, task):
"""Computes the track gain of the given tracks, returns a list """Computes the track gain for the tracks belonging to `task`, and sets
of TrackGain objects. the `track_gains` attribute on the task. Returns `task`.
""" """
supported_items = list(filter(self.format_supported, items)) supported_items = list(filter(self.format_supported, task.items))
output = self.compute_gain(supported_items, target_level, False) output = self.compute_gain(supported_items, task.target_level, False)
return output task.track_gains = output
return task
def compute_album_gain(self, items, target_level, peak): def compute_album_gain(self, task):
"""Computes the album gain of the given album, returns an """Computes the album gain for the album belonging to `task`, and sets
AlbumGain object. the `album_gain` attribute on the task. Returns `task`.
""" """
# TODO: What should be done when not all tracks in the album are # TODO: What should be done when not all tracks in the album are
# supported? # supported?
supported_items = list(filter(self.format_supported, items)) supported_items = list(filter(self.format_supported, task.items))
if len(supported_items) != len(items): if len(supported_items) != len(task.items):
self._log.debug('tracks are of unsupported format') self._log.debug('tracks are of unsupported format')
return AlbumGain(None, []) task.album_gain = None
task.track_gains = None
return task
output = self.compute_gain(supported_items, target_level, True) output = self.compute_gain(supported_items, task.target_level, True)
return AlbumGain(output[-1], output[:-1]) task.album_gain = output[-1]
task.track_gains = output[:-1]
return task
def format_supported(self, item): def format_supported(self, item):
"""Checks whether the given item is supported by the selected tool. """Checks whether the given item is supported by the selected tool.
@ -508,6 +637,8 @@ class CommandBackend(Backend):
# GStreamer-based backend. # GStreamer-based backend.
class GStreamerBackend(Backend): class GStreamerBackend(Backend):
NAME = "gstreamer"
def __init__(self, config, log): def __init__(self, config, log):
super().__init__(config, log) super().__init__(config, log)
self._import_gst() self._import_gst()
@ -612,21 +743,28 @@ class GStreamerBackend(Backend):
if self._error is not None: if self._error is not None:
raise self._error raise self._error
def compute_track_gain(self, items, target_level, peak): def compute_track_gain(self, task):
self.compute(items, target_level, False) """Computes the track gain for the tracks belonging to `task`, and sets
if len(self._file_tags) != len(items): the `track_gains` attribute on the task. Returns `task`.
"""
self.compute(task.items, task.target_level, False)
if len(self._file_tags) != len(task.items):
raise ReplayGainError("Some tracks did not receive tags") raise ReplayGainError("Some tracks did not receive tags")
ret = [] ret = []
for item in items: for item in task.items:
ret.append(Gain(self._file_tags[item]["TRACK_GAIN"], ret.append(Gain(self._file_tags[item]["TRACK_GAIN"],
self._file_tags[item]["TRACK_PEAK"])) self._file_tags[item]["TRACK_PEAK"]))
return ret task.track_gains = ret
return task
def compute_album_gain(self, items, target_level, peak): def compute_album_gain(self, task):
items = list(items) """Computes the album gain for the album belonging to `task`, and sets
self.compute(items, target_level, True) the `album_gain` attribute on the task. Returns `task`.
"""
items = list(task.items)
self.compute(items, task.target_level, True)
if len(self._file_tags) != len(items): if len(self._file_tags) != len(items):
raise ReplayGainError("Some items in album did not receive tags") raise ReplayGainError("Some items in album did not receive tags")
@ -648,7 +786,9 @@ class GStreamerBackend(Backend):
except KeyError: except KeyError:
raise ReplayGainError("results missing for album") raise ReplayGainError("results missing for album")
return AlbumGain(Gain(gain, peak), track_gains) task.album_gain = Gain(gain, peak)
task.track_gains = track_gains
return task
def close(self): def close(self):
self._bus.remove_signal_watch() self._bus.remove_signal_watch()
@ -779,6 +919,7 @@ class AudioToolsBackend(Backend):
<http://audiotools.sourceforge.net/>`_ and its capabilities to read more <http://audiotools.sourceforge.net/>`_ and its capabilities to read more
file formats and compute ReplayGain values using it replaygain module. file formats and compute ReplayGain values using it replaygain module.
""" """
NAME = "audiotools"
def __init__(self, config, log): def __init__(self, config, log):
super().__init__(config, log) super().__init__(config, log)
@ -840,12 +981,14 @@ class AudioToolsBackend(Backend):
return return
return rg return rg
def compute_track_gain(self, items, target_level, peak): def compute_track_gain(self, task):
"""Compute ReplayGain values for the requested items. """Computes the track gain for the tracks belonging to `task`, and sets
the `track_gains` attribute on the task. Returns `task`.
:return list: list of :class:`Gain` objects
""" """
return [self._compute_track_gain(item, target_level) for item in items] gains = [self._compute_track_gain(i, task.target_level)
for i in task.items]
task.track_gains = gains
return task
def _with_target_level(self, gain, target_level): def _with_target_level(self, gain, target_level):
"""Return `gain` relative to `target_level`. """Return `gain` relative to `target_level`.
@ -890,23 +1033,22 @@ class AudioToolsBackend(Backend):
item.artist, item.title, rg_track_gain, rg_track_peak) item.artist, item.title, rg_track_gain, rg_track_peak)
return Gain(gain=rg_track_gain, peak=rg_track_peak) return Gain(gain=rg_track_gain, peak=rg_track_peak)
def compute_album_gain(self, items, target_level, peak): def compute_album_gain(self, task):
"""Compute ReplayGain values for the requested album and its items. """Computes the album gain for the album belonging to `task`, and sets
the `album_gain` attribute on the task. Returns `task`.
:rtype: :class:`AlbumGain`
""" """
# The first item is taken and opened to get the sample rate to # The first item is taken and opened to get the sample rate to
# initialize the replaygain object. The object is used for all the # initialize the replaygain object. The object is used for all the
# tracks in the album to get the album values. # tracks in the album to get the album values.
item = list(items)[0] item = list(task.items)[0]
audiofile = self.open_audio_file(item) audiofile = self.open_audio_file(item)
rg = self.init_replaygain(audiofile, item) rg = self.init_replaygain(audiofile, item)
track_gains = [] track_gains = []
for item in items: for item in task.items:
audiofile = self.open_audio_file(item) audiofile = self.open_audio_file(item)
rg_track_gain, rg_track_peak = self._title_gain( rg_track_gain, rg_track_peak = self._title_gain(
rg, audiofile, target_level rg, audiofile, task.target_level
) )
track_gains.append( track_gains.append(
Gain(gain=rg_track_gain, peak=rg_track_peak) Gain(gain=rg_track_gain, peak=rg_track_peak)
@ -917,14 +1059,14 @@ class AudioToolsBackend(Backend):
# After getting the values for all tracks, it's possible to get the # After getting the values for all tracks, it's possible to get the
# album values. # album values.
rg_album_gain, rg_album_peak = rg.album_gain() rg_album_gain, rg_album_peak = rg.album_gain()
rg_album_gain = self._with_target_level(rg_album_gain, target_level) rg_album_gain = self._with_target_level(
rg_album_gain, task.target_level)
self._log.debug('ReplayGain for album {0}: {1:.2f}, {2:.2f}', self._log.debug('ReplayGain for album {0}: {1:.2f}, {2:.2f}',
items[0].album, rg_album_gain, rg_album_peak) task.items[0].album, rg_album_gain, rg_album_peak)
return AlbumGain( task.album_gain = Gain(gain=rg_album_gain, peak=rg_album_peak)
Gain(gain=rg_album_gain, peak=rg_album_peak), task.track_gains = track_gains
track_gains=track_gains return task
)
class ExceptionWatcher(Thread): class ExceptionWatcher(Thread):
@ -956,22 +1098,19 @@ class ExceptionWatcher(Thread):
# Main plugin logic. # Main plugin logic.
BACKEND_CLASSES = [
CommandBackend,
GStreamerBackend,
AudioToolsBackend,
FfmpegBackend,
]
BACKENDS = {b.NAME: b for b in BACKEND_CLASSES}
class ReplayGainPlugin(BeetsPlugin): class ReplayGainPlugin(BeetsPlugin):
"""Provides ReplayGain analysis. """Provides ReplayGain analysis.
""" """
backends = {
"command": CommandBackend,
"gstreamer": GStreamerBackend,
"audiotools": AudioToolsBackend,
"ffmpeg": FfmpegBackend,
}
peak_methods = {
"true": Peak.true,
"sample": Peak.sample,
}
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -989,30 +1128,36 @@ class ReplayGainPlugin(BeetsPlugin):
'r128_targetlevel': lufs_to_db(-23), 'r128_targetlevel': lufs_to_db(-23),
}) })
self.overwrite = self.config['overwrite'].get(bool) # FIXME: Consider renaming the configuration option and deprecating the
self.per_disc = self.config['per_disc'].get(bool) # old name 'overwrite'.
self.force_on_import = self.config['overwrite'].get(bool)
# Remember which backend is used for CLI feedback # Remember which backend is used for CLI feedback
self.backend_name = self.config['backend'].as_str() self.backend_name = self.config['backend'].as_str()
if self.backend_name not in self.backends: if self.backend_name not in BACKENDS:
raise ui.UserError( raise ui.UserError(
"Selected ReplayGain backend {} is not supported. " "Selected ReplayGain backend {} is not supported. "
"Please select one of: {}".format( "Please select one of: {}".format(
self.backend_name, self.backend_name,
', '.join(self.backends.keys()) ', '.join(BACKENDS.keys())
) )
) )
# FIXME: Consider renaming the configuration option to 'peak_method'
# and deprecating the old name 'peak'.
peak_method = self.config["peak"].as_str() peak_method = self.config["peak"].as_str()
if peak_method not in self.peak_methods: if peak_method not in PeakMethod.__members__:
raise ui.UserError( raise ui.UserError(
"Selected ReplayGain peak method {} is not supported. " "Selected ReplayGain peak method {} is not supported. "
"Please select one of: {}".format( "Please select one of: {}".format(
peak_method, peak_method,
', '.join(self.peak_methods.keys()) ', '.join(PeakMethod.__members__)
) )
) )
self._peak_method = self.peak_methods[peak_method] # This only applies to plain old rg tags, r128 doesn't store peak
# values.
self.peak_method = PeakMethod[peak_method]
# On-import analysis. # On-import analysis.
if self.config['auto']: if self.config['auto']:
@ -1024,7 +1169,7 @@ class ReplayGainPlugin(BeetsPlugin):
self.r128_whitelist = self.config['r128'].as_str_seq() self.r128_whitelist = self.config['r128'].as_str_seq()
try: try:
self.backend_instance = self.backends[self.backend_name]( self.backend_instance = BACKENDS[self.backend_name](
self.config, self._log self.config, self._log
) )
except (ReplayGainError, FatalReplayGainError) as e: except (ReplayGainError, FatalReplayGainError) as e:
@ -1037,70 +1182,66 @@ class ReplayGainPlugin(BeetsPlugin):
""" """
return item.format in self.r128_whitelist return item.format in self.r128_whitelist
@staticmethod
def has_r128_track_data(item):
return item.r128_track_gain is not None
@staticmethod
def has_rg_track_data(item):
return (item.rg_track_gain is not None
and item.rg_track_peak is not None)
def track_requires_gain(self, item): def track_requires_gain(self, item):
return self.overwrite or \ if self.should_use_r128(item):
(self.should_use_r128(item) and not item.r128_track_gain) or \ if not self.has_r128_track_data(item):
(not self.should_use_r128(item) and return True
(not item.rg_track_gain or not item.rg_track_peak)) else:
if not self.has_rg_track_data(item):
return True
return False
@staticmethod
def has_r128_album_data(item):
return (item.r128_track_gain is not None
and item.r128_album_gain is not None)
@staticmethod
def has_rg_album_data(item):
return (item.rg_album_gain is not None
and item.rg_album_peak is not None)
def album_requires_gain(self, album): def album_requires_gain(self, album):
# Skip calculating gain only when *all* files don't need # Skip calculating gain only when *all* files don't need
# recalculation. This way, if any file among an album's tracks # recalculation. This way, if any file among an album's tracks
# needs recalculation, we still get an accurate album gain # needs recalculation, we still get an accurate album gain
# value. # value.
return self.overwrite or \ for item in album.items():
any([self.should_use_r128(item) and if self.should_use_r128(item):
(not item.r128_track_gain or not item.r128_album_gain) if not self.has_r128_album_data(item):
for item in album.items()]) or \ return True
any([not self.should_use_r128(item) and else:
(not item.rg_album_gain or not item.rg_album_peak) if not self.has_rg_album_data(item):
for item in album.items()]) return True
def store_track_gain(self, item, track_gain): return False
item.rg_track_gain = track_gain.gain
item.rg_track_peak = track_gain.peak
item.store()
self._log.debug('applied track gain {0} LU, peak {1} of FS',
item.rg_track_gain, item.rg_track_peak)
def store_album_gain(self, item, album_gain): def create_task(self, items, use_r128, album=None):
item.rg_album_gain = album_gain.gain if use_r128:
item.rg_album_peak = album_gain.peak return R128Task(
item.store() items, album,
self._log.debug('applied album gain {0} LU, peak {1} of FS', self.config["r128_targetlevel"].as_number(),
item.rg_album_gain, item.rg_album_peak) self.backend_instance.NAME,
self._log,
def store_track_r128_gain(self, item, track_gain): )
item.r128_track_gain = track_gain.gain
item.store()
self._log.debug('applied r128 track gain {0} LU',
item.r128_track_gain)
def store_album_r128_gain(self, item, album_gain):
item.r128_album_gain = album_gain.gain
item.store()
self._log.debug('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, target_level,
peak_method).
"""
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
target_level = self.config['r128_targetlevel'].as_number()
peak = Peak.none # R128_* tags do not store the track/album peak
else: else:
store_track_gain = self.store_track_gain return RgTask(
store_album_gain = self.store_album_gain items, album,
target_level = self.config['targetlevel'].as_number() self.config["targetlevel"].as_number(),
peak = self._peak_method self.peak_method,
self.backend_instance.NAME,
return store_track_gain, store_album_gain, target_level, peak self._log,
)
def handle_album(self, album, write, force=False): def handle_album(self, album, write, force=False):
"""Compute album and track replay gain store it in all of the """Compute album and track replay gain store it in all of the
@ -1114,8 +1255,9 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.info('Skipping album {0}', album) self._log.info('Skipping album {0}', album)
return return
if (any([self.should_use_r128(item) for item in album.items()]) and not items_iter = iter(album.items())
all([self.should_use_r128(item) for item in album.items()])): use_r128 = self.should_use_r128(next(items_iter))
if any(use_r128 != self.should_use_r128(i) for i in items_iter):
self._log.error( self._log.error(
"Cannot calculate gain for album {0} (incompatible formats)", "Cannot calculate gain for album {0} (incompatible formats)",
album) album)
@ -1123,11 +1265,8 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.info('analyzing {0}', album) self._log.info('analyzing {0}', album)
tag_vals = self.tag_specific_values(album.items())
store_track_gain, store_album_gain, target_level, peak = tag_vals
discs = {} discs = {}
if self.per_disc: if self.config['per_disc'].get(bool):
for item in album.items(): for item in album.items():
if discs.get(item.disc) is None: if discs.get(item.disc) is None:
discs[item.disc] = [] discs[item.disc] = []
@ -1136,34 +1275,12 @@ class ReplayGainPlugin(BeetsPlugin):
discs[1] = album.items() discs[1] = album.items()
for discnumber, items in discs.items(): for discnumber, items in discs.items():
def _store_album(album_gain): task = self.create_task(items, use_r128, album=album)
if not album_gain or not album_gain.album_gain \
or len(album_gain.track_gains) != len(items):
# In some cases, backends fail to produce a valid
# `album_gain` without throwing FatalReplayGainError
# => raise non-fatal exception & continue
raise ReplayGainError(
"ReplayGain backend `{}` failed "
"for some tracks in album {}"
.format(self.backend_name, album)
)
for item, track_gain in zip(items,
album_gain.track_gains):
store_track_gain(item, track_gain)
store_album_gain(item, album_gain.album_gain)
if write:
item.try_write()
self._log.debug('done analyzing {0}', item)
try: try:
self._apply( self._apply(
self.backend_instance.compute_album_gain, args=(), self.backend_instance.compute_album_gain,
kwds={ args=[task], kwds={},
"items": list(items), callback=lambda task: task.store(write)
"target_level": target_level,
"peak": peak
},
callback=_store_album
) )
except ReplayGainError as e: except ReplayGainError as e:
self._log.info("ReplayGain error: {0}", e) self._log.info("ReplayGain error: {0}", e)
@ -1182,33 +1299,14 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.info('Skipping track {0}', item) self._log.info('Skipping track {0}', item)
return return
tag_vals = self.tag_specific_values([item]) use_r128 = self.should_use_r128(item)
store_track_gain, store_album_gain, target_level, peak = tag_vals
def _store_track(track_gains):
if not track_gains or len(track_gains) != 1:
# In some cases, backends fail to produce a valid
# `track_gains` without throwing FatalReplayGainError
# => raise non-fatal exception & continue
raise ReplayGainError(
"ReplayGain backend `{}` failed for track {}"
.format(self.backend_name, item)
)
store_track_gain(item, track_gains[0])
if write:
item.try_write()
self._log.debug('done analyzing {0}', item)
task = self.create_task([item], use_r128)
try: try:
self._apply( self._apply(
self.backend_instance.compute_track_gain, args=(), self.backend_instance.compute_track_gain,
kwds={ args=[task], kwds={},
"items": [item], callback=lambda task: task.store(write)
"target_level": target_level,
"peak": peak,
},
callback=_store_track
) )
except ReplayGainError as e: except ReplayGainError as e:
self._log.info("ReplayGain error: {0}", e) self._log.info("ReplayGain error: {0}", e)
@ -1308,9 +1406,9 @@ class ReplayGainPlugin(BeetsPlugin):
""" """
if self.config['auto']: if self.config['auto']:
if task.is_album: if task.is_album:
self.handle_album(task.album, False) self.handle_album(task.album, False, self.force_on_import)
else: else:
self.handle_track(task.item, False) self.handle_track(task.item, False, self.force_on_import)
def command_func(self, lib, opts, args): def command_func(self, lib, opts, args):
try: try:

View file

@ -44,6 +44,9 @@ Bug fixes:
* :doc:`plugins/lyrics`: Fixed issues with the Tekstowo.pl and Genius * :doc:`plugins/lyrics`: Fixed issues with the Tekstowo.pl and Genius
backends where some non-lyrics content got included in the lyrics backends where some non-lyrics content got included in the lyrics
* :doc:`plugins/limit`: Better header formatting to improve index * :doc:`plugins/limit`: Better header formatting to improve index
* :doc:`plugins/replaygain`: Correctly handle the ``overwrite`` config option,
which forces recomputing ReplayGain values on import even for tracks
that already have the tags.
For packagers: For packagers:

View file

@ -112,7 +112,10 @@ configuration file. The available options are:
- **backend**: The analysis backend; either ``gstreamer``, ``command``, ``audiotools`` - **backend**: The analysis backend; either ``gstreamer``, ``command``, ``audiotools``
or ``ffmpeg``. or ``ffmpeg``.
Default: ``command``. Default: ``command``.
- **overwrite**: Re-analyze files that already have ReplayGain tags. - **overwrite**: On import, re-analyze files that already have ReplayGain tags.
Note that, for historical reasons, the name of this option is somewhat
unfortunate: It does not decide whether tags are written to the files (which
is controlled by the :ref:`import.write <config-import-write>` option).
Default: ``no``. Default: ``no``.
- **targetlevel**: A number of decibels for the target loudness level for files - **targetlevel**: A number of decibels for the target loudness level for files
using ``REPLAYGAIN_`` tags. using ``REPLAYGAIN_`` tags.

View file

@ -373,21 +373,23 @@ class TestHelper:
items.append(item) items.append(item)
return items return items
def add_album_fixture(self, track_count=1, ext='mp3'): def add_album_fixture(self, track_count=1, ext='mp3', disc_count=1):
"""Add an album with files to the database. """Add an album with files to the database.
""" """
items = [] items = []
path = os.path.join(_common.RSRC, util.bytestring_path('full.' + ext)) path = os.path.join(_common.RSRC, util.bytestring_path('full.' + ext))
for i in range(track_count): for discnumber in range(1, disc_count + 1):
item = Item.from_path(path) for i in range(track_count):
item.album = '\u00e4lbum' # Check unicode paths item = Item.from_path(path)
item.title = f't\u00eftle {i}' item.album = '\u00e4lbum' # Check unicode paths
# mtime needs to be set last since other assignments reset it. item.title = f't\u00eftle {i}'
item.mtime = 12345 item.disc = discnumber
item.add(self.lib) # mtime needs to be set last since other assignments reset it.
item.move(operation=MoveOperation.COPY) item.mtime = 12345
item.store() item.add(self.lib)
items.append(item) item.move(operation=MoveOperation.COPY)
item.store()
items.append(item)
return self.lib.add_album(items) return self.lib.add_album(items)
def create_mediafile_fixture(self, ext='mp3', images=[]): def create_mediafile_fixture(self, ext='mp3', images=[]):

View file

@ -41,14 +41,54 @@ def reset_replaygain(item):
item['rg_track_gain'] = None item['rg_track_gain'] = None
item['rg_album_gain'] = None item['rg_album_gain'] = None
item['rg_album_gain'] = None item['rg_album_gain'] = None
item['r128_track_gain'] = None
item['r128_album_gain'] = None
item.write() item.write()
item.store() item.store()
item.store()
item.store()
class GstBackendMixin():
backend = 'gstreamer'
has_r128_support = True
def test_backend(self):
"""Check whether the backend actually has all required functionality.
"""
try:
# Check if required plugins can be loaded by instantiating a
# GStreamerBackend (via its .__init__).
config['replaygain']['targetlevel'] = 89
GStreamerBackend(config['replaygain'], None)
except FatalGstreamerPluginReplayGainError as e:
# Skip the test if plugins could not be loaded.
self.skipTest(str(e))
class CmdBackendMixin():
backend = 'command'
has_r128_support = False
def test_backend(self):
"""Check whether the backend actually has all required functionality.
"""
pass
class FfmpegBackendMixin():
backend = 'ffmpeg'
has_r128_support = True
def test_backend(self):
"""Check whether the backend actually has all required functionality.
"""
pass
class ReplayGainCliTestBase(TestHelper): class ReplayGainCliTestBase(TestHelper):
def setUp(self): def setUp(self):
# Implemented by Mixins, see above. This may decide to skip the test.
self.test_backend()
self.setup_beets(disk=True) self.setup_beets(disk=True)
self.config['replaygain']['backend'] = self.backend self.config['replaygain']['backend'] = self.backend
@ -58,25 +98,20 @@ class ReplayGainCliTestBase(TestHelper):
self.teardown_beets() self.teardown_beets()
self.unload_plugins() self.unload_plugins()
album = self.add_album_fixture(2) def _add_album(self, *args, **kwargs):
album = self.add_album_fixture(*args, **kwargs)
for item in album.items(): for item in album.items():
reset_replaygain(item) reset_replaygain(item)
return album
def tearDown(self): def tearDown(self):
self.teardown_beets() self.teardown_beets()
self.unload_plugins() self.unload_plugins()
def _reset_replaygain(self, item):
item['rg_track_peak'] = None
item['rg_track_gain'] = None
item['rg_album_peak'] = None
item['rg_album_gain'] = None
item['r128_track_gain'] = None
item['r128_album_gain'] = None
item.write()
item.store()
def test_cli_saves_track_gain(self): def test_cli_saves_track_gain(self):
self._add_album(2)
for item in self.lib.items(): for item in self.lib.items():
self.assertIsNone(item.rg_track_peak) self.assertIsNone(item.rg_track_peak)
self.assertIsNone(item.rg_track_gain) self.assertIsNone(item.rg_track_gain)
@ -102,15 +137,85 @@ class ReplayGainCliTestBase(TestHelper):
mediafile.rg_track_gain, item.rg_track_gain, places=2) mediafile.rg_track_gain, item.rg_track_gain, places=2)
def test_cli_skips_calculated_tracks(self): def test_cli_skips_calculated_tracks(self):
album_rg = self._add_album(1)
item_rg = album_rg.items()[0]
if self.has_r128_support:
album_r128 = self._add_album(1, ext="opus")
item_r128 = album_r128.items()[0]
self.run_command('replaygain') self.run_command('replaygain')
item = self.lib.items()[0]
peak = item.rg_track_peak item_rg.load()
item.rg_track_gain = 0.0 self.assertIsNotNone(item_rg.rg_track_gain)
self.assertIsNotNone(item_rg.rg_track_peak)
self.assertIsNone(item_rg.r128_track_gain)
item_rg.rg_track_gain += 1.0
item_rg.rg_track_peak += 1.0
item_rg.store()
rg_track_gain = item_rg.rg_track_gain
rg_track_peak = item_rg.rg_track_peak
if self.has_r128_support:
item_r128.load()
self.assertIsNotNone(item_r128.r128_track_gain)
self.assertIsNone(item_r128.rg_track_gain)
self.assertIsNone(item_r128.rg_track_peak)
item_r128.r128_track_gain += 1.0
item_r128.store()
r128_track_gain = item_r128.r128_track_gain
self.run_command('replaygain') self.run_command('replaygain')
self.assertEqual(item.rg_track_gain, 0.0)
self.assertEqual(item.rg_track_peak, peak) item_rg.load()
self.assertEqual(item_rg.rg_track_gain, rg_track_gain)
self.assertEqual(item_rg.rg_track_peak, rg_track_peak)
if self.has_r128_support:
item_r128.load()
self.assertEqual(item_r128.r128_track_gain, r128_track_gain)
def test_cli_does_not_skip_wrong_tag_type(self):
"""Check that items that have tags of the wrong type won't be skipped.
"""
if not self.has_r128_support:
# This test is a lot less interesting if the backend cannot write
# both tag types.
self.skipTest("r128 tags for opus not supported on backend {}"
.format(self.backend))
album_rg = self._add_album(1)
item_rg = album_rg.items()[0]
album_r128 = self._add_album(1, ext="opus")
item_r128 = album_r128.items()[0]
item_rg.r128_track_gain = 0.0
item_rg.store()
item_r128.rg_track_gain = 0.0
item_r128.rg_track_peak = 42.0
item_r128.store()
self.run_command('replaygain')
item_rg.load()
item_r128.load()
self.assertIsNotNone(item_rg.rg_track_gain)
self.assertIsNotNone(item_rg.rg_track_peak)
# FIXME: Should the plugin null this field?
# self.assertIsNone(item_rg.r128_track_gain)
self.assertIsNotNone(item_r128.r128_track_gain)
# FIXME: Should the plugin null these fields?
# self.assertIsNone(item_r128.rg_track_gain)
# self.assertIsNone(item_r128.rg_track_peak)
def test_cli_saves_album_gain_to_file(self): def test_cli_saves_album_gain_to_file(self):
self._add_album(2)
for item in self.lib.items(): for item in self.lib.items():
mediafile = MediaFile(item.path) mediafile = MediaFile(item.path)
self.assertIsNone(mediafile.rg_album_peak) self.assertIsNone(mediafile.rg_album_peak)
@ -133,13 +238,11 @@ class ReplayGainCliTestBase(TestHelper):
self.assertNotEqual(max(peaks), 0.0) self.assertNotEqual(max(peaks), 0.0)
def test_cli_writes_only_r128_tags(self): def test_cli_writes_only_r128_tags(self):
if self.backend == "command": if not self.has_r128_support:
# opus not supported by command backend self.skipTest("r128 tags for opus not supported on backend {}"
return .format(self.backend))
album = self.add_album_fixture(2, ext="opus") album = self._add_album(2, ext="opus")
for item in album.items():
self._reset_replaygain(item)
self.run_command('replaygain', '-a') self.run_command('replaygain', '-a')
@ -152,51 +255,138 @@ class ReplayGainCliTestBase(TestHelper):
self.assertIsNotNone(mediafile.r128_track_gain) self.assertIsNotNone(mediafile.r128_track_gain)
self.assertIsNotNone(mediafile.r128_album_gain) self.assertIsNotNone(mediafile.r128_album_gain)
def test_target_level_has_effect(self): def test_targetlevel_has_effect(self):
item = self.lib.items()[0] album = self._add_album(1)
item = album.items()[0]
def analyse(target_level): def analyse(target_level):
self.config['replaygain']['targetlevel'] = target_level self.config['replaygain']['targetlevel'] = target_level
self._reset_replaygain(item)
self.run_command('replaygain', '-f') self.run_command('replaygain', '-f')
mediafile = MediaFile(item.path) item.load()
return mediafile.rg_track_gain return item.rg_track_gain
gain_relative_to_84 = analyse(84) gain_relative_to_84 = analyse(84)
gain_relative_to_89 = analyse(89) gain_relative_to_89 = analyse(89)
# check that second calculation did work self.assertNotEqual(gain_relative_to_84, gain_relative_to_89)
if gain_relative_to_84 is not None:
self.assertIsNotNone(gain_relative_to_89) def test_r128_targetlevel_has_effect(self):
if not self.has_r128_support:
self.skipTest("r128 tags for opus not supported on backend {}"
.format(self.backend))
album = self._add_album(1, ext="opus")
item = album.items()[0]
def analyse(target_level):
self.config['replaygain']['r128_targetlevel'] = target_level
self.run_command('replaygain', '-f')
item.load()
return item.r128_track_gain
gain_relative_to_84 = analyse(84)
gain_relative_to_89 = analyse(89)
self.assertNotEqual(gain_relative_to_84, gain_relative_to_89) self.assertNotEqual(gain_relative_to_84, gain_relative_to_89)
def test_per_disc(self):
# Use the per_disc option and add a little more concurrency.
album = self._add_album(track_count=4, disc_count=3)
self.config['replaygain']['per_disc'] = True
self.run_command('replaygain', '-a')
# FIXME: Add fixtures with known track/album gain (within a suitable
# tolerance) so that we can actually check per-disc operation here.
for item in album.items():
self.assertIsNotNone(item.rg_track_gain)
self.assertIsNotNone(item.rg_album_gain)
@unittest.skipIf(not GST_AVAILABLE, 'gstreamer cannot be found') @unittest.skipIf(not GST_AVAILABLE, 'gstreamer cannot be found')
class ReplayGainGstCliTest(ReplayGainCliTestBase, unittest.TestCase): class ReplayGainGstCliTest(ReplayGainCliTestBase, unittest.TestCase,
backend = 'gstreamer' GstBackendMixin):
pass
def setUp(self):
try:
# Check if required plugins can be loaded by instantiating a
# GStreamerBackend (via its .__init__).
config['replaygain']['targetlevel'] = 89
GStreamerBackend(config['replaygain'], None)
except FatalGstreamerPluginReplayGainError as e:
# Skip the test if plugins could not be loaded.
self.skipTest(str(e))
super().setUp()
@unittest.skipIf(not GAIN_PROG_AVAILABLE, 'no *gain command found') @unittest.skipIf(not GAIN_PROG_AVAILABLE, 'no *gain command found')
class ReplayGainCmdCliTest(ReplayGainCliTestBase, unittest.TestCase): class ReplayGainCmdCliTest(ReplayGainCliTestBase, unittest.TestCase,
backend = 'command' CmdBackendMixin):
pass
@unittest.skipIf(not FFMPEG_AVAILABLE, 'ffmpeg cannot be found') @unittest.skipIf(not FFMPEG_AVAILABLE, 'ffmpeg cannot be found')
class ReplayGainFfmpegTest(ReplayGainCliTestBase, unittest.TestCase): class ReplayGainFfmpegCliTest(ReplayGainCliTestBase, unittest.TestCase,
backend = 'ffmpeg' FfmpegBackendMixin):
pass
class ImportTest(TestHelper):
threaded = False
def setUp(self):
# Implemented by Mixins, see above. This may decide to skip the test.
self.test_backend()
self.setup_beets(disk=True)
self.config['threaded'] = self.threaded
self.config['replaygain'] = {
'backend': self.backend,
}
try:
self.load_plugins('replaygain')
except Exception:
import sys
# store exception info so an error in teardown does not swallow it
exc_info = sys.exc_info()
try:
self.teardown_beets()
self.unload_plugins()
except Exception:
# if load_plugins() failed then setup is incomplete and
# teardown operations may fail. In particular # {Item,Album}
# may not have the _original_types attribute in unload_plugins
pass
raise None.with_traceback(exc_info[2])
self.importer = self.create_importer()
def tearDown(self):
self.unload_plugins()
self.teardown_beets()
def test_import_converted(self):
self.importer.run()
for item in self.lib.items():
# FIXME: Add fixtures with known track/album gain (within a
# suitable tolerance) so that we can actually check correct
# operation here.
self.assertIsNotNone(item.rg_track_gain)
self.assertIsNotNone(item.rg_album_gain)
@unittest.skipIf(not GST_AVAILABLE, 'gstreamer cannot be found')
class ReplayGainGstImportTest(ImportTest, unittest.TestCase,
GstBackendMixin):
pass
@unittest.skipIf(not GAIN_PROG_AVAILABLE, 'no *gain command found')
class ReplayGainCmdImportTest(ImportTest, unittest.TestCase,
CmdBackendMixin):
pass
@unittest.skipIf(not FFMPEG_AVAILABLE, 'ffmpeg cannot be found')
class ReplayGainFfmpegImportTest(ImportTest, unittest.TestCase,
FfmpegBackendMixin):
pass
@unittest.skipIf(not FFMPEG_AVAILABLE, 'ffmpeg cannot be found')
class ReplayGainFfmpegThreadedImportTest(ImportTest, unittest.TestCase,
FfmpegBackendMixin):
threaded = True
def suite(): def suite():