Initial push of code and tests

This commit is contained in:
Josh W 2021-03-21 19:37:47 -04:00
parent 976023cb59
commit c2fc68f7af
14 changed files with 1354 additions and 0 deletions

12
MANIFEST.in Normal file
View file

@ -0,0 +1,12 @@
prune *
graft tests
graft src/pyebur128
include src/lib/COPYING
include src/lib/README.md
include src/lib/ebur128/ebur128.c
include src/lib/ebur128/ebur128.h
include src/lib/ebur128/queue/sys/queue.h
include LICENSE README.md pyproject.toml setup.py setup.cfg
exclude CHANGELOG.md CONTRIBUTING.md mkdocs.yml mkdocs_macros.py
global-exclude __pycache__ *.py[cod] .*

16
pyproject.toml Normal file
View file

@ -0,0 +1,16 @@
[build-system]
requires = [
'wheel',
'setuptools',
'Cython',
'setuptools_scm[toml]',
]
build-backend = 'setuptools.build_meta'
[tool.pytest.ini_options]
minversion = '6.0'
addopts = '--cov-report=xml --cov-report=term:skip-covered --cov=pyebur128'
testpaths = ['tests']
[tool.setuptools_scm]
write_to = 'src/pyebur128/version.py'

74
setup.cfg Normal file
View file

@ -0,0 +1,74 @@
[metadata]
name = pyebur128
version = attr: pyebur128.__version__
url = https://github.com/jodhus/pyebur128/
license = MIT
license_files = LICENSE
author = Josh Washburne
author_email = josh@jodh.us
maintainer = Josh Washburne
description = A Cython implementation of the libebur128 library for measuring audio loudness.
long_description = file: README.md
long_description_content_type = text/markdown
project_urls =
Documentation = https://github.com/jodhus/pyebur128/
Source = https://github.com/jodhus/pyebur128/
Bug Tracker = https://github.com/jodhus/pyebur128/issues/
keywords =
audio loudness
loudness range
loudness units
true peak
sample peak
relative threshold
ebu r128
itu r bs 1770
classifiers =
Development Status :: 4 - Beta
Intended Audience :: Developers
License :: OSI Approved :: MIT License
Natural Language :: English
Operating System :: MacOS :: MacOS X
Operating System :: POSIX
Operating System :: Unix
Operating System :: Microsoft :: Windows
Programming Language :: Cython
Programming Language :: Python :: 3
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Topic :: Software Development :: Libraries :: Python Modules
Topic :: Multimedia :: Sound/Audio
Topic :: Multimedia :: Sound/Audio :: Analysis
[options]
zip_safe = True
include_package_data = True
python_requires = >= 3.6
install_requires =
packages = find:
package_dir =
=src
[options.packages.find]
where = src
[options.extras_require]
tests =
numpy
pytest
pytest-cov
SoundFile
dev =
cython
flake8
docs =
mkdocs
mkdocs-macros-plugin
mkdocs-material
mkdocstrings
pymdown-extensions
[build_ext]
inplace=1

59
setup.py Normal file
View file

@ -0,0 +1,59 @@
#!/usr/bin/python3
import platform
from setuptools import setup, find_packages, Extension
from distutils.ccompiler import new_compiler
from distutils.msvccompiler import MSVCCompiler
def is_msvc():
'''Checks to see if the detected C compiler is MSVC.'''
try:
# This depends on _winreg, which is not available on not-Windows.
from distutils.msvc9compiler import MSVCCompiler as MSVC9Compiler
except ImportError:
MSVC9Compiler = None
try:
from distutils._msvccompiler import MSVCCompiler as MSVC14Compiler
except ImportError:
MSVC14Compiler = None
msvc_classes = tuple(filter(None, (MSVCCompiler,
MSVC9Compiler,
MSVC14Compiler)))
cc = new_compiler()
return isinstance(cc, msvc_classes)
macros = []
# MSVC won't use <math.h> unless this is defined.
if platform.system() == 'Windows' and is_msvc():
macros.append(('_USE_MATH_DEFINES', None))
extensions = [
Extension(
name='pyebur128.pyebur128',
sources=[
"src/pyebur128/pyebur128.pyx",
"src/lib/ebur128/ebur128.c",
],
include_dirs=[
'.',
'src/lib/ebur128',
'src/lib/ebur128/queue',
],
define_macros=macros,
),
]
if __name__ == '__main__':
from Cython.Build import cythonize
setup(
ext_modules=cythonize(
extensions,
compiler_directives={'language_level': 3, 'embedsignature': True},
),
)

14
src/pyebur128/__init__.py Normal file
View file

@ -0,0 +1,14 @@
from pyebur128.pyebur128 import (
ChannelType, ErrorCode, MeasurementMode,
R128State,
get_loudness_global, get_loudness_global_multiple,
get_loudness_momentary, get_loudness_shortterm, get_loudness_window,
get_loudness_range, get_loudness_range_multiple,
get_sample_peak, get_previous_sample_peak,
get_true_peak, get_previous_true_peak,
get_relative_threshold,
get_libebur128_version
)
# Quick access to the version
from .version import version as __version__

140
src/pyebur128/pyebur128.pxd Normal file
View file

