Skip to content

Commit

Permalink
replaygain: add ffmpeg backend
Browse files Browse the repository at this point in the history
Add replaygain backend using ffmpeg's ebur128 filter.

The album gain is calculated as the mean of all BS.1770 gating block powers.
Besides differences in gating block offset, this should be equivalent to a
BS.1770 analysis of a proper concatenation of all tracks.

Just calculating the mean of all track gains (as implemented by the bs1770gain
backend) yields incorrect results as that would:
- completely ignore track lengths
  - just using length in seconds won't work either (e.g. BS.1770 ignores
    passages below a threshold)
- take the mean of track loudness, not power

When using the ffmpeg replaygain backend to create R128_*_GAIN tags, the
targetlevel will be set to -23 LUFS. GitHub PullRequest beetbox#3065 will make this
configurable.
It will also skip peak calculation, as there is no R128_*_PEAK tag.

It is checked if the libavfilter library supports replaygain calculation. Before
version 6.67.100 that did require the `--enable-libebur128` compile-time-option,
after that the ebur128 library is included in libavfilter itself. Thus we
require either a recent enough libavfilter version or the `--enable-libebur128`
option.
  • Loading branch information
zsinskri committed Jul 14, 2019
1 parent e67562d commit 867df1b
Show file tree
Hide file tree
Showing 3 changed files with 298 additions and 7 deletions.
276 changes: 273 additions & 3 deletions beetsplug/replaygain.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import subprocess
import os
import collections
import math
import sys
import warnings
import xml.parsers.expat
Expand Down Expand Up @@ -64,9 +65,22 @@ def call(args, **kwargs):
raise ReplayGainError(u"argument encoding failed")


