replaygain: pass target_level and peak to backends

Configure the replaygain analysis by passing arguments to the Backends. This
avoids the difference between ReplayGain and EBU r128 backends; every Backend
can now fulfil both tasks. Additionally it eases Backend development as the
difference between the two tag formats is now completely handled in the main
Plugin, not in the Backends.
This commit is contained in:
Zsin Skri 2018-10-27 21:04:51 +02:00
parent b9063a0240
commit 0c8eead459

View file

@ -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``.
"""