@ -0,0 +1,140 @@
cdef extern from "../lib/ebur128/ebur128.h":
ctypedef enum Channel "channel":
EBUR128_UNUSED = 0
EBUR128_LEFT = 1
EBUR128_Mp030 = 1
EBUR128_RIGHT = 2
EBUR128_Mm030 = 2
EBUR128_CENTER = 3
EBUR128_Mp000 = 3
EBUR128_LEFT_SURROUND = 4
EBUR128_Mp110 = 4
EBUR128_RIGHT_SURROUND = 5
EBUR128_Mm110 = 5
EBUR128_DUAL_MONO
EBUR128_MpSC
EBUR128_MmSC
EBUR128_Mp060
EBUR128_Mm060
EBUR128_Mp090
EBUR128_Mm090
EBUR128_Mp135
EBUR128_Mm135
EBUR128_Mp180
EBUR128_Up000
EBUR128_Up030
EBUR128_Um030
EBUR128_Up045
EBUR128_Um045
EBUR128_Up090
EBUR128_Um090
EBUR128_Up110
EBUR128_Um110
EBUR128_Up135
EBUR128_Um135
EBUR128_Up180
EBUR128_Tp000
EBUR128_Bp000
EBUR128_Bp045
EBUR128_Bm045
ctypedef enum Error "error":
EBUR128_SUCCESS
EBUR128_ERROR_NOMEM
EBUR128_ERROR_INVALID_MODE
EBUR128_ERROR_INVALID_CHANNEL_INDEX
EBUR128_ERROR_NO_CHANGE
ctypedef enum Mode "mode":
EBUR128_MODE_M = (1 << 0)
EBUR128_MODE_S = (1 << 1) | EBUR128_MODE_M
EBUR128_MODE_I = (1 << 2) | EBUR128_MODE_M
EBUR128_MODE_LRA = (1 << 3) | EBUR128_MODE_S
EBUR128_MODE_SAMPLE_PEAK = (1 << 4) | EBUR128_MODE_M
EBUR128_MODE_TRUE_PEAK = (
(1 << 5) | EBUR128_MODE_M | EBUR128_MODE_SAMPLE_PEAK
)
EBUR128_MODE_HISTOGRAM = (1 << 6)
# forward declaration of ebur128_state_internal
struct ebur128_state_internal
# Contains information about the state of a loudness measurement.
# You should not need to modify this struct directly.
ctypedef struct ebur128_state:
int mode # The current mode.
unsigned int channels # The number of channels.
unsigned long samplerate # The sample rate.
ebur128_state_internal* d # Internal state.
void ebur128_get_version(int *major, int *minor, int *patch)
ebur128_state *ebur128_init(unsigned int channels,
unsigned long samplerate,
int mode)
void ebur128_destroy(ebur128_state **state)
int ebur128_set_channel(ebur128_state *state,
unsigned int channel_number,
int value)
int ebur128_change_parameters(ebur128_state *state,
unsigned int channels,
unsigned long samplerate)
int ebur128_set_max_window(ebur128_state *state, unsigned long window)
int ebur128_set_max_history(ebur128_state *state, unsigned long history)
int ebur128_add_frames_short(ebur128_state *state,
const short *source,
size_t frames)
int ebur128_add_frames_int(ebur128_state *state,
const int *source,
size_t frames)
int ebur128_add_frames_float(ebur128_state *state,
const float *source,
size_t frames)
int ebur128_add_frames_double(ebur128_state *state,
const double *source,
size_t frames)
int ebur128_loudness_global(ebur128_state *state, double *loudness)
int ebur128_loudness_global_multiple(ebur128_state **states,
size_t size,
double *loudness)
int ebur128_loudness_momentary(ebur128_state *state, double *loudness)
int ebur128_loudness_shortterm(ebur128_state *state, double *loudness)
int ebur128_loudness_window(ebur128_state *state,
unsigned long window,
double *loudness)
int ebur128_loudness_range(ebur128_state *state, double *loudness)
int ebur128_loudness_range_multiple(ebur128_state **states,
size_t size,
double *loudness)
int ebur128_sample_peak(ebur128_state *state,
unsigned int channel_number,
double *max_peak)
int ebur128_prev_sample_peak(ebur128_state *state,
unsigned int channel_number,
double *max_peak)
int ebur128_true_peak(ebur128_state *state,
unsigned int channel_number,
double *max_peak)
int ebur128_prev_true_peak(ebur128_state *state,
unsigned int channel_number,
double *max_peak)
int ebur128_relative_threshold(ebur128_state *state, double *threshold)

647
src/pyebur128/pyebur128.pyx Normal file
View file