def db_to_lufs(db):
"""Convert db to LUFS.
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
# peak: part of full scale (FS is 1.0)
Gain = collections.namedtuple("Gain", "gain peak")
# album_gain: Gain object
# track_gains: list of Gain objects
AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains")


Expand All @@ -81,11 +95,15 @@ def __init__(self, config, log):
self._log = log

def compute_track_gain(self, items):
"""Computes the track gain of the given tracks, returns a list
of Gain objects.
"""
raise NotImplementedError()

def compute_album_gain(self, items):
# TODO: implement album gain in terms of track gain of the
# individual tracks which can be used for any backend.
"""Computes the album gain of the given album, returns an
AlbumGain object.
"""
raise NotImplementedError()

def use_ebu_r128(self):
Expand Down Expand Up @@ -286,6 +304,257 @@ def use_ebu_r128(self):
self.method = '--ebu'


# ffmpeg backend
class FfmpegBackend(Backend):
"""A replaygain backend using ffmpegs ebur128 filter.
"""
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
try:
ffmpeg_version_out = call([self._ffmpeg_path, "-version"])
except OSError:
raise FatalReplayGainError(
u"could not find ffmpeg at {0}".format(self._ffmpeg_path)
)
incompatible_ffmpeg = True
for line in ffmpeg_version_out.stdout.splitlines():
if line.startswith(b"configuration:"):
if b"--enable-libebur128" in line:
incompatible_ffmpeg = False
if line.startswith(b"libavfilter"):
version = line.split(b" ", 1)[1].split(b"/", 1)[0].split(b".")
version = tuple(map(int, version))
if version >= (6, 67, 100):
incompatible_ffmpeg = False
if incompatible_ffmpeg:
raise FatalReplayGainError(
u"Installed FFmpeg version does not support ReplayGain."
u"calculation. Either libavfilter version 6.67.100 or above or"
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):
"""Computes the track gain of the given tracks, returns a list
of Gain objects (the track gains).
"""
gains = []
for item in items:
gains.append(
self._analyse_item(
item,
count_blocks=False,
)[0] # take only the gain, discarding number of gating blocks
)
return gains

def compute_album_gain(self, items):
"""Computes the album gain of the given album, returns an
AlbumGain object.
"""
# analyse tracks
# list of track Gain objects
track_gains = []
# maximum peak
album_peak = 0
# sum of BS.1770 gating block powers
sum_powers = 0
# total number of BS.1770 gating blocks
n_blocks = 0

for item in items:
track_gain, track_n_blocks = self._analyse_item(item)

track_gains.append(track_gain)

# album peak is maximum track peak
album_peak = max(album_peak, track_gain.peak)

# prepare album_gain calculation
# total number of blocks is sum of track blocks
n_blocks += track_n_blocks

# convert `LU to target_level` -> LUFS
track_loudness = self._target_level - 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.
track_power = 10**((track_loudness + 0.691) / 10)

# Weight that average power by the number of gating blocks to
# get the sum of all their powers. Add that to the sum of all
# block powers in this album.
sum_powers += track_power * track_n_blocks

# calculate album gain
if n_blocks > 0:
# compare ITU-R BS.1770-4 p. 6 equation (5)
# Album gain is the replaygain of the concatenation of all tracks.
album_gain = -0.691 + 10 * math.log10(sum_powers / n_blocks)
else:
album_gain = -70
# convert LUFS -> `LU to target_level`
album_gain = self._target_level - album_gain

self._log.debug(
u"{0}: gain {1} LU, peak {2}"
.format(items, album_gain, album_peak)
)

return AlbumGain(Gain(album_gain, album_peak), track_gains)

def _construct_cmd(self, item, peak_method):
"""Construct the shell command to analyse items."""
return [
self._ffmpeg_path,
"-nostats",
"-hide_banner",
"-i",
item.path,
"-filter",
"ebur128=peak={0}".format(peak_method),
"-f",
"null",
"-",
]

def _analyse_item(self, item, count_blocks=True):
"""Analyse item. Returns a Pair (Gain object, number of gating
blocks above threshold).
If `count_blocks` is False, the number of gating blocks returned
will be 0.
"""
# call ffmpeg
self._log.debug(u"analyzing {0}".format(item))
cmd = self._construct_cmd(item, self._peak_method)
self._log.debug(
u'executing {0}', u' '.join(map(displayable_path, cmd))
)
output = call(cmd).stderr.splitlines()

# parse output

if self._peak_method == "none":
peak = 0
else:
line_peak = self._find_line(
output,
" {0} peak:".format(self._peak_method.capitalize()).encode(),
len(output) - 1, -1,
)
peak = self._parse_float(
output[self._find_line(
output, b" Peak:",
line_peak,
)])
# convert TPFS -> part of FS
peak = 10**(peak / 20)

line_integrated_loudness = self._find_line(
output, b" Integrated loudness:",
len(output) - 1, -1,
)
gain = self._parse_float(
output[self._find_line(
output, b" I:",
line_integrated_loudness,
)])
# convert LUFS -> LU from target level
gain = self._target_level - gain

# count BS.1770 gating blocks
n_blocks = 0
if count_blocks:
gating_threshold = self._parse_float(
output[self._find_line(
output, b" Threshold:",
line_integrated_loudness,
)])
for line in output:
if not line.startswith(b"[Parsed_ebur128"):
continue
if line.endswith(b"Summary:"):
continue
line = line.split(b"M:", 1)
if len(line) < 2:
continue
if self._parse_float(b"M: " + line[1]) >= gating_threshold:
n_blocks += 1
self._log.debug(
u"{0}: {1} blocks over {2} LUFS"
.format(item, n_blocks, gating_threshold)
)

self._log.debug(
u"{0}: gain {1} LU, peak {2}"
.format(item, gain, peak)
)

return Gain(gain, peak), n_blocks

def _find_line(self, output, search, start_index=0, step_size=1):
"""Return index of line beginning with `search`.
Begins searching at index `start_index` in `output`.
"""
end_index = len(output) if step_size > 0 else -1
for i in range(start_index, end_index, step_size):
if output[i].startswith(search):
return i
raise ReplayGainError(
u"ffmpeg output: missing {0} after line {1}"
.format(repr(search), start_index)
)

def _parse_float(self, line):
"""Extract a float.
Extract a float from a key value pair in `line`.
"""
# extract value
value = line.split(b":", 1)
if len(value) < 2:
raise ReplayGainError(
u"ffmpeg ouput: expected key value pair, found {0}"
.format(line)
)
value = value[1].lstrip()
# strip unit
value = value.split(b" ", 1)[0]
# cast value to as_type
try:
return float(value)
except ValueError:
raise ReplayGainError(
u"ffmpeg output: expected float value, found {1}"
.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):

Expand Down Expand Up @@ -836,9 +1105,10 @@ class ReplayGainPlugin(BeetsPlugin):
"gstreamer": GStreamerBackend,
"audiotools": AudioToolsBackend,
"bs1770gain": Bs1770gainBackend,
"ffmpeg": FfmpegBackend,
}

r128_backend_names = ["bs1770gain"]
r128_backend_names = ["bs1770gain", "ffmpeg"]

def __init__(self):
super(ReplayGainPlugin, self).__init__()
Expand Down
22 changes: 18 additions & 4 deletions docs/plugins/replaygain.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ playback levels.
Installation
------------

This plugin can use one of three backends to compute the ReplayGain values:
GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools. mp3gain
can be easier to install but GStreamer and Audio Tools support more audio
formats.
This plugin can use one of many backends to compute the ReplayGain values:
GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools or ffmpeg.
mp3gain can be easier to install but GStreamer, Audio Tools and ffmpeg support
more audio formats.

Once installed, this plugin analyzes all files during the import process. This
can be a slow process; to instead analyze after the fact, disable automatic
Expand Down Expand Up @@ -75,6 +75,15 @@ On OS X, most of the dependencies can be installed with `Homebrew`_::

.. _Python Audio Tools: http://audiotools.sourceforge.net

ffmpeg
``````

This backend uses ffmpeg to calculate EBU R128 gain values.
To use it, install the `ffmpeg`_ command-line tool and select the
``ffmpeg`` backend in your config file.

.. _ffmpeg: https://ffmpeg.org

Configuration
-------------

Expand Down Expand Up @@ -106,6 +115,11 @@ These options only work with the "command" backend:
would keep clipping from occurring.
Default: ``yes``.

This option only works with the "ffmpeg" backend:

- **peak**: Either ``true`` (the default) or ``sample``. ``true`` is
more accurate but slower.

Manual Analysis
---------------

Expand Down
7 changes: 7 additions & 0 deletions test/test_replaygain.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
else:
LOUDNESS_PROG_AVAILABLE = False

FFMPEG_AVAILABLE = has_program('ffmpeg', ['-version'])


def reset_replaygain(item):
item['rg_track_peak'] = None
Expand Down Expand Up @@ -205,6 +207,11 @@ def test_malformed_output(self, call_patch):
self.assertEqual(len(matching), 2)


@unittest.skipIf(not FFMPEG_AVAILABLE, u'ffmpeg cannot be found')
class ReplayGainFfmpegTest(ReplayGainCliTestBase, unittest.TestCase):
backend = u'ffmpeg'


def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

Expand Down

0 comments on commit 867df1b

Please sign in to comment.