diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..30a45ec --- /dev/null +++ b/MANIFEST.in @@ -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] .* \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bdfdecf --- /dev/null +++ b/pyproject.toml @@ -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' diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..695d1f2 --- /dev/null +++ b/setup.cfg @@ -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 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2e2ef17 --- /dev/null +++ b/setup.py @@ -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 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}, + ), + ) diff --git a/src/pyebur128/__init__.py b/src/pyebur128/__init__.py new file mode 100644 index 0000000..8224c3c --- /dev/null +++ b/src/pyebur128/__init__.py @@ -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__ diff --git a/src/pyebur128/pyebur128.pxd b/src/pyebur128/pyebur128.pxd new file mode 100644 index 0000000..8c74bf7 --- /dev/null +++ b/src/pyebur128/pyebur128.pxd @@ -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) diff --git a/src/pyebur128/pyebur128.pyx b/src/pyebur128/pyebur128.pyx new file mode 100644 index 0000000..7e50f62 --- /dev/null +++ b/src/pyebur128/pyebur128.pyx @@ -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 = 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] = (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 = 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] = (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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..081623c --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_loudness_global.py b/tests/test_loudness_global.py new file mode 100644 index 0000000..101fa50 --- /dev/null +++ b/tests/test_loudness_global.py @@ -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 = [] diff --git a/tests/test_loudness_momentary.py b/tests/test_loudness_momentary.py new file mode 100644 index 0000000..f45dbc6 --- /dev/null +++ b/tests/test_loudness_momentary.py @@ -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!') diff --git a/tests/test_loudness_range.py b/tests/test_loudness_range.py new file mode 100644 index 0000000..bf0eab6 --- /dev/null +++ b/tests/test_loudness_range.py @@ -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!') diff --git a/tests/test_loudness_shortterm.py b/tests/test_loudness_shortterm.py new file mode 100644 index 0000000..356fea4 --- /dev/null +++ b/tests/test_loudness_shortterm.py @@ -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!') diff --git a/tests/test_max_true_peak.py b/tests/test_max_true_peak.py new file mode 100644 index 0000000..871f4b1 --- /dev/null +++ b/tests/test_max_true_peak.py @@ -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!')