@ -0,0 +1,647 @@
import enum
cimport cython
from libc.stdlib cimport malloc, free
class ChannelType(enum.IntEnum):
'''Use these values when setting the channel map with
R128State.set_channel(). See definitions in ITU R-REC-BS 1770-4.
'''
Unused = 0 # unused channel (for example LFE channel)
Left = 1
Mplus030 = 1 # itu M+030
Right = 2
Mminus030 = 2 # itu M-030
Center = 3
Mplus000 = 3 # itu M+000
LeftSurround = 4
Mplus110 = 4 # itu M+110
RightSuround = 5
Mminus110 = 5 # itu M-110
DualMono = 6 # a channel that is counted twice
MplusSC = 7 # itu M+SC
MminusSC = 8 # itu M-SC
Mplus060 = 9 # itu M+060
Mminus060 = 10 # itu M-060
Mplus090 = 11 # itu M+090
Mminus090 = 12 # itu M-090
Mplus135 = 13 # itu M+135
Mminus135 = 14 # itu M-135
Mplus180 = 15 # itu M+180
Uplus000 = 16 # itu U+000
Uplus030 = 17 # itu U+030
Uminus030 = 18 # itu U-030
Uplus045 = 19 # itu U+045
Uminus045 = 20 # itu U-030
Uplus090 = 21 # itu U+090
Uminus090 = 22 # itu U-090
Uplus110 = 23 # itu U+110
Uminus110 = 24 # itu U-110
Uplus135 = 25 # itu U+135
Uminus135 = 26 # itu U-135
Uplus180 = 27 # itu U+180
Tplus000 = 28 # itu T+000
Bplus000 = 29 # itu B+000
Bplus045 = 30 # itu B+045
Bminus045 = 31 # itu B-045
class ErrorCode(enum.IntEnum):
'''Error codes returned by libebur128 functions.'''
Success = 0
OutOfMemory = 1
InvalidMode = 2
InvalidChannelIndex = 3
ValueDidNotChange = 4
class MeasurementMode(enum.IntFlag):
'''Use these values bitwise OR'd when instancing an R128State class.
Try to use the lowest possible modes that suit your needs, as performance
will be better.
'''
# can call get_loudness_momentary
ModeM = (1 << 0)
# can call get_loudness_shortterm
ModeS = (1 << 1) | ModeM
# can call get_loudness_global_* and get_relative_threshold
ModeI = (1 << 2) | ModeM
# can call get_loudness_range
ModeLRA = (1 << 3) | ModeS
# can call get_sample_peak
ModeSamplePeak = (1 << 4) | ModeM
# can call get_true_peak
ModeTruePeak = (1 << 5) | ModeM | ModeSamplePeak
# uses histogram algorithm to calculate loudness
ModeHistogram = (1 << 6)
ctypedef fused const_frames_array:
const short[::1]
const int[::1]
const float[::1]
const double[::1]
cdef class R128State:
'''This is a class representation of an EBU R128 Loudness Measurement State.
:param channels: The number of audio channels used in the measurement.
:type channels: int
:param samplerate: The samplerate in samples per second (or Hz).
:type samplerate: int
:param mode: A bitwise OR'd value from the :class:`Mode` enum. Try to use
the lowest possible modes that suit your needs, as performance will be
better.
:type mode: int
'''
# Contains information about the state of a loudness measurement.
# You should not need to modify this struct directly.
cdef ebur128_state *_state
def __cinit__(self,
unsigned int channels,
unsigned long samplerate,
int mode):
'''Initialize library state.
:raises MemoryError: If the underlying C-struct cannot be allocated in
memory.
'''
self._state = ebur128_init(channels, samplerate, mode)
if self._state == NULL:
raise MemoryError('Out of memory.')
def __dealloc__(self):
'''Destroy library state.'''
if self._state != NULL:
ebur128_destroy(&self._state)
def __repr__(self):
'''A nicer way of explaining the object.'''
obj = '<{0}: channels={1}, samplerate={2}, mode={3} at 0x{4:0{5}X}>'
return obj.format(
self.__class__.__name__,
self.channels,
self.samplerate,
self.mode.__repr__(),
id(self),
16
)
property channels:
'''The number of audio channels used in the measurement.'''
def __get__(self):
'''channels' getter'''
return self._state.channels if self._state is not NULL else None
def __set__(self, unsigned int c):
'''channels' setter'''
if self._state is not NULL:
self.change_parameters(c, self._state.samplerate)
property samplerate:
'''The samplerate in samples per second (or Hz).'''
def __get__(self):
'''samplerate's getter'''
return self._state.samplerate if self._state is not NULL else None
def __set__(self, unsigned long s):
'''samplerate's setter'''
if self._state is not NULL:
self.change_parameters(self._state.channels, s)
property mode:
''' A bitwise OR'd value from the :class:`Mode` enum. Try to use
the lowest possible modes that suit your needs, as performance will be
better.'''
def __get__(self):
'''mode's getter'''
if self._state is not NULL:
return MeasurementMode(self._state.mode)
else:
return None
def __set__(self, int m):
'''mode's setter'''
if self._state is not NULL:
self._state.mode = m
def set_channel(self, unsigned int channel_number, int channel_type):
'''Sets an audio channel to a specific channel type as defined in the
:class:`ChannelType` enum.
:param channel_number: The zero-based channel index.
:type channel_number: int
:param channel_type: The channel type from :class:`ChannelType`.
:type channel_type: int
:raises IndexError: If specified channel index is out of bounds.
'''
cdef int result
result = ebur128_set_channel(self._state,
channel_number,
channel_type)
if result == ErrorCode.InvalidChannelIndex:
raise IndexError('Channel index is out of bounds.')
def change_parameters(self,
unsigned int channels,
unsigned long samplerate):
'''Changes number of audio channels and/or the samplerate of the
loudness measurement. Returns an integer error code.
Note that the channel map will be reset when setting a different number
of channels. The current unfinished block will be lost.
:param channels: New number of audio channels.
:type channels: int
:param samplerate: The new samplerate in samples per second (or Hz).
:type samplerate: int
:raises MemoryError: If not enough memory could be allocated for the
new values.
:raises ValueError: If both new values are the same as the currently
stored values.
'''
cdef int result
result = ebur128_change_parameters(self._state,
channels,
samplerate)
if result == ErrorCode.OutOfMemory:
raise MemoryError('Out of memory.')
elif result == ErrorCode.ValueDidNotChange:
raise ValueError('Channel numbers & sample rate have not changed.')
def set_max_window(self, unsigned long window):
'''Set the maximum duration that will be used for
:func:`~pyebur128.get_loudness_window`.
Note that this destroys the current content of the audio buffer.
:param window: The duration of the window in milliseconds (ms).
:type window: int
:raises MemoryError: If not enough memory could be allocated for the
new value.
:raises ValueError: If the new window value is the same as the
currently stored window value.
'''
cdef int result
result = ebur128_set_max_window(self._state, window)
if result == ErrorCode.OutOfMemory:
raise MemoryError('Out of memory.')
elif result == ErrorCode.ValueDidNotChange:
raise ValueError('Maximum window duration has not changed.')
def set_max_history(self, unsigned long history):
'''Set the maximum history that will be stored for loudness integration.
More history provides more accurate results, but requires more
resources.
Applies to :func:`~pyebur128.get_loudness_range` and
:func:`~pyebur128.get_loudness_global` when ``ModeHistogram`` is
not set from :class:`pyebur128.MeasurementMode`.
Default is ULONG_MAX (at least ~50 days).
Minimum is 3000ms for ``ModeLRA`` and 400ms for ``ModeM``.
:param history: The duration of history in milliseconds (ms).
:type history: int
:raises MemoryError: If not enough memory could be allocated for the
new value.
:raises ValueError: If the new history value is the same as the
currently stored history value.
'''
cdef int result
result = ebur128_set_max_history(self._state, history)
if result == ErrorCode.ValueDidNotChange:
raise ValueError('Maximum history duration has not changed.')
@cython.boundscheck(False)
@cython.wraparound(False)
def add_frames(self, const_frames_array source, size_t frames):
'''Add frames to be processed.
:param source: An array of source frames. Channels must be interleaved.
:type source: New buffer protocol (PEP-3118) array of short, int, float,
or double.
:param frames: The number of frames. (Not the number of samples!)
:type frames: int
:raises MemoryError: If not enough memory could be allocated for the
new frames.
'''
cdef int result
if const_frames_array is short[::1]:
result = ebur128_add_frames_short(self._state,
&source[0],
frames)
elif const_frames_array is int[::1]:
result = ebur128_add_frames_int(self._state,
&source[0],
frames)
elif const_frames_array is float[::1]:
result = ebur128_add_frames_float(self._state,
&source[0],
frames)
elif const_frames_array is double[::1]:
result = ebur128_add_frames_double(self._state,
&source[0],
frames)
if result == ErrorCode.OutOfMemory:
raise MemoryError('Out of memory.')
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef double get_loudness_global(R128State state):
'''Get the global integrated loudness in LUFS.
:param state: An instance of the :class:`R128State` class.
:type state: R128State
:raises ValueError: If Mode ``ModeI`` has not been set.
:return: The integrated loudness in LUFS.
:rtype: float
'''
cdef double lufs
cdef int result
result = ebur128_loudness_global(state._state, &lufs)
if result == ErrorCode.InvalidMode:
raise ValueError('Mode "ModeI" has not been set.')
return lufs
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef double get_loudness_global_multiple(list states):
'''Get the global integrated loudness in LUFS across multiple instances.
:param state: A list of :class:`R128State` instances.
:type state: list of R128State
:raises MemoryError: If not enough memory could be allocated for the
conversion of a Python list to a C array.
:raises ValueError: If Mode ``ModeI`` has not been set.
:return: The integrated loudness in LUFS.
:rtype: float
'''
cdef double lufs
cdef int result
cdef size_t i, num
cdef ebur128_state **state_ptrs
num = len(states)
state_ptrs = <ebur128_state**>malloc(sizeof(ebur128_state*) * num)
if state_ptrs == NULL:
raise MemoryError('Unable to allocate array of R128 states.')
for i in range(num):
state_ptrs[i] = (<R128State?>states[i])._state
result = ebur128_loudness_global_multiple(state_ptrs, num, &lufs)
free(state_ptrs)
if result == ErrorCode.InvalidMode:
raise ValueError('Mode "ModeI" has not been set.')
return lufs
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef double get_loudness_momentary(R128State state):
'''Get the momentary loudness (last 400ms) in LUFS.
:param state: An instance of the :class:`R128State` class.
:type state: R128State
:return: The momentary loudness in LUFS.
:rtype: float
'''
cdef double lufs
cdef int result
result = ebur128_loudness_momentary(state._state, &lufs)
return lufs
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef double get_loudness_shortterm(R128State state):
'''Get the short-term loudness (last 3s) in LUFS.
:param state: An instance of the :class:`R128State` class.
:type state: R128State
:raises ValueError: If Mode ``ModeS`` has not been set.
:return: The short-term loudness in LUFS.
:rtype: float
'''
cdef double lufs
cdef int result
result = ebur128_loudness_shortterm(state._state, &lufs)
if result == ErrorCode.InvalidMode:
raise ValueError('Mode "ModeS" has not been set.')
return lufs
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef double get_loudness_window(R128State state, unsigned long window):
'''Get loudness of the specified window in LUFS.
:param state: An instance of the :class:`R128State` class.
:type state: R128State
:param window: The window size in milliseconds (ms) to calculate loudness.
:type window: int
:raises ValueError: If the new window size is larger than the current
window size stored in state.
:return: The loudness in LUFS.
:rtype: float
'''
cdef double lufs
cdef int result
result = ebur128_loudness_window(state._state, window, &lufs)
if result == ErrorCode.InvalidMode:
msg = (
'Requested window larger than the current '
'window in the provided state.'
)
raise ValueError(msg)
return lufs
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef double get_loudness_range(R128State state):
'''Get loudness range (LRA) of audio in LU.
Calculates loudness range according to EBU 3342.
:param state: An instance of the :class:`R128State` class.
:type state: R128State
:raises MemoryError: If not enough memory could be allocated for the
measurement.
:raises ValueError: If Mode ``ModeLRA`` has not been set.
:return: The loudness range (LRA) in LU.
:rtype: float
'''
cdef double lufs
cdef int result
result = ebur128_loudness_range(state._state, &lufs)
if result == ErrorCode.OutOfMemory:
raise MemoryError('Memory allocation error.')
elif result == ErrorCode.InvalidMode:
raise ValueError('Mode "ModeLRA" has not been set.')
return lufs
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef double get_loudness_range_multiple(list states):
'''Get loudness range (LRA) of audio in LU across multiple instances.
Calculates loudness range according to EBU 3342.
:param state: A list of :class:`R128State` instances.
:type state: list of R128State
:raises MemoryError: If not enough memory could be allocated for the
measurement or there was a problem with the Python list to C array
conversion.
:raises ValueError: If Mode ``ModeLRA`` has not been set.
:return: The loudness range (LRA) in LU.
:rtype: float
'''
cdef double lufs
cdef int result
cdef size_t i, num
cdef ebur128_state **state_ptrs
num = len(states)
state_ptrs = <ebur128_state**>malloc(sizeof(ebur128_state*) * num)
if state_ptrs == NULL:
raise MemoryError('Unable to allocate array of R128 states.')
for i in range(num):
state_ptrs[i] = (<R128State?>states[i])._state
result = ebur128_loudness_range_multiple(state_ptrs, num, &lufs)
free(state_ptrs)
if result == ErrorCode.OutOfMemory:
raise MemoryError('Memory allocation error.')
elif result == ErrorCode.InvalidMode:
raise ValueError('Mode "ModeLRA" has not been set.')
return lufs
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef double get_sample_peak(R128State state, unsigned int channel_number):
'''Get maximum sample peak from all frames that have been processed.
The equation to convert to dBFS is: 20 * log10(result).
:param state: An instance of the :class:`R128State` class.
:type state: R128State
:param channel_number: The index of the channel to analyze.
:type channel_number: int
:raises ValueError: If Mode ``ModeSamplePeak`` has not been set or the
channel index is out of bounds.
:return: The maximum sample peak (1.0 is 0 dBFS).
:rtype: float
'''
cdef double max_peak
cdef int result
result = ebur128_sample_peak(state._state, channel_number, &max_peak)
if result == ErrorCode.InvalidMode:
raise ValueError('Mode "ModeSamplePeak" has not been set.')
elif result == ErrorCode.InvalidChannelIndex:
raise ValueError('Invalid channel index provided.')
return max_peak
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef double get_previous_sample_peak(R128State state,
unsigned int channel_number):
'''Get maximum sample peak from the last call to add_frames().
The equation to convert to dBFS is: 20 * log10(result).
:param state: An instance of the :class:`R128State` class.
:type state: R128State
:param channel_number: The index of the channel to analyze.
:type channel_number: int
:raises ValueError: If Mode ``ModeSamplePeak`` has not been set or the
channel index is out of bounds.
:return: The maximum sample peak (1.0 is 0 dBFS).
:rtype: float
'''
cdef double max_peak
cdef int result
result = ebur128_prev_sample_peak(state._state, channel_number, &max_peak)
if result == ErrorCode.InvalidMode:
raise ValueError('Mode "ModeSamplePeak" has not been set.')
elif result == ErrorCode.InvalidChannelIndex:
raise ValueError('Invalid channel index provided.')
return max_peak
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef double get_true_peak(R128State state, unsigned int channel_number):
'''Get maximum true peak from all frames that have been processed.
Uses an implementation defined algorithm to calculate the true peak. Do not
try to compare resulting values across different versions of the library,
as the algorithm may change.
The current implementation uses a custom polyphase FIR interpolator to
calculate true peak. Will oversample 4x for sample rates < 96000 Hz, 2x for
sample rates < 192000 Hz and leave the signal unchanged for 192000 Hz.
The equation to convert to dBTP is: 20 * log10(out)
:param state: An instance of the :class:`R128State` class.
:type state: R128State
:param channel_number: The index of the channel to analyze.
:type channel_number: int
:raises ValueError: If Mode ``ModeTruePeak`` has not been set or the
channel index is out of bounds.
:return: The maximum true peak (1.0 is 0 dBTP).
:rtype: float
'''
cdef double max_peak
cdef int result
result = ebur128_true_peak(state._state, channel_number, &max_peak)
if result == ErrorCode.InvalidMode:
raise ValueError('Mode "ModeTruePeak" has not been set.')
elif result == ErrorCode.InvalidChannelIndex:
raise ValueError('Invalid channel index provided.')
return max_peak
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef double get_previous_true_peak(R128State state,
unsigned int channel_number):
'''Get maximum true peak from the last call to add_frames().
Uses an implementation defined algorithm to calculate the true peak. Do not
try to compare resulting values across different versions of the library,
as the algorithm may change.
The current implementation uses a custom polyphase FIR interpolator to
calculate true peak. Will oversample 4x for sample rates < 96000 Hz, 2x for
sample rates < 192000 Hz and leave the signal unchanged for 192000 Hz.
The equation to convert to dBTP is: 20 * log10(out)
:param state: An instance of the :class:`R128State` class.
:type state: R128State
:param channel_number: The index of the channel to analyze.
:type channel_number: int
:raises ValueError: If Mode ``ModeTruePeak`` has not been set or the
channel index is out of bounds.
:return: The maximum true peak (1.0 is 0 dBTP).
:rtype: float
'''
cdef double max_peak
cdef int result
result = ebur128_prev_true_peak(state._state, channel_number, &max_peak)
if result == ErrorCode.InvalidMode:
raise ValueError('Mode "ModeTruePeak" has not been set.')
elif result == ErrorCode.InvalidChannelIndex:
raise ValueError('Invalid channel index provided.')
return max_peak
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef double get_relative_threshold(R128State state):
'''Get relative threshold in LUFS.
:param state: An instance of the :class:`R128State` class.
:type state: R128State
:raises ValueError: If Mode ``ModeI`` has not been set.
:return: The relative threshold in LUFS.
:rtype: float
'''
cdef double threshold
cdef int result
result = ebur128_relative_threshold(state._state, &threshold)
if result == ErrorCode.InvalidMode:
raise ValueError('Mode "ModeI" has not been set.')
return threshold
cpdef get_libebur128_version():
'''Gets the version number of the compiled libebur128.
:return: The major, minor, and patch numbers of the implemented libebur128
version.
:rtype: tuple of int
'''
cdef int major, minor, patch
ebur128_get_version(&major, &minor, &patch)
return major, minor, patch

0
tests/__init__.py Normal file
View file

30
tests/conftest.py Normal file
View file

@ -0,0 +1,30 @@
import pytest
from urllib import request
from zipfile import ZipFile
states = {}
@pytest.fixture(scope='session')
def r128_test_data(tmp_path_factory):
'''Download and extract the test WAV files.'''
# The latest data can be found here:
# https://tech.ebu.ch/publications/ebu_loudness_test_set
url = 'https://tech.ebu.ch/files/live/sites/tech/files/shared/testmaterial/ebu-loudness-test-setv05.zip' # noqa
z = tmp_path_factory.getbasetemp() / 'ebu-loudness-test-setv05.zip'
data = request.urlopen(url)
with open(z, 'wb') as fp:
fp.write(data.read())
with ZipFile(z, 'r') as zp:
zp.extractall(tmp_path_factory.getbasetemp())
return tmp_path_factory.getbasetemp()
@pytest.fixture(scope='session')
def r128_states():
return states

View file

@ -0,0 +1,77 @@
import pytest
from pyebur128 import (
ChannelType, MeasurementMode, R128State,
get_loudness_global, get_loudness_global_multiple
)
import soundfile as sf
def get_single_loudness(filename):
'''Open the WAV file and get the global loudness.'''
with sf.SoundFile(filename) as wav:
state = R128State(wav.channels, wav.samplerate, MeasurementMode.ModeI)
if wav.channels == 5:
state.set_channel(0, ChannelType.Left)
state.set_channel(1, ChannelType.Right)
state.set_channel(2, ChannelType.Center)
state.set_channel(3, ChannelType.LeftSurround)
state.set_channel(4, ChannelType.RightSuround)
for sample in wav.read():
state.add_frames(sample, 1)
loudness = get_loudness_global(state)
return state, loudness
def test_loudness_global_single(r128_test_data, r128_states):
'''Test for the global loudness value of a single file.
NOTE: the tests have a second unused value of the exact expected value. We
are choosing to ignore this for now since it's overkill for most needs.
HOWEVER--passing the tests does not mean that the library is 100% EBU R 128
compliant either!
'''
expected = [
('seq-3341-1-16bit.wav', -23.0, -2.2953556442089987e+01),
('seq-3341-2-16bit.wav', -33.0, -3.2959860397340044e+01),
('seq-3341-3-16bit-v02.wav', -23.0, -2.2995899818255047e+01),
('seq-3341-4-16bit-v02.wav', -23.0, -2.3035918615414182e+01),
('seq-3341-5-16bit-v02.wav', -23.0, -2.2949997446096436e+01),
('seq-3341-6-5channels-16bit.wav', -23.0, -2.3017157781104373e+01),
('seq-3341-6-6channels-WAVEEX-16bit.wav', -23.0, -2.3017157781104373e+01), # noqa
('seq-3341-7_seq-3342-5-24bit.wav', -23.0, -2.2980242495081757e+01),
('seq-3341-2011-8_seq-3342-6-24bit-v02.wav', -23.0, -2.3009077718930545e+01), # noqa
]
tolerance = 0.1
status_msg = '==== \'{}\': want {} \u00b1 {} ---> '
print('\n')
for test in expected:
print(status_msg.format(test[0], test[1], tolerance), end='')
result = get_single_loudness(r128_test_data / test[0])
r128_states[test[0]] = result[0]
print('got {} '.format(round(result[1], 1)), end='')
assert (round(result[1], 1) <= test[1] + tolerance and
round(result[1], 1) >= test[1] - tolerance)
print('---> PASSED!')
def test_loudness_global_multiple(r128_states):
'''Test for the global loudness value across multiple files.'''
states = list(r128_states.values())
expected = -23.18758
tolerance = 0.05
print('\n==== want {} \u00b1 {} ---> '.format(expected, tolerance), end='')
result = get_loudness_global_multiple(states)
print('got {} '.format(round(result, 5)), end='')
assert (round(result, 5) >= expected - tolerance and
round(result, 5) <= expected + tolerance)
print('---> PASSED!')
r128_states = []

View file

@ -0,0 +1,82 @@
import pytest
from pyebur128 import (
ChannelType, MeasurementMode, R128State, get_loudness_momentary
)
import soundfile as sf
def get_max_loudness_momentary(filename):
'''Open the WAV file and get the loudness in momentary (400ms) chunks.'''
with sf.SoundFile(filename) as wav:
state = R128State(wav.channels,
wav.samplerate,
MeasurementMode.ModeM)
if wav.channels == 5:
state.set_channel(0, ChannelType.Left)
state.set_channel(1, ChannelType.Right)
state.set_channel(2, ChannelType.Center)
state.set_channel(3, ChannelType.LeftSurround)
state.set_channel(4, ChannelType.RightSuround)
# 10 ms buffer / 100 Hz refresh rate as 10 Hz refresh rate fails on
# several tests.
max_momentary = float('-inf')
total_frames_read = 0
for block in wav.blocks(blocksize=int(wav.samplerate / 100)):
frames_read = len(block)
total_frames_read += frames_read
for sample in block:
state.add_frames(sample, 1)
# Invalid results before the first 400 ms.
if total_frames_read >= 4 * wav.samplerate / 10:
momentary = get_loudness_momentary(state)
max_momentary = max(momentary, max_momentary)
del state
return max_momentary
def test_max_loudness_momentary(r128_test_data):
'''Test for the loudness value of a single file in momentary (400ms)
chunks.
'''
expected = [
('seq-3341-13-1-24bit.wav', -23.0),
('seq-3341-13-2-24bit.wav', -23.0),
('seq-3341-13-3-24bit.wav.wav', -23.0),
('seq-3341-13-4-24bit.wav.wav', -23.0),
('seq-3341-13-5-24bit.wav.wav', -23.0),
('seq-3341-13-6-24bit.wav.wav', -23.0),
('seq-3341-13-7-24bit.wav.wav', -23.0),
('seq-3341-13-8-24bit.wav.wav', -23.0),
('seq-3341-13-9-24bit.wav.wav', -23.0),
('seq-3341-13-10-24bit.wav.wav', -23.0),
('seq-3341-13-11-24bit.wav.wav', -23.0),
('seq-3341-13-12-24bit.wav.wav', -23.0),
('seq-3341-13-13-24bit.wav.wav', -23.0),
('seq-3341-13-14-24bit.wav.wav', -23.0),
('seq-3341-13-15-24bit.wav.wav', -23.0),
('seq-3341-13-16-24bit.wav.wav', -23.0),
('seq-3341-13-17-24bit.wav.wav', -23.0),
('seq-3341-13-18-24bit.wav.wav', -23.0),
('seq-3341-13-19-24bit.wav.wav', -23.0),
('seq-3341-13-20-24bit.wav.wav', -23.0),
]
tolerance = 0.1
status_msg = '==== \'{}\': want {} \u00b1 {} ---> '
print('\n')
for test in expected:
print(status_msg.format(test[0], test[1], tolerance), end='')
result = get_max_loudness_momentary(r128_test_data / test[0])
print('got {} '.format(round(result, 1)), end='')
assert (round(result, 1) <= test[1] + tolerance and
round(result, 1) >= test[1] - tolerance)
print('---> PASSED!')

View file

@ -0,0 +1,60 @@
import pytest
from pyebur128 import (
ChannelType, MeasurementMode, R128State, get_loudness_range
)
import soundfile as sf
def get_single_loudness_range(filename):
'''Open the WAV file and get the loudness range.'''
with sf.SoundFile(filename) as wav:
state = R128State(wav.channels,
wav.samplerate,
MeasurementMode.ModeLRA)
if wav.channels == 5:
state.set_channel(0, ChannelType.Left)
state.set_channel(1, ChannelType.Right)
state.set_channel(2, ChannelType.Center)
state.set_channel(3, ChannelType.LeftSurround)
state.set_channel(4, ChannelType.RightSuround)
for sample in wav.read():
state.add_frames(sample, 1)
loudness = get_loudness_range(state)
del state
return loudness
def test_loudness_range(r128_test_data):
'''Test for the loudness range value of a single file.
NOTE: the tests have a second unused value of the exact expected value. We
are choosing to ignore this for now since it's overkill for most needs.
HOWEVER--passing the tests does not mean that the library is 100% EBU R 128
compliant either!
'''
expected = [
('seq-3342-1-16bit.wav', 10.0, 1.0001105488329134e+01),
('seq-3342-2-16bit.wav', 5.0, 4.9993734051522178e+00),
('seq-3342-3-16bit.wav', 20.0, 1.9995064067783115e+01),
('seq-3342-4-16bit.wav', 15.0, 1.4999273937723455e+01),
('seq-3341-7_seq-3342-5-24bit.wav', 5.0, 4.9747585878473721e+00),
('seq-3341-2011-8_seq-3342-6-24bit-v02.wav', 15.0, 1.4993650849123316e+01), # noqa
]
tolerance = 1
status_msg = '==== \'{}\': want {} \u00b1 {} ---> '
print('\n')
for test in expected:
print(status_msg.format(test[0], test[1], tolerance), end='')
result = get_single_loudness_range(r128_test_data / test[0])
print('got {} '.format(round(result, 1)), end='')
assert (round(result, 1) <= test[1] + tolerance and
round(result, 1) >= test[1] - tolerance)
print('---> PASSED!')

View file

@ -0,0 +1,81 @@
import pytest
from pyebur128 import (
ChannelType, MeasurementMode, R128State, get_loudness_shortterm
)
import soundfile as sf
def get_max_loudness_shortterm(filename):
'''Open the WAV file and get the loudness in short-term (3s) chunks.'''
with sf.SoundFile(filename) as wav:
state = R128State(wav.channels,
wav.samplerate,
MeasurementMode.ModeS)
if wav.channels == 5:
state.set_channel(0, ChannelType.Left)
state.set_channel(1, ChannelType.Right)
state.set_channel(2, ChannelType.Center)
state.set_channel(3, ChannelType.LeftSurround)
state.set_channel(4, ChannelType.RightSuround)
# 10 ms buffer / 10 Hz refresh rate.
max_shortterm = float('-inf')
total_frames_read = 0
for block in wav.blocks(blocksize=int(wav.samplerate / 10)):
frames_read = len(block)
total_frames_read += frames_read
for sample in block:
state.add_frames(sample, 1)
# Invalid results before the first 3 seconds.
if total_frames_read >= 3 * wav.samplerate:
shortterm = get_loudness_shortterm(state)
max_shortterm = max(shortterm, max_shortterm)
del state
return max_shortterm
def test_max_loudness_shortterm(r128_test_data):
'''Test for the loudness value of a single file in short-term (3s)
chunks.
'''
expected = [
('seq-3341-10-1-24bit.wav', -23.0),
('seq-3341-10-2-24bit.wav', -23.0),
('seq-3341-10-3-24bit.wav', -23.0),
('seq-3341-10-4-24bit.wav', -23.0),
('seq-3341-10-5-24bit.wav', -23.0),
('seq-3341-10-6-24bit.wav', -23.0),
('seq-3341-10-7-24bit.wav', -23.0),
('seq-3341-10-8-24bit.wav', -23.0),
('seq-3341-10-9-24bit.wav', -23.0),
('seq-3341-10-10-24bit.wav', -23.0),
('seq-3341-10-11-24bit.wav', -23.0),
('seq-3341-10-12-24bit.wav', -23.0),
('seq-3341-10-13-24bit.wav', -23.0),
('seq-3341-10-14-24bit.wav', -23.0),
('seq-3341-10-15-24bit.wav', -23.0),
('seq-3341-10-16-24bit.wav', -23.0),
('seq-3341-10-17-24bit.wav', -23.0),
('seq-3341-10-18-24bit.wav', -23.0),
('seq-3341-10-19-24bit.wav', -23.0),
('seq-3341-10-20-24bit.wav', -23.0),
]
tolerance = 0.1
status_msg = '==== \'{}\': want {} \u00b1 {} ---> '
print('\n')
for test in expected:
print(status_msg.format(test[0], test[1], tolerance), end='')
result = get_max_loudness_shortterm(r128_test_data / test[0])
print('got {} '.format(round(result, 1)), end='')
assert (round(result, 1) <= test[1] + tolerance and
round(result, 1) >= test[1] - tolerance)
print('---> PASSED!')

View file

@ -0,0 +1,62 @@
from math import log10
import pytest
from pyebur128 import (
ChannelType, MeasurementMode, R128State, get_true_peak
)
import soundfile as sf
def get_max_true_peak(filename):
'''Open the WAV file and get the maximum true loudness peak.'''
with sf.SoundFile(filename) as wav:
state = R128State(wav.channels,
wav.samplerate,
MeasurementMode.ModeTruePeak)
if wav.channels == 5:
state.set_channel(0, ChannelType.Left)
state.set_channel(1, ChannelType.Right)
state.set_channel(2, ChannelType.Center)
state.set_channel(3, ChannelType.LeftSurround)
state.set_channel(4, ChannelType.RightSuround)
for sample in wav.read():
state.add_frames(sample, 1)
max_true_peak = float('-inf')
for channel in range(state.channels):
true_peak = get_true_peak(state, channel)
max_true_peak = max(true_peak, max_true_peak)
del state
return 20 * log10(max_true_peak)
def test_max_true_peak(r128_test_data):
'''Test for the maximum true loudness peak value of a single file.'''
expected = [
('seq-3341-15-24bit.wav.wav', -6.0),
('seq-3341-16-24bit.wav.wav', -6.0),
('seq-3341-17-24bit.wav.wav', -6.0),
('seq-3341-18-24bit.wav.wav', -6.0),
('seq-3341-19-24bit.wav.wav', 3.0),
('seq-3341-20-24bit.wav.wav', 0.0),
('seq-3341-21-24bit.wav.wav', 0.0),
('seq-3341-22-24bit.wav.wav', 0.0),
('seq-3341-23-24bit.wav.wav', 0.0),
]
tolerance = 0.4
status_msg = '==== \'{}\': want {} \u00b1 {} ---> '
print('\n')
for test in expected:
print(status_msg.format(test[0], test[1], tolerance), end='')
result = get_max_true_peak(r128_test_data / test[0])
print('got {} '.format(round(result, 1)), end='')
assert (round(result, 1) <= test[1] + tolerance and
round(result, 1) >= test[1] - tolerance)
print('---> PASSED!')