diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..2e40069a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: test + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + + - name: install libsndfile + run: sudo apt-get install -y libsndfile1 + + - name: Install Tox + run: pip install tox + + - name: Run Tox + run: tox -e py + + - name: Upload Coverage + uses: actions/upload-artifact@v2 + with: + name: coverage-${{ matrix.python }} + # use a wildcard so that the archives have a folder in the root + path: .tox/py/cov_* diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d8b28262..00000000 --- a/.travis.yml +++ /dev/null @@ -1,33 +0,0 @@ -language: python -os: linux -services: docker -sudo: required - -matrix: - include: - - env: PYTHON_VERSION=2.7 - - env: PYTHON_VERSION=3.6 - - env: PYTHON_VERSION=3.7 COVERAGE=true - -before_install: - - docker run -d --name linux -v $(pwd):/travis python:${PYTHON_VERSION}-stretch tail -f /dev/null; - docker ps - -install: - - docker exec -t linux bash -c " - apt-get update -qy && - apt-get install -y libsndfile1 && - pip install tox codecov" - -script: - - docker exec -t linux bash -c " - cd /travis && - tox -e py${PYTHON_VERSION//./}" - -after_success: - - | - if [[ "${COVERAGE}" == "true" ]]; then - docker exec -t linux bash -c " - cd /travis/.tox/py${PYTHON_VERSION//./} && - codecov -t ${CODECOV_UPLOAD_TOKEN}" - fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b3a83d9..85655871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## Unreleased Changes + +### Fixed +- Padding after data chunks in bw64 files was not written, and this error was silently ignored in the reader. This can be fixed with the new `ear-utils rewrite` command. +- Mutable default parameters in ADMBuilder could cause unexpected extra blocks to be added when this was used from other programs. +- ADM elements outside the `audioFormatExtended` were parsed, causing errors for some files containing non-standard ADM data. +- Generated IDs for audioStreamFormat and audioTrackFormat included the format (i.e. always PCM, or 0001), but should include the type of the linked channel/pack format. See [#78]. + +### Changed +- Added a warning for audioBlockFormats which have a duration but no rtime; previously these were fixed silently. See [#54]. +- Switched from ruamel.yaml to PyYAML. See [#62]. + +### Added + +- Preliminary support for BS.2076-2 structures. Until this is standardised, a warning will be emitted when rendering files with this version. See [#59] and [#58]. +- `--set-version` to `ear-utils regenerate`. This can be used to fix files which use BS.2076-2 features but have no version tag. + +## [2.1.0] - 2022-01-26 + +### Fixed +- Depth and height parameters were switched in metadata conversion. See [#26]. +- Bug in channel lock priority order, which controls the loudspeaker selection when the object position is the same distance from multiple loudspeakers. See [#28]. +- Screen scaling now fails explicitly in cases where it was not well-defined before, generally with extreme positions and sizes. See [#22]. +- Errors with gaps at the start of metadata. See [#13]. +- Rounding of times in XML writer. See [#12]. +- `audioStreamFormat` referencing error messages. See [34b738a] and [04533fc]. +- Improved extraData handling in BW64 reader; see [#48] + +### Changed +- `DirectSpeakers` panner uses allocentric panning for Cartesian positions. See [222374a]. +- Removed python 2.7 support. +- `fix_block_format_durations` parameter is deprecated, and the ADM XML parser no longer issues warnings for timing issues -- use `ear.fileio.adm.timing_fixes` for this functionality instead. See [#8]. +- `--enable-block-duration-fix` performs more extensive fixes; this now fixes the following issues: + - `audioBlockFormats` where the `rtime` plus the `duration` of one `audioBlockFormat` does not match the `rtime` of the next. + - `interpolationTime` parameter larger than `duration`. + - `audioBlockFormat` `rtime` plus `duration` extending past the end of the containing `audioObject`. +- Issue a warning for `DirectSpeakers` blocks with a `speakerLabel` containing `LFE` which is not detected as an LFE channel. See [#9]. +- Improved warning and error output: tidier formatting, and repeated warnings are suppressed by default. See [#37]. + +### Added +- `loudnessMetadata` data structures, parsing and generation. See [#25]. +- `ear-utils regenerate` command to re-generate AXML and CHNA chunks. See [#8]. +- The `absoluteDistance` parameter is now extracted from AXML and added to the `ExtraData` structure; see [#45]. +- Lots of documentation, see https://ear.readthedocs.io/ + ## [2.0.0] - 2019-05-22 Changes for ITU ADM renderer reference code. @@ -104,6 +149,26 @@ Changes for ITU ADM renderer reference code. Initial release. +[#8]: https://github.com/ebu/ebu_adm_renderer/pull/8 +[#9]: https://github.com/ebu/ebu_adm_renderer/pull/9 +[#12]: https://github.com/ebu/ebu_adm_renderer/pull/12 +[#13]: https://github.com/ebu/ebu_adm_renderer/pull/13 +[#22]: https://github.com/ebu/ebu_adm_renderer/pull/22 +[#25]: https://github.com/ebu/ebu_adm_renderer/pull/25 +[#26]: https://github.com/ebu/ebu_adm_renderer/pull/26 +[#28]: https://github.com/ebu/ebu_adm_renderer/pull/28 +[#37]: https://github.com/ebu/ebu_adm_renderer/pull/37 +[#45]: https://github.com/ebu/ebu_adm_renderer/pull/45 +[#48]: https://github.com/ebu/ebu_adm_renderer/pull/48 +[#54]: https://github.com/ebu/ebu_adm_renderer/pull/54 +[#62]: https://github.com/ebu/ebu_adm_renderer/pull/62 +[#59]: https://github.com/ebu/ebu_adm_renderer/pull/59 +[#58]: https://github.com/ebu/ebu_adm_renderer/issues/58 +[#78]: https://github.com/ebu/ebu_adm_renderer/pull/78 +[34b738a]: https://github.com/ebu/ebu_adm_renderer/commit/34b738a +[04533fc]: https://github.com/ebu/ebu_adm_renderer/commit/04533fc +[222374a]: https://github.com/ebu/ebu_adm_renderer/commit/222374a +[2.1.0]: https://github.com/ebu/ebu_adm_renderer/compare/2.0.0...2.1.0 [2.0.0]: https://github.com/ebu/ebu_adm_renderer/compare/1.2.0...2.0.0 [1.2.0]: https://github.com/ebu/ebu_adm_renderer/compare/1.1.2...1.2.0 [1.1.2]: https://github.com/ebu/ebu_adm_renderer/compare/1.1.1...1.1.2 diff --git a/MANIFEST.in b/MANIFEST.in index f8cd901d..eebba29e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include README.md include CHANGELOG.md -include ear/core/data/README.md include LICENSE include doc/layout_file.md include tox.ini + +graft ear diff --git a/README.md b/README.md index 090b2d7e..2aee01a2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # EBU ADM Renderer (EAR) -[![Build Status](https://travis-ci.org/ebu/ebu_adm_renderer.svg?branch=master)](https://travis-ci.org/ebu/ebu_adm_renderer) -[![codecov](https://codecov.io/gh/ebu/ebu_adm_renderer/branch/master/graph/badge.svg)](https://codecov.io/gh/ebu/ebu_adm_renderer) +[![build status badge](https://github.com/ebu/ebu_adm_renderer/workflows/test/badge.svg)](https://github.com/ebu/ebu_adm_renderer/actions?workflow=test) The **EBU ADM Renderer** **(*EAR*)** is a complete interpretation of the **Audio Definition Model (ADM)** format, specified in Recommendation [ITU-R BS.2076-1](https://www.itu.int/rec/R-REC-BS.2076/en). ADM is the recommended format for all stages and use cases within the scope of programme productions of **Next Generation Audio (NGA)**. This repository contains a Python reference implementation of the EBU ADM Renderer. @@ -26,8 +25,8 @@ $ pip install ear ### Python versions -*EAR* supports Python 2.7 and Python >=3.6 -and runs on all major platforms (Linux, Mac OSX, Windows). +*EAR* supports Python >=3.6 and runs on all major platforms (Linux, Mac OSX, +Windows). ### Installation of extra packages diff --git a/doc/.gitignore b/doc/.gitignore new file mode 100644 index 00000000..19ac2d07 --- /dev/null +++ b/doc/.gitignore @@ -0,0 +1,2 @@ +env +_build diff --git a/doc/ADM_XML.rst b/doc/ADM_XML.rst new file mode 100644 index 00000000..0ba30125 --- /dev/null +++ b/doc/ADM_XML.rst @@ -0,0 +1,18 @@ +ADM XML Handling +================ + +.. py:module:: ear.fileio.adm.xml + +XML Parsing +----------- + +.. autofunction:: load_axml_doc +.. autofunction:: load_axml_string +.. autofunction:: load_axml_file +.. autofunction:: parse_string +.. autofunction:: parse_file + +XML Generation +-------------- + +.. autofunction:: adm_to_xml diff --git a/doc/ADM_data.rst b/doc/ADM_data.rst new file mode 100644 index 00000000..a69326d1 --- /dev/null +++ b/doc/ADM_data.rst @@ -0,0 +1,193 @@ +ADM Data +-------- + +.. py:module:: ear.fileio.adm + +The ADM data representation is in the `ear.fileio.adm` module. + +An ADM document is represented by an :class:`adm.ADM` object, which contains +lists of all of the top-level ADM elements. + +In general, element objects have properties which match the ADM XML tag or +attribute names, except for: + +- The main ID of elements are stored in an ``.id`` property, rather than (for + example) ``.audioProgrammeID``. + +- ``typeDefinition`` and ``typeLabel`` are resolved to a single ``.type`` + property. + +- ``formatDefinition`` and ``formatLabel`` are resolved to a single ``.format`` + property. + +- References to other objects by ID are translated into a python object + reference, or a list of references. For example, ``audioObjectIDRef`` + elements in an ``audioContent`` tag become a list of + :class:`elements.AudioObject` stored in + :attr:`elements.AudioContent.audioObjects`. + + .. note:: + Element objects do contain ``IDRef`` properties (e.g. + ``audioObjectIDRef``), which are used while parsing, but these are + cleared when references are resolved to avoid storing conflicting + information. + +- In representations of ADM elements which contain both text and attributes + (for example ``0.5``), + the text part is stored in a semantically-relevant property, e.g. + :attr:`elements.ObjectDivergence.value`. For boolean elements (e.g. + ``channelLock``), this is represented by the presence or absence of the + object in the parent object. + +.. autoclass:: ear.fileio.adm.adm.ADM + :members: + +Top-level Elements +~~~~~~~~~~~~~~~~~~ + +.. autoclass:: ear.fileio.adm.elements.main_elements.ADMElement + :members: + +.. autoclass:: ear.fileio.adm.elements.AudioProgramme + :members: + +.. autoclass:: ear.fileio.adm.elements.AudioContent + :members: + +.. autoclass:: ear.fileio.adm.elements.AudioObject + :members: + +.. autoclass:: ear.fileio.adm.elements.AudioPackFormat + :members: + +.. autoclass:: ear.fileio.adm.elements.AudioChannelFormat + :members: + +.. autoclass:: ear.fileio.adm.elements.AudioTrackFormat + :members: + +.. autoclass:: ear.fileio.adm.elements.AudioStreamFormat + :members: + +.. autoclass:: ear.fileio.adm.elements.AudioTrackUID + :members: + +Common Sub-Elements +~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: ear.fileio.adm.elements.TypeDefinition + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: ear.fileio.adm.elements.FormatDefinition + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: ear.fileio.adm.elements.LoudnessMetadata + :members: + +.. autoclass:: ear.fileio.adm.elements.Frequency + :members: + +Common Types +~~~~~~~~~~~~ + +.. autoclass:: ear.fileio.adm.elements.ScreenEdgeLock + :members: + +Timing +~~~~~~ + +In general, times and durations are stored as a fractional number of seconds, +represented by :class:`fractions.Fraction`. + +In order to represent fractional ADM times, :class:`time_format.FractionalTime`, a subclass +of :class:`fractions.Fraction`, is used, while decimal times are represented by +:class:`fractions.Fraction`. + +.. autoclass:: ear.fileio.adm.time_format.FractionalTime + :members: + +.. autofunction:: ear.fileio.adm.time_format.parse_time + +.. autofunction:: ear.fileio.adm.time_format.unparse_time + + +audioBlockFormat types +~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: ear.fileio.adm.elements.AudioBlockFormat + :members: + +Objects audioBlockFormat +'''''''''''''''''''''''' + +.. autoclass:: ear.fileio.adm.elements.AudioBlockFormatObjects + :members: + :show-inheritance: + +.. autoclass:: ear.fileio.adm.elements.CartesianZone + :members: + +.. autoclass:: ear.fileio.adm.elements.PolarZone + :members: + +.. autoclass:: ear.fileio.adm.elements.ChannelLock + :members: + +.. autoclass:: ear.fileio.adm.elements.JumpPosition + :members: + +.. autoclass:: ear.fileio.adm.elements.ObjectDivergence + :members: + +.. autoclass:: ear.fileio.adm.elements.ObjectPosition + :members: + +.. autoclass:: ear.fileio.adm.elements.ObjectPolarPosition + :members: + :show-inheritance: + +.. autoclass:: ear.fileio.adm.elements.ObjectCartesianPosition + :members: + :show-inheritance: + +DirectSpeakers audioBlockFormat +''''''''''''''''''''''''''''''' + +.. autoclass:: ear.fileio.adm.elements.AudioBlockFormatDirectSpeakers + :members: + :show-inheritance: + +.. autoclass:: ear.fileio.adm.elements.BoundCoordinate + :members: + +.. autoclass:: ear.fileio.adm.elements.DirectSpeakerPosition + :members: + +.. autoclass:: ear.fileio.adm.elements.DirectSpeakerPolarPosition + :members: + :show-inheritance: + +.. autoclass:: ear.fileio.adm.elements.DirectSpeakerCartesianPosition + :members: + :show-inheritance: + +HOA AudioBlockFormat +'''''''''''''''''''' + +.. autoclass:: ear.fileio.adm.elements.AudioBlockFormatHoa + :members: + :show-inheritance: + +Matrix AudioBlockFormat +''''''''''''''''''''''' + +.. autoclass:: ear.fileio.adm.elements.AudioBlockFormatMatrix + :members: + :show-inheritance: + +.. autoclass:: ear.fileio.adm.elements.MatrixCoefficient + :members: diff --git a/doc/ADM_exceptions.rst b/doc/ADM_exceptions.rst new file mode 100644 index 00000000..9e0af1e3 --- /dev/null +++ b/doc/ADM_exceptions.rst @@ -0,0 +1,21 @@ +ADM Exceptions +============== + +.. py:module:: ear.fileio.adm.exceptions + +Various ADM-related exceptions are defined: + +.. autoclass:: AdmError + :show-inheritance: + +.. autoclass:: AdmMissingRequiredElement + :show-inheritance: + +.. autoclass:: AdmIDError + :show-inheritance: + +.. autoclass:: AdmFormatRefError + :show-inheritance: + +.. + leave out warnings as they are not used diff --git a/doc/ADM_utils.rst b/doc/ADM_utils.rst new file mode 100644 index 00000000..ff790046 --- /dev/null +++ b/doc/ADM_utils.rst @@ -0,0 +1,68 @@ +ADM Utilities +============= + +.. py:module:: ear.fileio.adm + :noindex: + +.. _adm builder: + +ADM Builder +----------- + +The :class:`builder.ADMBuilder` class makes it easier to construct basic ADM +structures which are properly linked together. For example, to make an ADM with +a single Objects channel: + +.. code-block:: python + + from ear.fileio.adm.builder import ADMBuilder + from ear.fileio.adm.elements import AudioBlockFormatObjects, ObjectPolarPosition + + builder = ADMBuilder() + + builder.create_programme(audioProgrammeName="my audioProgramme") + builder.create_content(audioContentName="my audioContent") + + block_formats = [ + AudioBlockFormatObjects( + position=ObjectPolarPosition(azimuth=0.0, elevation=0.0, distance=1.0), + ), + ] + builder.create_item_objects(0, "MyObject 1", block_formats=block_formats) + + # do something with builder.adm + +.. autoclass:: ear.fileio.adm.builder.ADMBuilder + :members: + +ID Generation +------------- + +When ADM objects are created, they have their IDs set to ``None``. Before serialisation, the IDs must be generated using :func:`generate_ids`: + +.. autofunction:: ear.fileio.adm.generate_ids.generate_ids + +CHNA Utilities +-------------- + +In a BW64 file, the AXML and CHNA chunks store overlapping and related information about audioTrackUIDs: + +- track index: CHNA only +- audioTrackFormat reference: CHNA and AXML +- audioPackFormat reference: CHNA and AXML + +To simplify this, the :class:`elements.AudioTrackUID` class stores the track +index from the CHNA, and we provide utilities for copying data between a CHNA +and an ADM object: + +.. autofunction:: ear.fileio.adm.chna.load_chna_chunk +.. autofunction:: ear.fileio.adm.chna.populate_chna_chunk +.. autofunction:: ear.fileio.adm.chna.guess_track_indices + +Common Definitions +------------------ + +The library includes a copy of the common definitions file, which can be loaded +into an ADM structure: + +.. autofunction:: ear.fileio.adm.common_definitions.load_common_definitions diff --git a/doc/BW64.rst b/doc/BW64.rst new file mode 100644 index 00000000..7176e566 --- /dev/null +++ b/doc/BW64.rst @@ -0,0 +1,122 @@ +BW64 I/O +======== + +.. py:module:: ear.fileio + :noindex: + +To read or write a BW64 file, the primary interface is the +:func:`openBw64Adm` and :func:`openBw64` functions. + +To read samples and ADM metadata (as an :class:`.ADM` object) from a file, use +something like: + +.. code-block:: python + + from ear.fileio import openBw64Adm + + with openBw64Adm("path/to/file.wav") as f: + adm = f.adm # get the ADM metadata + + for sample_block in f.iter_sample_blocks(1024): + # do something with sample_block, which will be a numpy float array + # of (nsamples, nchannels) + print(sample_block.shape) + +For lower level access without parsing ADM data: + +.. code-block:: python + + from ear.fileio import openBw64 + + with openBw64("path/to/file.wav") as f: + print(f.axml) # get the raw AXML data + print(f.chna) # get the CHNA data + + while True: + sample_block = f.read(1024) + if not len(sample_block): + break + print(sample_block.shape) + +To write a file, you have to construct the format chunk manually: + +.. code-block:: python + + from ear.fileio.bw64.chunks import FormatInfoChunk, ChnaChunk, AudioID + import numpy as np + + # dummy ADM data + axml = b'some AXML data here' + chna = ChnaChunk([ + AudioID(1, 'ATU_00000001', 'AT_00010001_01', 'AP_00010003'), + ]) + + formatInfo = FormatInfoChunk(formatTag=1, + channelCount=1, + sampleRate=48000, + bitsPerSample=24) + + with openBw64("path/to/file.wav", "w", formatInfo=formatInfo) as f: + # optionally write axml and chna data + f.axml = axml + f.chna = chna + + # write some sample blocks + for i in range(10): + f.write(np.zeros((1024, 1))) + +To write some generated adm data, use something like this to generate the CHNA +and AXML chunk data: + +.. code-block:: python + + from ear.fileio.adm.chna import populate_chna_chunk + from ear.fileio.adm.generate_ids import generate_ids + from ear.fileio.adm.xml import adm_to_xml + import lxml.etree + + adm = ... + + generate_ids(adm) + + chna = ChnaChunk() + populate_chna_chunk(chna, adm) + + xml = adm_to_xml(adm) + axml = lxml.etree.tostring(xml, pretty_print=True) + +.. seealso:: + + :func:`.generate_ids`, :func:`.populate_chna_chunk`, :func:`.adm_to_xml`. + For generating ADM metadata, see :ref:`adm builder`. + +These functions and classes are documented below: + +.. autofunction:: ear.fileio.openBw64 + +.. autofunction:: ear.fileio.openBw64Adm + +.. autoclass:: ear.fileio.utils.Bw64AdmReader + :members: + +.. autoclass:: ear.fileio.bw64.Bw64Reader + :members: + +.. autoclass:: ear.fileio.bw64.Bw64Writer + :members: + +Chunk Classes +~~~~~~~~~~~~~ + +These classes represent chunks (or parts of chunks) in a BW64 file: + +.. autoclass:: ear.fileio.bw64.chunks.ChnaChunk + :members: + +.. autoclass:: ear.fileio.bw64.chunks.AudioID + :members: + +.. autoclass:: ear.fileio.bw64.chunks.FormatInfoChunk + :members: + :undoc-members: + diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 00000000..d4bb2cbb --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 00000000..31a617fe --- /dev/null +++ b/doc/README.md @@ -0,0 +1,5 @@ +To build: + + pip install -r requirements.txt + make html + diff --git a/doc/common.rst b/doc/common.rst new file mode 100644 index 00000000..54d84af9 --- /dev/null +++ b/doc/common.rst @@ -0,0 +1,40 @@ +Common Structures +================= + +.. py::module:: ear.common + +Position Classes +---------------- + +.. autoclass:: ear.common.Position + :members: + +.. autoclass:: ear.common.PolarPosition + :members: + :show-inheritance: + +.. autoclass:: ear.common.CartesianPosition + :members: + :show-inheritance: + +Position Mixins +~~~~~~~~~~~~~~~ + +These two classes provide common methods for the various real position classes: + +.. autoclass:: ear.common.PolarPositionMixin + :members: + +.. autoclass:: ear.common.CartesianPositionMixin + :members: + +Screen Classes +-------------- + +.. autoclass:: ear.common.CartesianScreen + :members: + +.. autoclass:: ear.common.PolarScreen + :members: + +.. autodata:: ear.common.default_screen diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 00000000..e3c98918 --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,147 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# add path to ear module for autodoc +import os +import sys + +sys.path.insert(0, os.path.abspath(".")) +sys.path.insert(0, os.path.abspath("..")) + + +# -- Project information ----------------------------------------------------- + +project = "EBU ADM Renderer (EAR)" +copyright = "2021, EBU ADM Renderer Authors" +author = "EBU ADM Renderer Authors" + +# The full version, including alpha/beta/rc tags +release = "2.0.0" + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx_rtd_theme", + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx.ext.inheritance_diagram", + "sphinxarg.ext", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "README.md", "env", "sphinxarg"] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +html_theme_options = { + "navigation_depth": 5, +} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ["_static"] + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "lxml": ("https://lxml.de/apidoc", None), + "numpy": ("https://numpy.org/doc/stable/", None), +} + + +# put parameter / return types in function descriptions +autodoc_typehints = "description" + + +def autodoc_before_process_signature(app, obj, bound_method): + # remove return type from __init__ type annotations; these are added by + # attrs, and while they are true, they don't make any sense in the + # documentation + if obj.__name__ == "__init__": + annotations = getattr(obj, "__annotations__", {}) + if "return" in annotations: + del annotations["return"] + + +def setup(app): + app.connect("autodoc-before-process-signature", autodoc_before_process_signature) + + import importlib + + # list of import-only modules and their contents, which are modified on + # import to trick sphinx into documenting the in the right place + + alias_modules = { + "ear.fileio.adm.elements": [ + "AudioBlockFormat", + "AudioBlockFormatBinaural", + "AudioBlockFormatDirectSpeakers", + "AudioBlockFormatHoa", + "AudioBlockFormatMatrix", + "AudioBlockFormatObjects", + "AudioChannelFormat", + "AudioContent", + "AudioObject", + "AudioPackFormat", + "AudioProgramme", + "AudioStreamFormat", + "AudioTrackFormat", + "AudioTrackUID", + "BoundCoordinate", + "CartesianZone", + "ChannelLock", + "DirectSpeakerCartesianPosition", + "DirectSpeakerPolarPosition", + "DirectSpeakerPosition", + "FormatDefinition", + "Frequency", + "JumpPosition", + "LoudnessMetadata", + "MatrixCoefficient", + "ObjectCartesianPosition", + "ObjectDivergence", + "ObjectPolarPosition", + "ObjectPosition", + "PolarZone", + "ScreenEdgeLock", + "TypeDefinition", + ], + "ear.fileio": [ + "openBw64", + "openBw64Adm", + ], + "ear.core.select_items": [ + "select_rendering_items", + ], + "ear.core.geom": [ + "cart", + "azimuth", + "elevation", + "distance", + ], + } + + for modname, aliases in alias_modules.items(): + mod = importlib.import_module(modname) + + for attr_name in aliases: + getattr(mod, attr_name).__module__ = modname diff --git a/doc/conversion.rst b/doc/conversion.rst new file mode 100644 index 00000000..30ea83dc --- /dev/null +++ b/doc/conversion.rst @@ -0,0 +1,42 @@ +Conversion +========== + +.. py:module:: ear.core.objectbased.conversion + +The ``ear.core.objectbased.conversion`` module contains functionality to convert +:class:`.AudioBlockFormatObjects` objects between polar and Cartesian coordinate +conventions, according to section 10 of BS.2127-0. + +Conversion functions in this module are not straightforward coordinate +conversions, they instead try to minimise the difference in behaviour between +the polar and Cartesian rendering paths. + +Conversion can not account for all differences between the two rendering paths, +and while conversion of position coordinates is invertible, conversion of +extent parameters is not, except in simple cases. Because of these limitations, +conversion should be used as part of production processes, where the results +can be monitored and adjusted. + +audioBlockFormat Conversion Functions +------------------------------------- + +These conversions apply conversion to :class:`.AudioBlockFormatObjects` objects, +returning a new copy: + +.. autofunction:: to_polar + +.. autofunction:: to_cartesian + +Low-Level Conversion Functions +------------------------------ + +These functions operate on the individual parameters, and may be useful for +testing: + +.. autofunction:: point_polar_to_cart + +.. autofunction:: point_cart_to_polar + +.. autofunction:: extent_polar_to_cart + +.. autofunction:: extent_cart_to_polar diff --git a/doc/core.rst b/doc/core.rst new file mode 100644 index 00000000..0cbebefa --- /dev/null +++ b/doc/core.rst @@ -0,0 +1,20 @@ +Core +==== + +.. py:module:: ear.core + +The ``ear.core`` module contains functionality for rendering ADM data and audio +to loudspeaker signals, and various components supporting that functionality. +In general, an implementation of everything written in Recommendation ITU-R +BS.2127-0 can be found somewhere in this module. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + metadata_input + select_items + track_processor + layout + conversion + geom diff --git a/doc/dev_docs.rst b/doc/dev_docs.rst new file mode 100644 index 00000000..fd99c1da --- /dev/null +++ b/doc/dev_docs.rst @@ -0,0 +1,25 @@ +Developer Documentation +======================= + +These pages contain documentation for people wanting to use the EAR python APIs +to work with ADM data. + +The EAR was primarily developed as a stand-alone application for rendering ADM +files to loudspeaker signals, to support the standardisation of renderers. As +such, the internal APIs are not necessarily as tidy and stable as they could +be, as they were not the focus during development. You have been warned! + +That being said, they represent one of the more complete packages for working +with ADM data (along with libbw64_ and libadm_), so we welcome changes that +improve the experience of using them. + +.. _libbw64: https://github.com/ebu/libbw64 +.. _libadm: https://github.com/ebu/libadm + +.. toctree:: + :maxdepth: 3 + :caption: Contents: + + fileio + core + common diff --git a/doc/fileio.rst b/doc/fileio.rst new file mode 100644 index 00000000..c2a96006 --- /dev/null +++ b/doc/fileio.rst @@ -0,0 +1,21 @@ +File IO +======= + +.. py:module:: ear.fileio + +The ``ear.fileio`` module contains functionality for reading and writing BW64 +files, both with and without ADM content, a class structure for representing +ADM metadata which can be parsed from and converted to XML, and various +utilities for working with ADM data in this structure. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + BW64 + ADM_data + ADM_XML + ADM_utils + ADM_exceptions + timing_fixes + diff --git a/doc/geom.rst b/doc/geom.rst new file mode 100644 index 00000000..d3ee376e --- /dev/null +++ b/doc/geom.rst @@ -0,0 +1,22 @@ +Geometry Utilities +================== + +.. py::module:: ear.common + +Coordinate Conversion +--------------------- + +.. autofunction:: ear.core.geom.cart + +.. autofunction:: ear.core.geom.azimuth + +.. autofunction:: ear.core.geom.elevation + +.. autofunction:: ear.core.geom.distance + +Angle Utilities +--------------- + +.. autofunction:: ear.core.geom.relative_angle + +.. autofunction:: ear.core.geom.inside_angle_range diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 00000000..6768da04 --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,38 @@ +EBU ADM Renderer (EAR) Documentation +==================================== + +The **EBU ADM Renderer** **(EAR)** is a complete interpretation of the **Audio +Definition Model (ADM)** format, specified in Recommendation `ITU-R BS.2076-1 +`__. ADM is the recommended format +for all stages and use cases within the scope of programme productions of +**Next Generation Audio (NGA)**. + +This documentation applies to the python reference implementation of the EAR, +which can be found at https://github.com/ebu/ebu_adm_renderer. + +This EAR is capable of rendering audio signals to all reproduction systems +mentioned in `“Advanced sound system for programme production (ITU-R +BS.2051-1)” `__. + +Further descriptions of the EAR algorithms and functionalities can be found +in `EBU Tech 3388 +`__. + +From version 2.0, this is also the reference implementation of `ITU-R BS.2127 +(ITU ADM Renderer) `__. + +.. toctree:: + :maxdepth: 3 + :caption: Contents: + + install + usage + dev_docs + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/doc/install.rst b/doc/install.rst new file mode 100644 index 00000000..95d429d8 --- /dev/null +++ b/doc/install.rst @@ -0,0 +1,95 @@ +Installation +============ + +For best results, follow this Three Step Plan to installing the EAR without +messing up your system python installation: + +1) `Install Python`_ +2) `Use a Virtualenv`_ +3) `Install EAR`_ + +Install Python +-------------- + +EAR requires Python version 3.6+. Recent python releases include virtualenv by +default, so there's no need to install it separately. + +Debian/Ubuntu + ``sudo apt install python3`` + +OSX + ``brew install python`` + + OSX includes python by default, but it's often outdated and configured a + bit strangely, so it's best to install it from homebrew. + +Windows + https://www.python.org/downloads/windows/ + +It will probably work with tools like anaconda, pyenv, pipenv, poetry etc., but +these are not necessary for most work, and are not actively tested. + +Use a Virtualenv +---------------- + +A virtualenv (or `virtual environment`, or `venv`) is a self-contained python +installation, containing the interpreter and a set of libraries and programs in +a directory in your file system. + +For information about how this should be used on different systems, refer to +the `official virtualenv guide +`_. + +In short, to create a virtualenv called ``env`` in the current directory: + +.. code-block:: shell + + python3 -m venv env + +(you may have to adjust ``python3`` to the version which you installed above) + +To activate it run: + +.. code-block:: shell + + source env/bin/activate + +Now ``pip`` and ``python`` in this shell will operate within the virtualenv -- +pip will install packages into it, and python will only see packages installed +into it. You'll have to activate the virtualenv in any other shell session +which you want to work in. + +If you want to use other python tools with the EAR +(ipython, jupyter etc.) you should install and run them from the same +virtualenv. + +Install EAR +----------- + +To install the latest published version: + +.. code-block:: shell + + pip install ear + +Check that the install was successful by running ``ear-render --help`` -- you +should see the help message. + +For development, or to try out the latest version, clone the repository and +install it in `editable` mode instead: + +.. code-block:: shell + + git clone https://github.com/ebu/ebu_adm_renderer.git + cd ebu_adm_renderer + pip install -e . + +Installed like this, any changes to the source will be visible without having +to re-install. + +You may want to install the extra tools required for testing and development at +the same time: + +.. code-block:: shell + + pip install -e .[test,dev] diff --git a/doc/layout.rst b/doc/layout.rst new file mode 100644 index 00000000..bf216e0d --- /dev/null +++ b/doc/layout.rst @@ -0,0 +1,38 @@ +Loudspeaker Layout +================== + +.. py::module:: ear.core.layout + +The ``ear.core.layout`` module contains data structures which represent +loudspeaker layouts. Rather than being constructed directly, these should be +created by calling :meth:`ear.core.bs2051.get_layout`, and modified to match +real-world layouts using the functionality described in `Real-world Layouts`_. + +.. autoclass:: ear.core.layout.Layout + :members: + +.. autoclass:: ear.core.layout.Channel + :members: + +BS.2051 Layouts +--------------- + +.. autofunction:: ear.core.bs2051.get_layout + +Real-world Layouts +------------------ + +The following functionality is used to allow a user to specify the real +position of loudspeakers in a listening room (independent of the layouts that +can be created with them), which can be used to modify :class:`.layout.Layout` +objects using :meth:`.Layout.with_real_layout`: + +.. autoclass:: ear.core.layout.RealLayout + :members: + +.. autoclass:: ear.core.layout.Speaker + :members: + +.. autofunction:: ear.core.layout.load_real_layout + + See :ref:`speakers_file` for more details and examples. diff --git a/doc/make.bat b/doc/make.bat new file mode 100644 index 00000000..2119f510 --- /dev/null +++ b/doc/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/doc/metadata_input.rst b/doc/metadata_input.rst new file mode 100644 index 00000000..b11ec08d --- /dev/null +++ b/doc/metadata_input.rst @@ -0,0 +1,134 @@ +.. _metadata input: + +Metadata Input +============== + +.. py:module:: ear.core.metadata_input + +The data structures in the ``ear.core.metadata_input`` module act as the main +interface between the core renderer classes for each type, and the wider EAR +system, or other systems which want to render audio using the EAR. + +The data structures are *not ADM*: they are essentially a simplified view of +ADM data containing only the parts required for rendering: an association +between some input audio tracks, and streams of time-varying metadata to apply +to them. + +The input to a renderer is a list of :class:`RenderingItem` objects, which are +specialised for each ADM type (:class:`ObjectRenderingItem`, +:class:`DirectSpeakersRenderingItem` etc.). Each rendering item contains: + +- A pointer to some audio data, through a :class:`TrackSpec` object. This + generally says something like "track 2 of the input audio stream", but can + also contain a more complex mapping for Matrix types, or reference a silent + input. + + In the case of HOA where multiple tracks are rendered together, multiple + track specs are given. + + See :ref:`track specs`. + +- A source of time-varying metadata, provided by a :class:`MetadataSource`. + This can be used to access a sequence of :class:`TypeMetadata` objects, which + are again sub-classed for each ADM type (:class:`ObjectTypeMetadata`, + :class:`DirectSpeakersTypeMetadata` etc.). + + :class:`TypeMetadata` sub-classes generally contain a pointer to the + corresponding audioBlockFormat data, as well as any extra data from outside + the audioBlockFormat which is needed for rendering. + +- Extra data (generally :class:`ImportanceData` and :class:`ADMPath`) which is + not required for rendering, but may be useful for debugging, or uses other + than straightforward rendering (for example only rendering some sub-set of an + ADM file). + +The available classes and their inheritance relationships are shown below: + +.. inheritance-diagram:: + TypeMetadata ObjectTypeMetadata DirectSpeakersTypeMetadata HOATypeMetadata + RenderingItem ObjectRenderingItem DirectSpeakersRenderingItem HOARenderingItem + TrackSpec DirectTrackSpec SilentTrackSpec MatrixCoefficientTrackSpec MixTrackSpec + :parts: 1 + + +Overall Structure +----------------- + +.. autoclass:: ear.core.metadata_input.RenderingItem + +.. autoclass:: ear.core.metadata_input.TypeMetadata + +.. autoclass:: ear.core.metadata_input.MetadataSource + :members: + +.. _track specs: + +Track Specs +----------- + +To render track specs, see :func:`ear.core.track_processor.TrackProcessor` and +:class:`ear.core.track_processor.MultiTrackProcessor`. + +.. autoclass:: ear.core.metadata_input.TrackSpec + :members: + +.. autoclass:: ear.core.metadata_input.DirectTrackSpec + :members: + :show-inheritance: + +.. autoclass:: ear.core.metadata_input.SilentTrackSpec + :members: + :show-inheritance: + +.. autoclass:: ear.core.metadata_input.MatrixCoefficientTrackSpec + :members: + :show-inheritance: + +.. autoclass:: ear.core.metadata_input.MixTrackSpec + :members: + :show-inheritance: + +Objects +------- + +.. autoclass:: ear.core.metadata_input.ObjectTypeMetadata + :members: + :show-inheritance: + +.. autoclass:: ear.core.metadata_input.ObjectRenderingItem + :members: + :show-inheritance: + +Direct Speakers +--------------- + +.. autoclass:: ear.core.metadata_input.DirectSpeakersTypeMetadata + :members: + :show-inheritance: + +.. autoclass:: ear.core.metadata_input.DirectSpeakersRenderingItem + :members: + :show-inheritance: + +HOA +--- + +.. autoclass:: ear.core.metadata_input.HOATypeMetadata + :members: + :show-inheritance: + +.. autoclass:: ear.core.metadata_input.HOARenderingItem + :members: + :show-inheritance: + +Shared Data +----------- + +.. autoclass:: ear.core.metadata_input.ExtraData + :members: + +.. autoclass:: ear.core.metadata_input.ADMPath + :members: + +.. autoclass:: ear.core.metadata_input.ImportanceData + :members: diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 00000000..2077ece7 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,23 @@ +alabaster==0.7.13 +Babel==2.13.0 +certifi==2023.7.22 +charset-normalizer==3.3.0 +docutils==0.18.1 +idna==3.4 +imagesize==1.4.1 +Jinja2==3.1.2 +MarkupSafe==2.1.3 +packaging==23.2 +Pygments==2.16.1 +requests==2.31.0 +snowballstemmer==2.2.0 +Sphinx==7.2.6 +sphinx-rtd-theme==1.3.0 +sphinxcontrib-applehelp==1.0.7 +sphinxcontrib-devhelp==1.0.5 +sphinxcontrib-htmlhelp==2.0.4 +sphinxcontrib-jquery==4.1 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==1.0.6 +sphinxcontrib-serializinghtml==1.1.9 +urllib3==2.0.6 diff --git a/doc/select_items.rst b/doc/select_items.rst new file mode 100644 index 00000000..6bb40940 --- /dev/null +++ b/doc/select_items.rst @@ -0,0 +1,9 @@ +Rendering Item Selection +======================== + +.. py::module:: ear.core.select_items + +Rendering item selection is the process of turning an ADM document into a set +of rendering items, as described in :ref:`metadata input`. + +.. autofunction:: ear.core.select_items.select_rendering_items diff --git a/doc/sphinxarg/LICENSE b/doc/sphinxarg/LICENSE new file mode 100644 index 00000000..b649775b --- /dev/null +++ b/doc/sphinxarg/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2013 Alex Rudakov + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/doc/sphinxarg/README.md b/doc/sphinxarg/README.md new file mode 100644 index 00000000..9f6dec90 --- /dev/null +++ b/doc/sphinxarg/README.md @@ -0,0 +1,3 @@ +Vendored version of https://github.com/alex-rudakov/sphinx-argparse , hacked to +work with option_list directives which modern sphinx rst parsers produce for +definition lists which look like argument definitions. diff --git a/doc/sphinxarg/__init__.py b/doc/sphinxarg/__init__.py new file mode 100644 index 00000000..13a85f77 --- /dev/null +++ b/doc/sphinxarg/__init__.py @@ -0,0 +1 @@ +__version__ = '0.2.5' diff --git a/doc/sphinxarg/ext.py b/doc/sphinxarg/ext.py new file mode 100644 index 00000000..d7c915f7 --- /dev/null +++ b/doc/sphinxarg/ext.py @@ -0,0 +1,519 @@ +import sys +from argparse import ArgumentParser +import os + +from docutils import nodes +from docutils.statemachine import StringList +from docutils.parsers.rst.directives import flag, unchanged +from docutils.parsers.rst import Parser, Directive +from docutils.utils import new_document +from docutils.frontend import OptionParser +from sphinx.util.nodes import nested_parse_with_titles + +from sphinxarg.parser import parse_parser, parser_navigate + + +def map_nested_definitions(nested_content): + if nested_content is None: + raise Exception('Nested content should be iterable, not null') + # build definition dictionary + definitions = {} + for item in nested_content: + if not isinstance(item, (nodes.definition_list, nodes.option_list)): + continue + for subitem in item: + if not isinstance(subitem, (nodes.definition_list_item, nodes.option_list_item)): + continue + if not len(subitem.children) > 0: + continue + classifier = '@after' + idx = subitem.first_child_matching_class(nodes.classifier) + if idx is not None: + ci = subitem[idx] + if len(ci.children) > 0: + classifier = ci.children[0].astext() + if classifier is not None and classifier not in ( + '@replace', '@before', '@after', '@skip'): + raise Exception('Unknown classifier: %s' % classifier) + idx = subitem.first_child_matching_class((nodes.term, nodes.option_group)) + if idx is not None: + term = subitem[idx] + if len(term.children) > 0: + term = term.astext() + idx = subitem.first_child_matching_class((nodes.definition, nodes.description)) + if idx is not None: + subContent = [] + for _ in subitem[idx]: + if isinstance(_, (nodes.definition_list, nodes.option_list)): + subContent.append(_) + + definition = subitem[idx] + if isinstance(definition, nodes.description): + definition = nodes.definition('', *definition.children) + + definitions[term] = (classifier, definition, subContent) + + return definitions + + +def renderList(l, markDownHelp, settings=None): + """ + Given a list of reStructuredText or MarkDown sections, return a docutils node list + """ + if len(l) == 0: + return [] + if markDownHelp: + from sphinxarg.markdown import parseMarkDownBlock + return parseMarkDownBlock('\n\n'.join(l) + '\n') + else: + all_children = [] + for element in l: + if isinstance(element, str): + if settings is None: + settings = OptionParser(components=(Parser,)).get_default_values() + document = new_document(None, settings) + Parser().parse(element + '\n', document) + all_children += document.children + elif isinstance(element, (nodes.definition, nodes.description)): + all_children += element + else: + assert False, element + + return all_children + + +def print_action_groups(data, nested_content, markDownHelp=False, settings=None): + """ + Process all 'action groups', which are also include 'Options' and 'Required + arguments'. A list of nodes is returned. + """ + definitions = map_nested_definitions(nested_content) + nodes_list = [] + if 'action_groups' in data: + for action_group in data['action_groups']: + # Every action group is comprised of a section, holding a title, the description, and the option group (members) + section = nodes.section(ids=[action_group['title']]) + section += nodes.title(action_group['title'], action_group['title']) + + desc = [] + if action_group['description']: + desc.append(action_group['description']) + # Replace/append/prepend content to the description according to nested content + subContent = [] + if action_group['title'] in definitions: + classifier, s, subContent = definitions[action_group['title']] + if classifier == '@replace': + desc = [s] + elif classifier == '@after': + desc.append(s) + elif classifier == '@before': + desc.insert(0, s) + elif classifier == '@skip': + continue + if len(subContent) > 0: + for k, v in map_nested_definitions(subContent).items(): + definitions[k] = v + # Render appropriately + for element in renderList(desc, markDownHelp): + section += element + + localDefinitions = definitions + if len(subContent) > 0: + localDefinitions = {k: v for k, v in definitions.items()} + for k, v in map_nested_definitions(subContent).items(): + localDefinitions[k] = v + + items = [] + # Iterate over action group members + for entry in action_group['options']: + """ + Members will include: + default The default value. This may be ==SUPPRESS== + name A list of option names (e.g., ['-h', '--help'] + help The help message string + There may also be a 'choices' member. + """ + # Build the help text + arg = [] + if 'choices' in entry: + arg.append('Possible choices: {}\n'.format(", ".join([str(c) for c in entry['choices']]))) + if 'help' in entry: + arg.append(entry['help']) + if entry['default'] is not None and entry['default'] not in ['"==SUPPRESS=="', '==SUPPRESS==']: + if entry['default'] == '': + arg.append('Default: ""') + else: + arg.append('Default: {}'.format(entry['default'])) + + # Handle nested content, the term used in the dict has the comma removed for simplicity + desc = arg + term = ', '.join(entry['name']) + if term in localDefinitions: + classifier, s, subContent = localDefinitions[term] + if classifier == '@replace': + desc = [s] + elif classifier == '@after': + desc.append(s) + elif classifier == '@before': + desc.insert(0, s) + term = ', '.join(entry['name']) + + n = nodes.option_list_item('', + nodes.option_group('', nodes.option_string(text=term)), + nodes.description('', *renderList(desc, markDownHelp, settings))) + items.append(n) + + section += nodes.option_list('', *items) + nodes_list.append(section) + + return nodes_list + + +def print_subcommands(data, nested_content, markDownHelp=False, settings=None): + """ + Each subcommand is a dictionary with the following keys: + + ['usage', 'action_groups', 'bare_usage', 'name', 'help'] + + In essence, this is all tossed in a new section with the title 'name'. + Apparently there can also be a 'description' entry. + """ + + definitions = map_nested_definitions(nested_content) + items = [] + if 'children' in data: + subCommands = nodes.section(ids=["Sub-commands:"]) + subCommands += nodes.title('Sub-commands:', 'Sub-commands:') + + for child in data['children']: + sec = nodes.section(ids=[child['name']]) + sec += nodes.title(child['name'], child['name']) + + if 'description' in child and child['description']: + desc = [child['description']] + elif child['help']: + desc = [child['help']] + else: + desc = ['Undocumented'] + + # Handle nested content + subContent = [] + if child['name'] in definitions: + classifier, s, subContent = definitions[child['name']] + if classifier == '@replace': + desc = [s] + elif classifier == '@after': + desc.append(s) + elif classifier == '@before': + desc.insert(0, s) + + for element in renderList(desc, markDownHelp): + sec += element + sec += nodes.literal_block(text=child['bare_usage']) + for x in print_action_groups(child, nested_content + subContent, markDownHelp, + settings=settings): + sec += x + + for x in print_subcommands(child, nested_content + subContent, markDownHelp, + settings=settings): + sec += x + + if 'epilog' in child and child['epilog']: + for element in renderList([child['epilog']], markDownHelp): + sec += element + + subCommands += sec + items.append(subCommands) + + return items + + +def ensureUniqueIDs(items): + """ + If action groups are repeated, then links in the table of contents will + just go to the first of the repeats. This may not be desirable, particularly + in the case of subcommands where the option groups have different members. + This function updates the title IDs by adding _repeatX, where X is a number + so that the links are then unique. + """ + s = set() + for item in items: + for n in item.traverse(descend=True, siblings=True, ascend=False): + if isinstance(n, nodes.section): + ids = n['ids'] + for idx, id in enumerate(ids): + if id not in s: + s.add(id) + else: + i = 1 + while "{}_repeat{}".format(id, i) in s: + i += 1 + ids[idx] = "{}_repeat{}".format(id, i) + s.add(ids[idx]) + n['ids'] = ids + + +class ArgParseDirective(Directive): + has_content = True + option_spec = dict(module=unchanged, func=unchanged, ref=unchanged, + prog=unchanged, path=unchanged, nodefault=flag, + nodefaultconst=flag, filename=unchanged, + manpage=unchanged, nosubcommands=unchanged, passparser=flag, + noepilog=unchanged, nodescription=unchanged, + markdown=flag, markdownhelp=flag) + + def _construct_manpage_specific_structure(self, parser_info): + """ + Construct a typical man page consisting of the following elements: + NAME (automatically generated, out of our control) + SYNOPSIS + DESCRIPTION + OPTIONS + FILES + SEE ALSO + BUGS + """ + items = [] + # SYNOPSIS section + synopsis_section = nodes.section( + '', + nodes.title(text='Synopsis'), + nodes.literal_block(text=parser_info["bare_usage"]), + ids=['synopsis-section']) + items.append(synopsis_section) + # DESCRIPTION section + if 'nodescription' not in self.options: + description_section = nodes.section( + '', + nodes.title(text='Description'), + nodes.paragraph(text=parser_info.get( + 'description', parser_info.get( + 'help', "undocumented").capitalize())), + ids=['description-section']) + nested_parse_with_titles( + self.state, self.content, description_section) + items.append(description_section) + if parser_info.get('epilog') and 'noepilog' not in self.options: + # TODO: do whatever sphinx does to understand ReST inside + # docstrings magically imported from other places. The nested + # parse method invoked above seem to be able to do this but + # I haven't found a way to do it for arbitrary text + if description_section: + description_section += nodes.paragraph( + text=parser_info['epilog']) + else: + description_section = nodes.paragraph( + text=parser_info['epilog']) + items.append(description_section) + # OPTIONS section + options_section = nodes.section( + '', + nodes.title(text='Options'), + ids=['options-section']) + if 'args' in parser_info: + options_section += nodes.paragraph() + options_section += nodes.subtitle(text='Positional arguments:') + options_section += self._format_positional_arguments(parser_info) + for action_group in parser_info['action_groups']: + if 'options' in parser_info: + options_section += nodes.paragraph() + options_section += nodes.subtitle(text=action_group['title']) + options_section += self._format_optional_arguments(action_group) + + # NOTE: we cannot generate NAME ourselves. It is generated by + # docutils.writers.manpage + # TODO: items.append(files) + # TODO: items.append(see also) + # TODO: items.append(bugs) + + if len(options_section.children) > 1: + items.append(options_section) + if 'nosubcommands' not in self.options: + # SUBCOMMANDS section (non-standard) + subcommands_section = nodes.section( + '', + nodes.title(text='Sub-Commands'), + ids=['subcommands-section']) + if 'children' in parser_info: + subcommands_section += self._format_subcommands(parser_info) + if len(subcommands_section) > 1: + items.append(subcommands_section) + if os.getenv("INCLUDE_DEBUG_SECTION"): + import json + # DEBUG section (non-standard) + debug_section = nodes.section( + '', + nodes.title(text="Argparse + Sphinx Debugging"), + nodes.literal_block(text=json.dumps(parser_info, indent=' ')), + ids=['debug-section']) + items.append(debug_section) + return items + + def _format_positional_arguments(self, parser_info): + assert 'args' in parser_info + items = [] + for arg in parser_info['args']: + arg_items = [] + if arg['help']: + arg_items.append(nodes.paragraph(text=arg['help'])) + elif 'choices' not in arg: + arg_items.append(nodes.paragraph(text='Undocumented')) + if 'choices' in arg: + arg_items.append( + nodes.paragraph( + text='Possible choices: ' + ', '.join(arg['choices']))) + items.append( + nodes.option_list_item( + '', + nodes.option_group( + '', nodes.option( + '', nodes.option_string(text=arg['metavar']) + ) + ), + nodes.description('', *arg_items))) + return nodes.option_list('', *items) + + def _format_optional_arguments(self, parser_info): + assert 'options' in parser_info + items = [] + for opt in parser_info['options']: + names = [] + opt_items = [] + for name in opt['name']: + option_declaration = [nodes.option_string(text=name)] + if opt['default'] is not None \ + and opt['default'] not in ['"==SUPPRESS=="', '==SUPPRESS==']: + option_declaration += nodes.option_argument( + '', text='=' + str(opt['default'])) + names.append(nodes.option('', *option_declaration)) + if opt['help']: + opt_items.append(nodes.paragraph(text=opt['help'])) + elif 'choices' not in opt: + opt_items.append(nodes.paragraph(text='Undocumented')) + if 'choices' in opt: + opt_items.append( + nodes.paragraph( + text='Possible choices: ' + ', '.join(opt['choices']))) + items.append( + nodes.option_list_item( + '', nodes.option_group('', *names), + nodes.description('', *opt_items))) + return nodes.option_list('', *items) + + def _format_subcommands(self, parser_info): + assert 'children' in parser_info + items = [] + for subcmd in parser_info['children']: + subcmd_items = [] + if subcmd['help']: + subcmd_items.append(nodes.paragraph(text=subcmd['help'])) + else: + subcmd_items.append(nodes.paragraph(text='Undocumented')) + items.append( + nodes.definition_list_item( + '', + nodes.term('', '', nodes.strong( + text=subcmd['bare_usage'])), + nodes.definition('', *subcmd_items))) + return nodes.definition_list('', *items) + + def _nested_parse_paragraph(self, text): + content = nodes.paragraph() + self.state.nested_parse(StringList(text.split("\n")), 0, content) + return content + + def run(self): + if 'module' in self.options and 'func' in self.options: + module_name = self.options['module'] + attr_name = self.options['func'] + elif 'ref' in self.options: + _parts = self.options['ref'].split('.') + module_name = '.'.join(_parts[0:-1]) + attr_name = _parts[-1] + elif 'filename' in self.options and 'func' in self.options: + mod = {} + try: + f = open(self.options['filename']) + except IOError: + # try open with abspath + f = open(os.path.abspath(self.options['filename'])) + code = compile(f.read(), self.options['filename'], 'exec') + exec(code, mod) + attr_name = self.options['func'] + func = mod[attr_name] + else: + raise self.error( + ':module: and :func: should be specified, or :ref:, or :filename: and :func:') + + # Skip this if we're dealing with a local file, since it obviously can't be imported + if 'filename' not in self.options: + try: + mod = __import__(module_name, globals(), locals(), [attr_name]) + except: + raise self.error('Failed to import "%s" from "%s".\n%s' % (attr_name, module_name, sys.exc_info()[1])) + + if not hasattr(mod, attr_name): + raise self.error(( + 'Module "%s" has no attribute "%s"\n' + 'Incorrect argparse :module: or :func: values?' + ) % (module_name, attr_name)) + func = getattr(mod, attr_name) + + if isinstance(func, ArgumentParser): + parser = func + elif 'passparser' in self.options: + parser = ArgumentParser() + func(parser) + else: + parser = func() + if 'path' not in self.options: + self.options['path'] = '' + path = str(self.options['path']) + if 'prog' in self.options: + parser.prog = self.options['prog'] + result = parse_parser( + parser, skip_default_values='nodefault' in self.options, skip_default_const_values='nodefaultconst' in self.options) + result = parser_navigate(result, path) + if 'manpage' in self.options: + return self._construct_manpage_specific_structure(result) + + # Handle nested content, where markdown needs to be preprocessed + items = [] + nested_content = nodes.paragraph() + if 'markdown' in self.options: + from sphinxarg.markdown import parseMarkDownBlock + items.extend(parseMarkDownBlock('\n'.join(self.content) + '\n')) + else: + self.state.nested_parse( + self.content, self.content_offset, nested_content) + nested_content = nested_content.children + # add common content between + for item in nested_content: + if not isinstance(item, (nodes.definition_list, nodes.option_list)): + items.append(item) + + markDownHelp = False + if 'markdownhelp' in self.options: + markDownHelp = True + if 'description' in result and 'nodescription' not in self.options: + if markDownHelp: + items.extend(renderList([result['description']], True)) + else: + items.append(self._nested_parse_paragraph(result['description'])) + items.append(nodes.literal_block(text=result['usage'])) + items.extend(print_action_groups(result, nested_content, markDownHelp, + settings=self.state.document.settings)) + if 'nosubcommands' not in self.options: + items.extend(print_subcommands(result, nested_content, markDownHelp, + settings=self.state.document.settings)) + if 'epilog' in result and 'noepilog' not in self.options: + items.append(self._nested_parse_paragraph(result['epilog'])) + + # Traverse the returned nodes, modifying the title IDs as necessary to avoid repeats + ensureUniqueIDs(items) + + return items + + +def setup(app): + app.add_directive('argparse', ArgParseDirective) diff --git a/doc/sphinxarg/markdown.py b/doc/sphinxarg/markdown.py new file mode 100644 index 00000000..4e5be428 --- /dev/null +++ b/doc/sphinxarg/markdown.py @@ -0,0 +1,404 @@ +try: + from commonmark import Parser +except ImportError: + from CommonMark import Parser # >= 0.5.6 +try: + from commonmark.node import Node +except ImportError: + from CommonMark.node import Node +from docutils import nodes +from docutils.utils.code_analyzer import Lexer + + +def customWalker(node, space=''): + """ + A convenience function to ease debugging. It will print the node structure that's returned from CommonMark + + The usage would be something like: + + >>> content = Parser().parse('Some big text block\n===================\n\nwith content\n') + >>> customWalker(content) + document + heading + text Some big text block + paragraph + text with content + + Spaces are used to convey nesting + """ + txt = '' + try: + txt = node.literal + except: + pass + + if txt is None or txt == '': + print('{}{}'.format(space, node.t)) + else: + print('{}{}\t{}'.format(space, node.t, txt)) + + cur = node.first_child + if cur: + while cur is not None: + customWalker(cur, space + ' ') + cur = cur.nxt + + +def paragraph(node): + """ + Process a paragraph, which includes all content under it + """ + text = '' + if node.string_content is not None: + text = node.string_content + o = nodes.paragraph('', ' '.join(text)) + o.line = node.sourcepos[0][0] + for n in MarkDown(node): + o.append(n) + + return o + + +def text(node): + """ + Text in a paragraph + """ + return nodes.Text(node.literal) + + +def hardbreak(node): + """ + A
in html or "\n" in ascii + """ + return nodes.Text('\n') + + +def softbreak(node): + """ + A line ending or space. + """ + return nodes.Text('\n') + + +def reference(node): + """ + A hyperlink. Note that alt text doesn't work, since there's no apparent way to do that in docutils + """ + o = nodes.reference() + o['refuri'] = node.destination + if node.title: + o['name'] = node.title + for n in MarkDown(node): + o += n + return o + + +def emphasis(node): + """ + An italicized section + """ + o = nodes.emphasis() + for n in MarkDown(node): + o += n + return o + + +def strong(node): + """ + A bolded section + """ + o = nodes.strong() + for n in MarkDown(node): + o += n + return o + + +def literal(node): + """ + Inline code + """ + rendered = [] + try: + if node.info is not None: + l = Lexer(node.literal, node.info, tokennames="long") + for _ in l: + rendered.append(node.inline(classes=_[0], text=_[1])) + except: + pass + + classes = ['code'] + if node.info is not None: + classes.append(node.info) + if len(rendered) > 0: + o = nodes.literal(classes=classes) + for element in rendered: + o += element + else: + o = nodes.literal(text=node.literal, classes=classes) + + for n in MarkDown(node): + o += n + return o + + +def literal_block(node): + """ + A block of code + """ + rendered = [] + try: + if node.info is not None: + l = Lexer(node.literal, node.info, tokennames="long") + for _ in l: + rendered.append(node.inline(classes=_[0], text=_[1])) + except: + pass + + classes = ['code'] + if node.info is not None: + classes.append(node.info) + if len(rendered) > 0: + o = nodes.literal_block(classes=classes) + for element in rendered: + o += element + else: + o = nodes.literal_block(text=node.literal, classes=classes) + + o.line = node.sourcepos[0][0] + for n in MarkDown(node): + o += n + return o + + +def raw(node): + """ + Add some raw html (possibly as a block) + """ + o = nodes.raw(node.literal, node.literal, format='html') + if node.sourcepos is not None: + o.line = node.sourcepos[0][0] + for n in MarkDown(node): + o += n + return o + + +def transition(node): + """ + An
tag in html. This has no children + """ + return nodes.transition() + + +def title(node): + """ + A title node. It has no children + """ + return nodes.title(node.first_child.literal, node.first_child.literal) + + +def section(node): + """ + A section in reStructuredText, which needs a title (the first child) + This is a custom type + """ + title = '' # All sections need an id + if node.first_child is not None: + if node.first_child.t == u'heading': + title = node.first_child.first_child.literal + o = nodes.section(ids=[title], names=[title]) + for n in MarkDown(node): + o += n + return o + + +def block_quote(node): + """ + A block quote + """ + o = nodes.block_quote() + o.line = node.sourcepos[0][0] + for n in MarkDown(node): + o += n + return o + + +def image(node): + """ + An image element + + The first child is the alt text. reStructuredText can't handle titles + """ + o = nodes.image(uri=node.destination) + if node.first_child is not None: + o['alt'] = node.first_child.literal + return o + + +def listItem(node): + """ + An item in a list + """ + o = nodes.list_item() + for n in MarkDown(node): + o += n + return o + + +def listNode(node): + """ + A list (numbered or not) + For numbered lists, the suffix is only rendered as . in html + """ + if node.list_data['type'] == u'bullet': + o = nodes.bullet_list(bullet=node.list_data['bullet_char']) + else: + o = nodes.enumerated_list(suffix=node.list_data['delimiter'], enumtype='arabic', start=node.list_data['start']) + for n in MarkDown(node): + o += n + return o + + +def MarkDown(node): + """ + Returns a list of nodes, containing CommonMark nodes converted to docutils nodes + """ + cur = node.first_child + + # Go into each child, in turn + output = [] + while cur is not None: + t = cur.t + if t == 'paragraph': + output.append(paragraph(cur)) + elif t == 'text': + output.append(text(cur)) + elif t == 'softbreak': + output.append(softbreak(cur)) + elif t == 'linebreak': + output.append(hardbreak(cur)) + elif t == 'link': + output.append(reference(cur)) + elif t == 'heading': + output.append(title(cur)) + elif t == 'emph': + output.append(emphasis(cur)) + elif t == 'strong': + output.append(strong(cur)) + elif t == 'code': + output.append(literal(cur)) + elif t == 'code_block': + output.append(literal_block(cur)) + elif t == 'html_inline' or t == 'html_block': + output.append(raw(cur)) + elif t == 'block_quote': + output.append(block_quote(cur)) + elif t == 'thematic_break': + output.append(transition(cur)) + elif t == 'image': + output.append(image(cur)) + elif t == 'list': + output.append(listNode(cur)) + elif t == 'item': + output.append(listItem(cur)) + elif t == 'MDsection': + output.append(section(cur)) + else: + print('Received unhandled type: {}. Full print of node:'.format(t)) + cur.pretty() + + cur = cur.nxt + + return output + + +def finalizeSection(section): + """ + Correct the nxt and parent for each child + """ + cur = section.first_child + last = section.last_child + if last is not None: + last.nxt = None + + while cur is not None: + cur.parent = section + cur = cur.nxt + + +def nestSections(block, level=1): + """ + Sections aren't handled by CommonMark at the moment. + This function adds sections to a block of nodes. + 'title' nodes with an assigned level below 'level' will be put in a child section. + If there are no child nodes with titles of level 'level' then nothing is done + """ + cur = block.first_child + if cur is not None: + children = [] + # Do we need to do anything? + nest = False + while cur is not None: + if cur.t == 'heading' and cur.level == level: + nest = True + break + cur = cur.nxt + if not nest: + return + + section = Node('MDsection', 0) + section.parent = block + cur = block.first_child + while cur is not None: + if cur.t == 'heading' and cur.level == level: + # Found a split point, flush the last section if needed + if section.first_child is not None: + finalizeSection(section) + children.append(section) + section = Node('MDsection', 0) + nxt = cur.nxt + # Avoid adding sections without titles at the start + if section.first_child is None: + if cur.t == 'heading' and cur.level == level: + section.append_child(cur) + else: + children.append(cur) + else: + section.append_child(cur) + cur = nxt + + # If there's only 1 child then don't bother + if section.first_child is not None: + finalizeSection(section) + children.append(section) + + block.first_child = None + block.last_child = None + nextLevel = level + 1 + for child in children: + # Handle nesting + if child.t == 'MDsection': + nestSections(child, level=nextLevel) + + # Append + if block.first_child is None: + block.first_child = child + else: + block.last_child.nxt = child + child.parent = block + child.nxt = None + child.prev = block.last_child + block.last_child = child + + +def parseMarkDownBlock(text): + """ + Parses a block of text, returning a list of docutils nodes + + >>> parseMarkdownBlock("Some\n====\n\nblock of text\n\nHeader\n======\n\nblah\n") + [] + """ + block = Parser().parse(text) + # CommonMark can't nest sections, so do it manually + nestSections(block) + + return MarkDown(block) diff --git a/doc/sphinxarg/parser.py b/doc/sphinxarg/parser.py new file mode 100644 index 00000000..aa3ef09c --- /dev/null +++ b/doc/sphinxarg/parser.py @@ -0,0 +1,172 @@ +from argparse import _HelpAction, _SubParsersAction, _StoreConstAction +import re + + +class NavigationException(Exception): + pass + + +def parser_navigate(parser_result, path, current_path=None): + if isinstance(path, str): + if path == '': + return parser_result + path = re.split('\s+', path) + current_path = current_path or [] + if len(path) == 0: + return parser_result + if 'children' not in parser_result: + raise NavigationException( + 'Current parser has no child elements. (path: %s)' % + ' '.join(current_path)) + next_hop = path.pop(0) + for child in parser_result['children']: + if child['name'] == next_hop: + current_path.append(next_hop) + return parser_navigate(child, path, current_path) + raise NavigationException( + 'Current parser has no child element with name: %s (path: %s)' % ( + next_hop, ' '.join(current_path))) + + +def _try_add_parser_attribute(data, parser, attribname): + attribval = getattr(parser, attribname, None) + if attribval is None: + return + if not isinstance(attribval, str): + return + if len(attribval) > 0: + data[attribname] = attribval + + +def _format_usage_without_prefix(parser): + """ + Use private argparse APIs to get the usage string without + the 'usage: ' prefix. + """ + fmt = parser._get_formatter() + fmt.add_usage(parser.usage, parser._actions, + parser._mutually_exclusive_groups, prefix='') + return fmt.format_help().strip() + + +def parse_parser(parser, data=None, **kwargs): + if data is None: + data = { + 'name': '', + 'usage': parser.format_usage().strip(), + 'bare_usage': _format_usage_without_prefix(parser), + 'prog': parser.prog, + } + _try_add_parser_attribute(data, parser, 'description') + _try_add_parser_attribute(data, parser, 'epilog') + for action in parser._get_positional_actions(): + if not isinstance(action, _SubParsersAction): + continue + helps = {} + for item in action._choices_actions: + helps[item.dest] = item.help + + # commands which share an existing parser are an alias, + # don't duplicate docs + subsection_alias = {} + subsection_alias_names = set() + for name, subaction in action._name_parser_map.items(): + if subaction not in subsection_alias: + subsection_alias[subaction] = [] + else: + subsection_alias[subaction].append(name) + subsection_alias_names.add(name) + + for name, subaction in action._name_parser_map.items(): + if name in subsection_alias_names: + continue + subalias = subsection_alias[subaction] + subaction.prog = '%s %s' % (parser.prog, name) + subdata = { + 'name': name if not subalias else '%s (%s)' % (name, ', '.join(subalias)), + 'help': helps.get(name, ''), + 'usage': subaction.format_usage().strip(), + 'bare_usage': _format_usage_without_prefix(subaction), + } + parse_parser(subaction, subdata, **kwargs) + data.setdefault('children', []).append(subdata) + + show_defaults = True + if 'skip_default_values' in kwargs and kwargs['skip_default_values'] is True: + show_defaults = False + show_defaults_const = show_defaults + if 'skip_default_const_values' in kwargs and kwargs['skip_default_const_values'] is True: + show_defaults_const = False + + # argparse stores the different groups as a list in parser._action_groups + # the first element of the list holds the positional arguments, the + # second the option arguments not in groups, and subsequent elements + # argument groups with positional and optional parameters + action_groups = [] + for action_group in parser._action_groups: + options_list = [] + for action in action_group._group_actions: + if isinstance(action, _HelpAction): + continue + + # Quote default values for string/None types + default = action.default + if action.default not in ['', None, True, False] and action.type in [None, str] and isinstance(action.default, str): + default = '"%s"' % default + + # fill in any formatters, like %(default)s + formatDict = dict(vars(action), prog=data.get('prog', ''), default=default) + formatDict['default'] = default + helpStr = action.help or '' # Ensure we don't print None + try: + helpStr = helpStr % formatDict + except: + pass + + # Options have the option_strings set, positional arguments don't + name = action.option_strings + if name == []: + if action.metavar is None: + name = [action.dest] + else: + name = [action.metavar] + # Skip lines for subcommands + if name == ['==SUPPRESS==']: + continue + + if isinstance(action, _StoreConstAction): + option = { + 'name': name, + 'default': default if show_defaults_const else '==SUPPRESS==', + 'help': helpStr + } + else: + option = { + 'name': name, + 'default': default if show_defaults else '==SUPPRESS==', + 'help': helpStr + } + if action.choices: + option['choices'] = action.choices + if "==SUPPRESS==" not in option['help']: + options_list.append(option) + + if len(options_list) == 0: + continue + + # Upper case "Positional Arguments" and "Optional Arguments" titles + if action_group.title == 'optional arguments': + action_group.title = 'Named Arguments' + if action_group.title == 'positional arguments': + action_group.title = 'Positional Arguments' + + group = {'title': action_group.title, + 'description': action_group.description, + 'options': options_list} + + action_groups.append(group) + + if len(action_groups) > 0: + data['action_groups'] = action_groups + + return data diff --git a/doc/timing_fixes.rst b/doc/timing_fixes.rst new file mode 100644 index 00000000..a749ac99 --- /dev/null +++ b/doc/timing_fixes.rst @@ -0,0 +1,22 @@ +Timing Fixes +============ + +.. py:module:: ear.fileio.adm.timing_fixes + +This module contains functions which can be used to fix common timing issues in +ADM files. Generally these are caused by rounding of `start`, `rtime` and +`duration` parameters. + +The following timing issues are addressed: + +- `audioBlockFormats` where the `rtime` plus the `duration` of one + `audioBlockFormat` does not match the `rtime` of the next. + +- `interpolationTime` parameter larger than `duration`. + +- `audioBlockFormat` `rtime` plus `duration` extending past the end of the + containing `audioObject`. + +.. autofunction:: check_blockFormat_timings + +.. autofunction:: fix_blockFormat_timings diff --git a/doc/track_processor.rst b/doc/track_processor.rst new file mode 100644 index 00000000..eeafe4cf --- /dev/null +++ b/doc/track_processor.rst @@ -0,0 +1,19 @@ +Track Processor +=============== + +.. py:module:: ear.core.track_processor + +The ``ear.core.track_processor`` module can be used to render :ref:`track +specs`. Users should create a :class:`TrackProcessorBase` via +:func:`TrackProcessor`, which can be used to extract a single channel of audio +from a multi-channel bus. + +:class:`MultiTrackProcessor` allows for processing multiple tracks at once. + +.. autoclass:: ear.core.track_processor.TrackProcessorBase + :members: + +.. autofunction:: ear.core.track_processor.TrackProcessor + +.. autoclass:: ear.core.track_processor.MultiTrackProcessor + :members: diff --git a/doc/update_deps.sh b/doc/update_deps.sh new file mode 100755 index 00000000..0fc73098 --- /dev/null +++ b/doc/update_deps.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# updates requirements.txt by installing the latest requirements into a +# temporary virtualenv + +REQUIREMENTS="Sphinx sphinx-rtd-theme" + +### + +set -eu + +ENV=$(mktemp -d env.XXXXX) +python -m venv $ENV +source $ENV/bin/activate + +pip install $REQUIREMENTS +pip freeze | grep -v '^-' > requirements.txt + +deactivate +rm -r $ENV diff --git a/doc/usage.rst b/doc/usage.rst new file mode 100644 index 00000000..218b9b63 --- /dev/null +++ b/doc/usage.rst @@ -0,0 +1,209 @@ +Usage +===== + +Command-Line Tools +------------------ + +The EAR reference implementation comes with two command line tools: + +|ear-render|_ + Is the main tool to render BW64/ADM audio files + +|ear-utils|_ + Collection of useful ADM utilities + +.. |ear-render| replace:: ``ear-render`` +.. |ear-utils| replace:: ``ear-utils`` + +.. _ear-render: + +``ear-render`` +~~~~~~~~~~~~~~ + +To render an ADM file, the following three parameters must be given: + +- ``-s`` followed by the target output format to render to +- the name of the input file +- the name of the output file + +For example, ``ear-render -s 0+5+0 input.wav output_surround.wav`` will render +the BW64/ADM file ``input.wav`` to a ``0+5+0`` target speaker layout and store +the result in ``output_surround.wav``. See :ref:`output_format` for details of +the output file format. + +.. argparse:: + :module: ear.cmdline.render_file + :func: make_parser + :prog: ear-render + :nodescription: + + -l, --layout + See :ref:`speakers_file`. + +.. _ear-utils: + +``ear-utils`` +~~~~~~~~~~~~~ + +The ``ear-utils`` command contains a collection of utilities for working with +ADM files as sub-commands. + +.. argparse:: + :module: ear.cmdline.utils + :func: make_parser + :prog: ear-utils + + --screen + See :ref:`speakers_file`. + +.. _output_format: + +Output Format +------------- + +The output of ``ear-render`` is a BW64 file containing one channel for each +loudspeaker in the specified layout. + +The channel order is the same as in the "Loudspeaker configuration" tables in +BS.2051-2 (e.g. table 4 for 0+5+0). + +The output may need further processing before being played back on loudspeakers. + +In particular, the renderer does not do any bass management -- LFE channels in +the output must be treated according to section 3 or 4 of attachment 1 to annex +7 of BS.775-4. This includes the addition of a 10dB gain, and routing to +loudspeakers or a subwoofer. + +The renderer also does not apply any kind of loudspeaker distance compensation +(beyond the gain which may be specified in the speakers file), or EQ. + +To illustrate this, if the input to the renderer exactly matches the output +loudspeaker layout, then the output will be identical to the input. + +.. _speakers_file: + +Speakers File Format +-------------------- + +Information about the loudspeaker layout can be passed to the renderer +by using a speakers file with the ``--speakers`` flag. + +File Format +~~~~~~~~~~~ + +A speakers file is a `YAML `__ +document, which contains a list of loudspeakers under the ``speakers`` +key, and the screen information under the ``screen`` key. Either may be +omitted if not required. + +Speakers list +^^^^^^^^^^^^^ + +The top level ``speakers`` item should contain a sequence of mappings, +one for each output loudspeaker. + +Each mapping should look something like this: + +.. code:: yaml + + - {channel: 7, names: M+000, position: {az: 0.0, el: 0.0, r: 2.0 }} + +which defines a loudspeaker connected to channel 7 (zero based), +assigned to M+000 (in bs.2051 terms), with a given position. The file +should contain a sequence of lines as above; one line per speaker. + +The possible keys are as follows: + +``channel`` (required) + The zero-based output channel number. + +``names`` (required) + A list (or a single string) of BS.2051 channel names that this speaker + should handle, i.e. like ``M+000`` or ``[U+180, UH+180]``. + +``position`` (optional) + A mapping containing the real loudspeaker position, with keys ``az``, + ``el`` and ``r`` specifying the azimuth, elevation and distance of the + loudspeaker in ADM angle format (anticlockwise azimuth, degrees) and + metres. Note that the radius specified is not used to apply distance + compensation. + +``gain_linear`` (optional) + A linear gain to apply to this output channel; this is useful for LFE + outputs. + +Screen +^^^^^^ + +The top level ``screen`` item should contain a mapping, with at least a +``type`` key, and the following options, depending on the type. If the +screen key is omitted, the default polar screen position specified in +BS.2076-1 will be assumed. If a null screen is specified, then +screen-related processing will not be applied. + +if ``type == "polar"`` +'''''''''''''''''''''' + +``aspectRatio`` (required) + Screen width divided by screen height + +``centrePosition`` (required) + Polar position of the centre of the screen, in the same format as the + speaker ``position`` attribute. + +``widthAzimuth`` (required) + Width of the screen in degrees. + +if ``type == "cart"`` +''''''''''''''''''''' + +``aspectRatio`` (required) + Screen width divided by screen height + +``centrePosition`` (required) + Cartesian position of the centre of the screen; a mapping with keys ``X``, + ``Y`` and ``Z``. + +``widthX`` (required) + Width of the screen in Cartesian coordinates. + +Examples +~~~~~~~~ + +Useful speakers files should be stored in ``ear/doc/speakers_files/``. + +A minimal example with a polar screen would look like: + +.. code:: yaml + + speakers: + - {channel: 0, names: M+030, position: {az: 30.0, el: 0.0, r: 2.0 }} + - {channel: 1, names: M-030, position: {az: -30.0, el: 0.0, r: 2.0 }} + screen: + type: polar + aspectRatio: 1.78 + centrePosition: {az: 0.0, el: 0.0, r: 1.0} + widthAzimuth: 58.0 + +A minimal example with a Cartesian screen would look like: + +.. code:: yaml + + speakers: + - {channel: 0, names: M+030, position: {az: 30.0, el: 0.0, r: 2.0 }} + - {channel: 1, names: M-030, position: {az: -30.0, el: 0.0, r: 2.0 }} + screen: + type: cart + aspectRatio: 1.78 + centrePosition: {X: 0.0, Y: 1.0, Z: 0.0} + widthX: 0.5 + +A minimal example with screen processing disabled: + +.. code:: yaml + + speakers: + - {channel: 0, names: M+030, position: {az: 30.0, el: 0.0, r: 2.0 }} + - {channel: 1, names: M-030, position: {az: -30.0, el: 0.0, r: 2.0 }} + screen: null + diff --git a/ear/cmdline/ambix_to_bwf.py b/ear/cmdline/ambix_to_bwf.py index ce14bfc8..471204c2 100644 --- a/ear/cmdline/ambix_to_bwf.py +++ b/ear/cmdline/ambix_to_bwf.py @@ -2,21 +2,30 @@ import lxml.etree from ..core.hoa import from_acn from ..fileio.adm.adm import ADM -from ..fileio.adm.elements import AudioBlockFormatHoa, AudioChannelFormat, TypeDefinition, FormatDefinition -from ..fileio.adm.elements import AudioStreamFormat, AudioTrackFormat, AudioPackFormat, AudioObject, AudioTrackUID +from ..fileio.adm.builder import ADMBuilder +from ..fileio.adm.elements import AudioTrackUID from ..fileio.adm.chna import populate_chna_chunk from ..fileio.adm.generate_ids import generate_ids from ..fileio.adm.xml import adm_to_xml from ..fileio import openBw64 -from ..fileio.bw64.chunks import ChnaChunk, FormatInfoChunk +from ..fileio.bw64.chunks import ChnaChunk def add_args(subparsers): - subparser = subparsers.add_parser("ambix_to_bwf", help="make a BWF file from an ambix format HOA file") + subparser = subparsers.add_parser( + "ambix_to_bwf", help="make a BWF file from an ambix format HOA file" + ) subparser.add_argument("--norm", default="SN3D", help="normalization mode") - subparser.add_argument("--nfcDist", type=float, default=None, help="Near-Field Compensation Distance (float)") + subparser.add_argument( + "--nfcDist", + type=float, + default=None, + help="Near-Field Compensation Distance (float)", + ) subparser.add_argument("--screenRef", help="Screen Reference", action="store_true") - subparser.add_argument("--chna-only", help="use only CHNA with common definitions", action="store_true") + subparser.add_argument( + "--chna-only", help="use only CHNA with common definitions", action="store_true" + ) subparser.add_argument("input", help="input file") subparser.add_argument("output", help="output BWF file") subparser.set_defaults(command=ambix_to_bwf) @@ -27,87 +36,49 @@ def get_acn(n_channels, args): def build_adm(acn, norm, nfcDist, screenRef): - adm = ADM() - - track_uids = [] - - pack_format = AudioPackFormat( - audioPackFormatName="HOA", - type=TypeDefinition.HOA, - audioChannelFormats=[], - ) - adm.addAudioPackFormat(pack_format) + builder = ADMBuilder() order, degree = from_acn(acn) - for channel_no, (order, degree) in enumerate(zip(order, degree), 1): - block_format = AudioBlockFormatHoa( - order=int(order), - degree=int(degree), - normalization=norm, - nfcRefDist=nfcDist, - screenRef=screenRef, - ) - name = "channel_{}".format(channel_no) - channel_format = AudioChannelFormat( - audioChannelFormatName=name, - type=TypeDefinition.HOA, - audioBlockFormats=[block_format], - ) - adm.addAudioChannelFormat(channel_format) - pack_format.audioChannelFormats.append(channel_format) - - stream_format = AudioStreamFormat( - audioStreamFormatName=name, - format=FormatDefinition.PCM, - audioChannelFormat=channel_format, - ) - adm.addAudioStreamFormat(stream_format) - - track_format = AudioTrackFormat( - audioTrackFormatName=name, - format=FormatDefinition.PCM, - audioStreamFormat=stream_format, - ) - adm.addAudioTrackFormat(track_format) - - track_uid = AudioTrackUID( - trackIndex=channel_no, - audioTrackFormat=track_format, - audioPackFormat=pack_format, - ) - adm.addAudioTrackUID(track_uid) - track_uids.append(track_uid) - - audio_object = AudioObject( - audioObjectName="HOA", - audioPackFormats=[pack_format], - audioTrackUIDs=track_uids, + builder.create_item_hoa( + track_indices=acn.tolist(), + orders=order.tolist(), + degrees=degree.tolist(), + name="HOA", + normalization=norm, + nfcRefDist=nfcDist, + screenRef=screenRef, ) - adm.addAudioObject(audio_object) - return adm + return builder.adm def build_adm_common_defs(acns, norm): from ..fileio.adm.common_definitions import load_common_definitions + adm = ADM() load_common_definitions(adm) order, degree = from_acn(acns) pack_name = "3D_order{order}_{norm}_ACN".format(order=max(order), norm=norm) - [pack_format] = [apf for apf in adm.audioPackFormats if apf.audioPackFormatName == pack_name] + [pack_format] = [ + apf for apf in adm.audioPackFormats if apf.audioPackFormatName == pack_name + ] for channel_no, acn in enumerate(acns, 1): track_name = "PCM_{norm}_ACN_{acn}".format(norm=norm, acn=acn) - [track_format] = [tf for tf in adm.audioTrackFormats if tf.audioTrackFormatName == track_name] - - adm.addAudioTrackUID(AudioTrackUID( - trackIndex=channel_no, - audioTrackFormat=track_format, - audioPackFormat=pack_format, - )) + [track_format] = [ + tf for tf in adm.audioTrackFormats if tf.audioTrackFormatName == track_name + ] + + adm.addAudioTrackUID( + AudioTrackUID( + trackIndex=channel_no, + audioTrackFormat=track_format, + audioPackFormat=pack_format, + ) + ) return adm @@ -134,13 +105,11 @@ def ambix_to_bwf(args): chna = ChnaChunk() populate_chna_chunk(chna, adm) - fmtInfo = FormatInfoChunk(formatTag=1, - channelCount=infile.channels, - sampleRate=infile.sampleRate, - bitsPerSample=infile.bitdepth) - - with openBw64(args.output, 'w', chna=chna, formatInfo=fmtInfo, axml=axml) as outfile: + with openBw64( + args.output, "w", chna=chna, formatInfo=infile.formatInfo, axml=axml + ) as outfile: while True: samples = infile.read(1024) - if samples.shape[0] == 0: break + if samples.shape[0] == 0: + break outfile.write(samples) diff --git a/ear/cmdline/dev_config.py b/ear/cmdline/dev_config.py index 7a59aeb1..2e96031b 100644 --- a/ear/cmdline/dev_config.py +++ b/ear/cmdline/dev_config.py @@ -1,7 +1,7 @@ def load_config(args): - from ruamel import yaml + from ..compatibility import load_yaml if args.config is not None: - return yaml.safe_load(args.config) + return load_yaml(args.config) else: return {} @@ -10,7 +10,7 @@ def dump_config_command(args, config): from ..core import Renderer from .. import options import sys - config_str = options.dump_config_with_comments(Renderer.options, options=config) + config_str = options.dump_config(Renderer.options, options=config) if args.output is None or args.output == "-": sys.stdout.write(config_str) diff --git a/ear/cmdline/error_handler.py b/ear/cmdline/error_handler.py new file mode 100644 index 00000000..00b6dc26 --- /dev/null +++ b/ear/cmdline/error_handler.py @@ -0,0 +1,64 @@ +from collections import defaultdict +import contextlib +import logging +import sys +import warnings +from ..fileio.adm.exceptions import AdmUnknownAttribute + + +class LimitedWarningPrint: + """Utility to print a given number of warnings per line; to use, replace + warnings.showwarning with self.showwarning. + """ + + def __init__(self, logger: logging.Logger, max_per_line=5): + self.logger = logger + self.max_per_line = max_per_line + self.counts = defaultdict(lambda: 0) + + def showwarning(self, message, category, filename, lineno, file=None, line=None): + self.counts[(filename, lineno)] += 1 + count = self.counts[(filename, lineno)] + + if count <= self.max_per_line: + self.logger.warning(message) + if count == self.max_per_line: + self.logger.warning( + "suppressing further messages like the above; use --debug to show more" + ) + + +@contextlib.contextmanager +def error_handler(logger: logging.Logger, debug: bool = False, strict: bool = False): + """Context manager for use in CLIs which handles logging of exceptions and + warnings. + + Parameters: + logger: log to write to + debug: should we be more verbose, printing full exceptions + strict: turn unknown attribute warnings into errors + """ + # debug: print every warning in full + # no debug: just print message, stop after n + if debug: + + def showwarning(message, category, filename, lineno, file=None, line=None): + category = category.__name__ + msg = f"{filename}:{lineno}: {category}: {message}" + logger.warning(msg) + + else: + showwarning = LimitedWarningPrint(logger).showwarning + + try: + with warnings.catch_warnings(): + warnings.showwarning = showwarning # documentation says this is allowed + if strict: + warnings.filterwarnings("error", category=AdmUnknownAttribute) + yield + except Exception as error: + if debug: + raise + else: + logger.error(error) + sys.exit(1) diff --git a/ear/cmdline/generate_test_file.py b/ear/cmdline/generate_test_file.py index 67b1a8df..4e1057d9 100644 --- a/ear/cmdline/generate_test_file.py +++ b/ear/cmdline/generate_test_file.py @@ -1,9 +1,15 @@ -import ruamel.yaml import lxml.etree +from ..compatibility import load_yaml from ..core import layout from ..fileio.adm.builder import ADMBuilder -from ..fileio.adm.elements import AudioBlockFormatObjects, JumpPosition, Frequency +from ..fileio.adm.elements import ( + AudioBlockFormatObjects, + JumpPosition, + Frequency, + ObjectDivergence, +) from ..fileio.adm.elements import AudioBlockFormatDirectSpeakers, BoundCoordinate, DirectSpeakerPolarPosition +from ..fileio.adm.elements.version import parse_version from ..fileio.adm.chna import populate_chna_chunk from ..fileio.adm.generate_ids import generate_ids from ..fileio.adm.xml import adm_to_xml @@ -88,6 +94,9 @@ def load_block_objects(block): if "jumpPosition" in block: kwargs["jumpPosition"] = load_jump_position(block["jumpPosition"]) + if "objectDivergence" in block: + kwargs["objectDivergence"] = ObjectDivergence(**block["objectDivergence"]) + for attr in ["position", "gain"]: if attr in block: kwargs[attr] = block[attr] @@ -152,9 +161,10 @@ def create_item(builder, item): def load_test_file_adm(filename): with open(filename) as f: - yaml = ruamel.yaml.safe_load(f) + yaml = load_yaml(f) - builder = ADMBuilder() + version = parse_version(yaml.get("version", None)) + builder = ADMBuilder.for_version(version) builder.create_programme( audioProgrammeName=yaml.get("name", "unnamed"), diff --git a/ear/cmdline/render_file.py b/ear/cmdline/render_file.py index aa9ce856..9f46876e 100644 --- a/ear/cmdline/render_file.py +++ b/ear/cmdline/render_file.py @@ -1,6 +1,5 @@ from __future__ import print_function import argparse -import sys from attr import attrs, attrib, Factory import scipy.sparse from itertools import chain @@ -10,14 +9,16 @@ from ..core.select_items import select_rendering_items from ..fileio import openBw64, openBw64Adm from ..fileio.adm.elements import AudioProgramme, AudioObject +from ..fileio.adm.elements.version import version_at_least from ..fileio.bw64.chunks import FormatInfoChunk +from ..fileio.adm import timing_fixes +import logging +from .error_handler import error_handler import warnings -from ..fileio.adm.exceptions import AdmUnknownAttribute -def handle_strict(args): - if args.strict: - warnings.filterwarnings("error", category=AdmUnknownAttribute) +logging.basicConfig() +logger = logging.getLogger("ear") @attrs @@ -195,7 +196,21 @@ def run(self, input_file, output_file): output_monitor = PeakMonitor(n_channels) - with openBw64Adm(input_file, self.enable_block_duration_fix) as infile: + with openBw64Adm(input_file) as infile: + if infile.adm is None: + raise Exception( + f"error: {input_file!r} does not have ADM metadata (missing 'chna' chunk)" + ) + + if version_at_least(infile.adm.version, 2): + warnings.warn( + f"rendering of files with version {infile.adm.version} is not standardised" + ) + infile.adm.validate() + timing_fixes.check_blockFormat_timings( + infile.adm, fix=self.enable_block_duration_fix + ) + formatInfo = FormatInfoChunk(formatTag=1, channelCount=n_channels, sampleRate=infile.sampleRate, @@ -207,10 +222,10 @@ def run(self, input_file, output_file): output_monitor.warn_overloaded() if self.fail_on_overload and output_monitor.has_overloaded(): - sys.exit("error: output overloaded") + raise Exception("error: output overloaded") -def parse_command_line(): +def make_parser(): parser = argparse.ArgumentParser(description="EBU ADM renderer") parser.add_argument("-d", "--debug", @@ -219,13 +234,18 @@ def parse_command_line(): OfflineRenderDriver.add_args(parser) - parser.add_argument("input_file") - parser.add_argument("output_file") + parser.add_argument("input_file", help="BW64 file with CHNA and AXML (optional) chunks") + parser.add_argument("output_file", help="BW64 output file") parser.add_argument("--strict", help="treat unknown ADM attributes as errors", action="store_true") + return parser + + +def parse_command_line(): + parser = make_parser() args = parser.parse_args() return args @@ -233,16 +253,8 @@ def parse_command_line(): def main(): args = parse_command_line() - handle_strict(args) - - try: + with error_handler(logger, debug=args.debug, strict=args.strict): OfflineRenderDriver.from_args(args).run(args.input_file, args.output_file) - except Exception as error: - if args.debug: - raise - else: - sys.exit(str(error)) - if __name__ == "__main__": main() diff --git a/ear/cmdline/test/test_utils.py b/ear/cmdline/test/test_utils.py index 1adbe278..45fbf6c3 100644 --- a/ear/cmdline/test/test_utils.py +++ b/ear/cmdline/test/test_utils.py @@ -1,6 +1,9 @@ import numpy as np +import pytest import subprocess +import sys from ...test.test_integrate import bwf_file +from ...fileio import openBw64 def test_dump_axml(tmpdir): @@ -12,13 +15,12 @@ def test_dump_axml(tmpdir): with openBw64(filename, 'w', axml=axml) as outfile: outfile.write(np.zeros((1000, 1))) - assert subprocess.check_output(["ear-utils", "dump_axml", filename]) == axml + assert subprocess.check_output(["ear-utils", "-d", "dump_axml", filename]) == axml def test_dump_chna(tmpdir): filename = str(tmpdir / 'test_chna.wav') - from ...fileio import openBw64 from ...fileio.bw64.chunks import ChnaChunk, AudioID chna = ChnaChunk() @@ -29,11 +31,11 @@ def test_dump_chna(tmpdir): outfile.write(np.zeros((1000, 1))) expected = str(audioID) + "\n" - output = subprocess.check_output(["ear-utils", "dump_chna", filename]).decode("utf8") + output = subprocess.check_output(["ear-utils", "-d", "dump_chna", filename]).decode("utf8") assert output == expected expected = chna.asByteArray()[8:] # strip marker and size - output = subprocess.check_output(["ear-utils", "dump_chna", "--binary", filename]) + output = subprocess.check_output(["ear-utils", "-d", "dump_chna", "--binary", filename]) assert output == expected @@ -42,8 +44,6 @@ def test_replace_axml_basic(tmpdir): filename_axml = str(tmpdir / 'test_replace_axml_new_axml.xml') filename_out = str(tmpdir / 'test_replace_axml_out.wav') - from ...fileio import openBw64 - axml_in = b'axml' axml_out = b'axml2' @@ -53,7 +53,7 @@ def test_replace_axml_basic(tmpdir): with openBw64(filename_in, 'w', axml=axml_in) as outfile: outfile.write(np.zeros((1000, 1))) - assert subprocess.check_call(["ear-utils", "replace_axml", "-a", filename_axml, + assert subprocess.check_call(["ear-utils", "-d", "replace_axml", "-a", filename_axml, filename_in, filename_out]) == 0 with openBw64(filename_out, 'r') as infile: @@ -64,7 +64,6 @@ def test_replace_axml_regenerate(tmpdir): filename_axml = str(tmpdir / 'test_replace_axml_new_axml.xml') filename_out = str(tmpdir / 'test_replace_axml_out.wav') - from ...fileio import openBw64 with openBw64(bwf_file, 'r') as f: axml_a = f.axml assert f.chna.audioIDs[-1].trackIndex == 4 @@ -74,9 +73,86 @@ def test_replace_axml_regenerate(tmpdir): with open(filename_axml, 'wb') as f: f.write(axml_out) - assert subprocess.check_call(["ear-utils", "replace_axml", "-a", filename_axml, "--gen-chna", + assert subprocess.check_call(["ear-utils", "-d", "replace_axml", "-a", filename_axml, "--gen-chna", bwf_file, filename_out]) == 0 with openBw64(filename_out, 'r') as f: assert f.axml == axml_out assert f.chna.audioIDs[-1].trackIndex == 6 + + +@pytest.mark.xfail( + sys.version_info < (3, 6), + reason="output may vary on platforms where dictionaries are not ordered", +) +def test_regenerate(tmpdir): + bwf_out = str(tmpdir / "test_regenerate_out.wav") + + args = [ + "ear-utils", + "-d", + "regenerate", + "--enable-block-duration-fix", + bwf_file, + bwf_out, + ] + assert subprocess.check_call(args) == 0 + + assert open(bwf_out, "rb").read() == open(bwf_file, "rb").read() + + +@pytest.mark.xfail( + sys.version_info < (3, 6), + reason="output may vary on platforms where dictionaries are not ordered", +) +def test_regenerate_version(tmpdir): + bwf_out = str(tmpdir / "test_regenerate_v2_out.wav") + bwf_expected = str(tmpdir / "test_regenerate_out_expected.wav") + + with openBw64(bwf_file, "r") as f_in: + # consider saving the whole axml if there are more changes + axml = f_in.axml.replace( + b">> cart(0, 0, 1) array([0., 1., 0.]) >>> cart(90, 0, 1).round(6) array([-1., 0., 0.]) >>> cart(0, 90, 1).round(6) array([0., 0., 1.]) - - # inputs are broadcast together... + >>> # inputs are broadcast together... >>> cart([0, 90], 0, 1).round(6) array([[ 0., 1., 0.], [-1., 0., 0.]]) - - # ... along the given axis + >>> # ... along the given axis >>> cart([0, 90], 0, 1, axis=0).round(6) array([[ 0., -1.], [ 1., 0.], @@ -100,10 +112,8 @@ def azimuth(positions, axis=-1): >>> azimuth([0, 1, 0]).round(0).astype(int) 0 - >>> azimuth([[1, 0, 0], [0, 1, 0]]).round(0).astype(int) array([-90, 0]) - >>> azimuth([[1, 0], [0, 1], [0, 0]], axis=0).round(0).astype(int) array([-90, 0]) """ @@ -112,9 +122,9 @@ def azimuth(positions, axis=-1): def elevation(positions, axis=-1): - """Get the azimuth in degrees from ADM-format Cartesian positions. + """Get the elevation in degrees from ADM-format Cartesian positions. - See `azimuth`. + See :func:`azimuth`. """ x, y, z = np.moveaxis(positions, axis, 0) radius = np.hypot(x, y) @@ -124,7 +134,7 @@ def elevation(positions, axis=-1): def distance(positions, axis=-1): """Get the distance from ADM-format Cartesian positions. - See `azimuth`. + See :func:`azimuth`. """ return np.linalg.norm(positions, axis=axis) @@ -135,9 +145,15 @@ class PolarPositionMixin(object): __slots__ = () def as_cartesian_array(self): + """Get the position as a Cartesian array. + + Returns: + np.array of shape (3,): equivalent X, Y and Z coordinates + """ return cart(self.azimuth, self.elevation, self.distance) - def as_cartesian_position(self): + def as_cartesian_position(self) -> "CartesianPosition": + """Get the equivalent cartesian position.""" x, y, z = self.as_cartesian_array() return CartesianPosition(x, y, z) @@ -152,9 +168,15 @@ class CartesianPositionMixin(object): __slots__ = () def as_cartesian_array(self): + """Get the position as a Cartesian array. + + Returns: + np.array of shape (3,): equivalent X, Y and Z coordinates + """ return np.array([self.X, self.Y, self.Z]) - def as_polar_position(self): + def as_polar_position(self) -> "PolarPosition": + """Get the equivalent cartesian position.""" cart_array = self.as_cartesian_array() return PolarPosition(azimuth(cart_array), elevation(cart_array), distance(cart_array)) @@ -166,7 +188,17 @@ class Position(object): @attrs(slots=True) class PolarPosition(Position, PolarPositionMixin): - """A 3D position represented in ADM-format polar coordinates.""" + """A 3D position represented in ADM-format polar coordinates. + + Attributes: + azimuth (float): anti-clockwise azimuth in degrees, measured from the + front + elevation (float): elevation in degrees, measured upwards from the + equator + distance (float): distance relative to the audioPackFormat + absoluteDistance parameter + """ + azimuth = attrib(converter=float, validator=validate_range(-180, 180)) elevation = attrib(converter=float, validator=validate_range(-90, 90)) distance = attrib(converter=float, validator=validate_range(0, float('inf')), @@ -175,7 +207,14 @@ class PolarPosition(Position, PolarPositionMixin): @attrs(slots=True) class CartesianPosition(Position, CartesianPositionMixin): - """A 3D position represented in ADM-format Cartesian coordinates.""" + """A 3D position represented in ADM-format Cartesian coordinates. + + Attributes: + X (float): left-to-right position, from -1 to 1 + Y (float): back-to-front position, from -1 to 1 + Z (float): bottom-to-top position, from -1 to 1 + """ + X = attrib(converter=float) Y = attrib(converter=float) Z = attrib(converter=float) @@ -183,16 +222,38 @@ class CartesianPosition(Position, CartesianPositionMixin): @attrs(slots=True, frozen=True) class CartesianScreen(object): - aspectRatio = attrib(validator=instance_of(float)) + """ADM screen representation using Cartesian coordinates. + + This is used to represent the audioProgrammeReferenceScreen, as well as the + screen position in the reproduction room. + + Attributes: + aspectRatio (float): aspect ratio + centrePosition (CartesianPosition): screenCentrePosition element + widthX (float): screenWidth X attribute + """ + + aspectRatio = attrib(validator=finite_float()) centrePosition = attrib(validator=instance_of(CartesianPosition)) - widthX = attrib(validator=instance_of(float)) + widthX = attrib(validator=finite_float()) @attrs(slots=True, frozen=True) class PolarScreen(object): - aspectRatio = attrib(validator=instance_of(float)) + """ADM screen representation using Cartesian coordinates. + + This is used to represent the audioProgrammeReferenceScreen, as well as the + screen position in the reproduction room. + + Attributes: + aspectRatio (float): aspect ratio + centrePosition (PolarPosition): screenCentrePosition element + widthX (float): screenWidth azimuth attribute + """ + + aspectRatio = attrib(validator=finite_float()) centrePosition = attrib(validator=instance_of(PolarPosition)) - widthAzimuth = attrib(validator=instance_of(float)) + widthAzimuth = attrib(validator=finite_float()) default_screen = PolarScreen(aspectRatio=1.78, @@ -201,3 +262,4 @@ class PolarScreen(object): elevation=0.0, distance=1.0), widthAzimuth=58.0) +"""The default screen position, size and shape.""" diff --git a/ear/compatibility.py b/ear/compatibility.py index cc75e842..99ead007 100644 --- a/ear/compatibility.py +++ b/ear/compatibility.py @@ -1,10 +1,38 @@ -import sys -from six import PY2 - - def write_bytes_to_stdout(b): """Write bytes (python 3) or string (python 2) to stdout.""" + from six import PY2 + import sys + if PY2: return sys.stdout.write(b) else: return sys.stdout.buffer.write(b) + + +def load_yaml(stream): + """load yaml from a file-like object; used to make it easier to cater to + API changes in the yaml library + """ + import yaml + + return yaml.load(stream, Loader=yaml.Loader) + + +def dump_yaml_str(yaml_obj): + """stringify some yaml""" + import yaml + + return yaml.dump(yaml_obj) + + +def test_yaml(): + from io import StringIO + + obj = {"some": "yaml"} + + yaml_str = dump_yaml_str(obj) + + f = StringIO(yaml_str) + parsed_obj = load_yaml(f) + + assert parsed_obj == obj diff --git a/ear/core/allocentric.py b/ear/core/allocentric.py index 3b5a12d9..c1a85559 100644 --- a/ear/core/allocentric.py +++ b/ear/core/allocentric.py @@ -1,14 +1,16 @@ import numpy as np from .objectbased.conversion import point_polar_to_cart +from ..compatibility import load_yaml def _load_allo_positions(): - import pkg_resources - from ruamel import yaml + import importlib_resources fname = "data/allo_positions.yaml" - with pkg_resources.resource_stream(__name__, fname) as layouts_file: - return yaml.safe_load(layouts_file) + path = importlib_resources.files("ear.core") / fname + + with path.open() as layouts_file: + return load_yaml(layouts_file) _allo_positions = _load_allo_positions() diff --git a/ear/core/bs2051.py b/ear/core/bs2051.py index 0933e3f0..41539bcc 100644 --- a/ear/core/bs2051.py +++ b/ear/core/bs2051.py @@ -1,5 +1,5 @@ -import pkg_resources -from ruamel import yaml +import importlib_resources +from ..compatibility import load_yaml from .geom import PolarPosition from .layout import Channel, Layout @@ -28,8 +28,10 @@ def _dict_to_layout(d): def _load_layouts(): fname = "data/2051_layouts.yaml" - with pkg_resources.resource_stream(__name__, fname) as layouts_file: - layouts_data = yaml.safe_load(layouts_file) + path = importlib_resources.files("ear.core") / fname + + with path.open() as layouts_file: + layouts_data = load_yaml(layouts_file) layouts = list(map(_dict_to_layout, layouts_data)) @@ -51,10 +53,10 @@ def get_layout(name): """Get data for a layout specified in BS.2051. Parameters: - name: Full layout name, e.g. "4+5+0" + name (str): Full layout name, e.g. "4+5+0" Returns: - Layout object representing the layout; real speaker positions are set + Layout: object representing the layout; real speaker positions are set to the nominal positions. """ if name not in layout_names: diff --git a/ear/core/direct_speakers/panner.py b/ear/core/direct_speakers/panner.py index 62fa4b0b..c1c35b22 100644 --- a/ear/core/direct_speakers/panner.py +++ b/ear/core/direct_speakers/panner.py @@ -6,7 +6,7 @@ from ..geom import inside_angle_range from .. import point_source from .. import allocentric -from ..renderer_common import is_lfe +from ..renderer_common import get_object_gain, is_lfe from ...options import OptionsHandler, SubOptions, Option from ..screen_edge_lock import ScreenEdgeLockHandler from ...fileio.adm.elements import DirectSpeakerCartesianPosition, DirectSpeakerPolarPosition @@ -251,6 +251,7 @@ def __init__(self, layout, point_source_opts={}, additional_substitutions={}): self._screen_edge_lock_handler = ScreenEdgeLockHandler(self.layout.screen, layout) self.pvs = np.eye(self.n_channels) + self.pvs.flags.writeable = False self.substitutions = { "LFE": "LFE1", @@ -388,8 +389,19 @@ def apply_screen_edge_lock(self, position): bounded_Z=evolve(position.bounded_Z, value=Z)) def handle(self, type_metadata): + pvs = self._handle_without_gain(type_metadata) + return pvs * type_metadata.block_format.gain * get_object_gain(type_metadata) + + def _handle_without_gain(self, type_metadata): tol = 1e-5 + if type_metadata.extra_data.object_positionOffset is not None: + # it's not clear how positionOffset would work, and wouldn't have + # any effect most of the time if a static down/upmix matrix or a + # speakerLabel is used. there isn't an obvious use-case for it, so + # disallow it for now + raise ValueError("positionOffset is not supported with DirectSpeakers") + block_format = type_metadata.block_format if isinstance(block_format.position, DirectSpeakerPolarPosition): @@ -403,6 +415,14 @@ def handle(self, type_metadata): is_lfe_channel = self.is_lfe_channel(type_metadata) + if not is_lfe_channel and any("LFE" in l.upper() for l in block_format.speakerLabel): + warnings.warn( + "block {bf.id} not being treated as LFE, but has 'LFE' in a speakerLabel; " + "use an ITU speakerLabel or audioChannelFormat frequency element instead".format( + bf=block_format + ) + ) + if type_metadata.audioPackFormats is not None: pack = type_metadata.audioPackFormats[-1] if pack.is_common_definition and pack.id in itu_packs: diff --git a/ear/core/direct_speakers/test/test_panner.py b/ear/core/direct_speakers/test/test_panner.py index 2edad653..ea92b7ab 100644 --- a/ear/core/direct_speakers/test/test_panner.py +++ b/ear/core/direct_speakers/test/test_panner.py @@ -5,13 +5,22 @@ from ...metadata_input import DirectSpeakersTypeMetadata, ExtraData from ....fileio.adm.adm import ADM from ....fileio.adm.common_definitions import load_common_definitions -from ....fileio.adm.elements import AudioBlockFormatDirectSpeakers, BoundCoordinate, Frequency, ScreenEdgeLock -from ....fileio.adm.elements import DirectSpeakerCartesianPosition, DirectSpeakerPolarPosition +from ....fileio.adm.elements import ( + AudioBlockFormatDirectSpeakers, + BoundCoordinate, + Frequency, + PolarPositionOffset, + ScreenEdgeLock, +) +from ....fileio.adm.elements import ( + DirectSpeakerCartesianPosition, + DirectSpeakerPolarPosition, +) from ... import bs2051 from ...geom import cart -def tm_with_labels(labels, lfe_freq=False): +def tm_with_labels(labels, lfe_freq=False, **kwargs): """Get a DirectSpeakersTypeMetadata with the given speakerLabels and default position.""" return DirectSpeakersTypeMetadata( @@ -21,7 +30,8 @@ def tm_with_labels(labels, lfe_freq=False): bounded_elevation=BoundCoordinate(0.0), bounded_distance=BoundCoordinate(1.0), ), - speakerLabel=labels), + speakerLabel=labels, + **kwargs), extra_data=ExtraData( channel_frequency=Frequency( lowPass=120.0 if lfe_freq else None)) @@ -104,7 +114,7 @@ def test_lfe(): npt.assert_allclose(p.handle(tm_with_labels([], lfe_freq=True)), direct_pv(layout, "LFE1")) # check warnings with mismatch between label and frequency elements - with pytest.warns(None) as record: + with pytest.warns(UserWarning) as record: # using just labels for lfe_option in ["LFE", "LFE1", "LFEL"]: npt.assert_allclose(p.handle(tm_with_labels([lfe_option])), direct_pv(layout, "LFE1")) @@ -118,6 +128,22 @@ def test_lfe(): for w in record) +def test_incorrect_lfe_label_warning(): + layout = bs2051.get_layout("4+5+0") + p = DirectSpeakersPanner(layout) + + bf_id = "AB_00010004_00000001" + msg = ( + "block {bf_id} not being treated as LFE, but has 'LFE' in a speakerLabel; " + "use an ITU speakerLabel or audioChannelFormat frequency element instead".format( + bf_id=bf_id + ) + ) + with pytest.warns(UserWarning, match=msg): + npt.assert_allclose(p.handle(tm_with_labels(["some other Lfe"], id=bf_id)), + direct_pv(layout, "M+000")) + + def test_mapping(): layout = bs2051.get_layout("4+5+0") p = DirectSpeakersPanner(layout) @@ -362,3 +388,32 @@ def test_screen_edge_lock_cart(): )) npt.assert_allclose(p.handle(DirectSpeakersTypeMetadata(bf)), direct_pv(layout, "M-030")) + + +def test_gain(): + layout = bs2051.get_layout("4+5+0") + p = DirectSpeakersPanner(layout) + + tm = tm_with_labels(["M+000"], gain=0.5) + npt.assert_allclose(p.handle(tm), direct_pv(layout, "M+000") * 0.5) + # make sure that internal state (pvs) is not modified + npt.assert_allclose(p.handle(tm), direct_pv(layout, "M+000") * 0.5) + + tm.extra_data.object_gain = 0.5 + npt.assert_allclose(p.handle(tm), direct_pv(layout, "M+000") * 0.25) + + tm.extra_data.object_mute = True + npt.assert_allclose(p.handle(tm), np.zeros(len(layout.channels))) + + +def test_object_positionOffset(): + layout = bs2051.get_layout("4+5+0") + p = DirectSpeakersPanner(layout) + + tm = tm_with_labels(["M+000"]) + tm.extra_data.object_positionOffset = PolarPositionOffset(azimuth=30.0) + + with pytest.raises( + ValueError, match="positionOffset is not supported with DirectSpeakers" + ): + p.handle(tm) diff --git a/ear/core/hoa.py b/ear/core/hoa.py index 967f8664..549201d1 100644 --- a/ear/core/hoa.py +++ b/ear/core/hoa.py @@ -2,7 +2,6 @@ import numpy as np import scipy.special from scipy.special import eval_legendre, legendre -from scipy.optimize import fsolve def fact(n): @@ -11,7 +10,7 @@ def fact(n): def Alegendre(n, m, x): - """Associated Legendre function P_n^m(x), ommitting the the (-1)^m + """Associated Legendre function P_n^m(x), ommitting the (-1)^m Condon-Shortley phase term.""" return (-1.0)**m * scipy.special.lpmv(m, n, x) @@ -131,8 +130,11 @@ def allrad_design(points, panning_func, n, m, norm=norm_SN3D, G_virt=None): def load_points(fname="data/Design_5200_100_random.dat"): """Load a spherical t-design from a file.""" # see data/README.md - import pkg_resources - with pkg_resources.resource_stream(__name__, fname) as points_file: + import importlib_resources + + path = importlib_resources.files("ear.core") / fname + + with path.open() as points_file: data = np.loadtxt(points_file) if data.shape[1] == 2: @@ -207,6 +209,7 @@ def MultipleImpResp(Orders, r1, r2, Fs): def MaxRECoefficients(Nmax): """rE computation (maximum zero of the Nmax+1 degree legendre polynomial)""" + from scipy.optimize import fsolve t = np.arange(0.5, 1.0, 0.05) # Sampling the interval [0.5,1] # Search the highest root of the N+1 degree legendre polynom in the interval [0.5,1]. This value is the highest rE reachable. rE = np.max(fsolve(legendre(Nmax+1), t)) diff --git a/ear/core/importance.py b/ear/core/importance.py index df5c4eff..64cf53f5 100644 --- a/ear/core/importance.py +++ b/ear/core/importance.py @@ -1,4 +1,5 @@ -from .metadata_input import MetadataSource, HOARenderingItem, ObjectRenderingItem +from .metadata_input import MetadataSource, HOARenderingItem +from attr import evolve def filter_by_importance(rendering_items, @@ -17,6 +18,7 @@ def filter_by_importance(rendering_items, Yields: RenderingItem """ f = mute_audioBlockFormat_by_importance(rendering_items, threshold) + f = mute_hoa_channels_by_importance(f, threshold) f = filter_audioObject_by_importance(f, threshold) f = filter_audioPackFormat_by_importance(f, threshold) return f @@ -67,34 +69,63 @@ def filter_audioPackFormat_by_importance(rendering_items, threshold): yield item -class MetadataSourceImportanceFilter(MetadataSource): - """A Metadata source adapter to change block formats if their importance is below a given threshold. +class MetadataSourceMap(MetadataSource): + """A metadata source which yields the blocks from input_source after + applying callback to them.""" - The intended result of "muting" the rendering item during this block format - is emulated by setting its gain to zero and disabling any interpolation by - activating the jumpPosition flag. - - Note: This MetadataSource can only be used for MetadataSources that - generate `ObjectTypeMetadata`. - """ - def __init__(self, adapted_source, threshold): - super(MetadataSourceImportanceFilter, self).__init__() - self._adapted = adapted_source - self._threshold = threshold + def __init__(self, input_source, callback): + super(MetadataSourceMap, self).__init__() + self._input_source = input_source + self._callback = callback def get_next_block(self): - block = self._adapted.get_next_block() + block = self._input_source.get_next_block() if block is None: return None - if block.block_format.importance < self._threshold: - block.block_format.gain = 0 - return block + return self._callback(block) def mute_audioBlockFormat_by_importance(rendering_items, threshold): - """Adapt rendering items of type `ObjectRenderingItem` to emulate block format importance handling + """Adapt non-HOA rendering items to emulate block format importance handling + + This installs an `MetadataSourceMap` which sets gains to 0 if the block + importance is less than the given threshold. + + Parameters: + rendering_items (iterable of RenderingItems): RenderingItems to adapt + threshold (int): importance threshold + + Yields: RenderingItem + """ + + def mute_unimportant_block(type_metadata): + if type_metadata.block_format.importance < threshold: + return evolve( + type_metadata, block_format=evolve(type_metadata.block_format, gain=0.0) + ) + else: + return type_metadata + + for item in rendering_items: + if isinstance(item, HOARenderingItem): + yield item + else: + yield evolve( + item, + metadata_source=MetadataSourceMap( + item.metadata_source, mute_unimportant_block + ), + ) + - This installs an `MetadataSourceImportanceFilter` with the given threshold +def mute_hoa_channels_by_importance(rendering_items, threshold): + """Adapt HOA rendering items to emulate block format importance handling + + This installs a `MetadataSourceMap` which sets the gain to zero if the + block importance is less than the given threshold. This operates + independently for each channel, so can reduce the HOA order (or make a mess + if the importances are not structured so that higher orders are discarded + first). Parameters: rendering_items (iterable of RenderingItems): RenderingItems to adapt @@ -102,7 +133,25 @@ def mute_audioBlockFormat_by_importance(rendering_items, threshold): Yields: RenderingItem """ + def mute_unimportant_channels(type_metadata): + if min(type_metadata.importances) < threshold: + new_gains = [ + 0.0 if importance < threshold else gain + for (gain, importance) in zip( + type_metadata.gains, type_metadata.importances + ) + ] + return evolve(type_metadata, gains=new_gains) + else: + return type_metadata + for item in rendering_items: - if isinstance(item, ObjectRenderingItem): - item.metadata_source = MetadataSourceImportanceFilter(adapted_source=item.metadata_source, threshold=threshold) - yield item + if isinstance(item, HOARenderingItem): + yield evolve( + item, + metadata_source=MetadataSourceMap( + item.metadata_source, mute_unimportant_channels + ), + ) + else: + yield item diff --git a/ear/core/layout.py b/ear/core/layout.py index 5e4b964d..7e41f84e 100644 --- a/ear/core/layout.py +++ b/ear/core/layout.py @@ -5,6 +5,7 @@ import sys from .geom import CartesianPosition, PolarPosition, inside_angle_range from ..common import list_of, CartesianScreen, PolarScreen, default_screen +from ..compatibility import load_yaml def to_polar_position(pp): @@ -83,7 +84,14 @@ def check_position(self, callback=_print_warning): @attrs(frozen=True, slots=True) class Layout(object): - """Representation of a loudspeaker layout, with a name and a list of channels.""" + """Representation of a loudspeaker layout, with a name and a list of channels. + + Attributes: + name (str): layout name + channels (list[Channel]): list of channels in the layout + screen (Optional[Union[CartesianScreen, PolarScreen]]): screen + information to use for screen-related content + """ name = attrib() channels = attrib() screen = attrib(validator=optional(instance_of((CartesianScreen, PolarScreen))), @@ -106,7 +114,7 @@ def nominal_positions(self): @property def without_lfe(self): - """The same layout, without LFE channels.""" + """Layout: The same layout, without LFE channels.""" return evolve(self, channels=[channel for channel in self.channels if not channel.is_lfe]) @property @@ -116,7 +124,7 @@ def is_lfe(self): @property def channel_names(self): - """The channel names for each channel.""" + """list[str]: The channel names for each channel.""" return [channel.name for channel in self.channels] @property @@ -135,7 +143,7 @@ def with_speakers(self, speakers): speaker list. Parameters: - speakers (list of Speaker): list of speakers to map to. + speakers (list[Speaker]): list of speakers to map to. Returns: - A new Layout object with the same channels but with positions @@ -220,10 +228,10 @@ class Speaker(object): data required to use the renderer in a given listening room. Attributes: - channel: 0-based channel number - names: list of BS.2051 channel names this speaker should handle. - polar_position: a PolarPosition object, or None - gain_linear: linear gain to apply to this output channel + channel (int): 0-based channel number + names (list[str]): list of BS.2051 channel names this speaker should handle. + polar_position (Optional[PolarPosition]): real loudspeaker position, if known + gain_linear (float): linear gain to apply to this output channel """ channel = attrib() names = attrib() @@ -237,8 +245,9 @@ class RealLayout(object): standard layout will be mapped. Attributes: - speakers: all speakers that could be used - screen: screen information to use for screen-related content + speakers (Optional[list[Speaker]]): all speakers that could be used + screen (Optional[Union[CartesianScreen, PolarScreen]]): screen + information to use for screen-related content """ speakers = attrib(default=None, validator=optional(list_of(Speaker))) screen = attrib(validator=optional(instance_of((CartesianScreen, PolarScreen))), @@ -249,50 +258,68 @@ def load_real_layout(fileobj): """Load a real layout from a yaml file. The format is either a list of objects representing speakers, or an object - with optional keys "speakers" (which contains a list of objects - representing speakers) and "screen" (which contains an object representing + with optional keys ``speakers`` (which contains a list of objects + representing speakers) and ``screen`` (which contains an object representing the screen). Objects representing speakers may have the following keys: - channel: 0-based channel number, required - names: list (or a single string) of BS.2051 channel names that this - speaker should handle, i.e. like "M+000" or ["U+180", "UH+180"] - position: optional associative array containing the real loudspeaker position, with keys: - az: anti-clockwise azimuth in degrees - el: elevation in degrees - r: radius in metres - gain_linear: optional linear gain to be applied to this channel + channel + 0-based channel number, required + names + list (or a single string) of BS.2051 channel names that this speaker + should handle, i.e. like ``"M+000"`` or ``["U+180", "UH+180"]`` + position + optional associative array containing the real loudspeaker position, with keys: + + az + anti-clockwise azimuth in degrees + el + elevation in degrees + r + radius in metres + gain_linear + optional linear gain to be applied to this channel A polar screen may be represented with the following keys: - type: "polar", required - aspectRatio: aspect ratio of the screen - centrePosition: object representing the centre position of the screen: - az: anti-clockwise azimuth in degrees - el: elevation in degrees - r: radius in metres - widthAzimuth: width of the screen in degrees + type + ``"polar"``, required + aspectRatio + aspect ratio of the screen + centrePosition + object representing the centre position of the screen: + + az + anti-clockwise azimuth in degrees + el + elevation in degrees + r + radius in metres + widthAzimuth + width of the screen in degrees A Cartesian screen may be represented with the following keys: - type: "cart", required - aspectRatio: aspect ratio of the screen - centrePosition: object representing the centre position of the screen - containing X, Y and Z coordinates - widthX: width of the screen along the Cartesian X axis + type + ``"cart"``, required + aspectRatio + aspect ratio of the screen + centrePosition + object representing the centre position of the screen containing X, Y and + Z coordinates + widthX + width of the screen along the Cartesian X axis If the screen is omitted, the default screen is used; if the screen is specified but null, then screen-related processing will not be applied. Parameters: - file: a file-like object to read yaml from + fileobj: a file-like object to read yaml from Returns: - list of Speaker + RealLayout: real layout information """ - from ruamel import yaml - def parse_yaml_polar_position(position): if set(position.keys()) == set(["az", "el", "r"]): return PolarPosition(position["az"], position["el"], position["r"]) @@ -344,7 +371,7 @@ def parse_yaml_screen(yaml_screen): else: raise Exception("Unknown screen type: {!r}".format(screen_type)) - yaml_info = yaml.safe_load(fileobj) + yaml_info = load_yaml(fileobj) if isinstance(yaml_info, dict): yaml_info_dict = yaml_info diff --git a/ear/core/metadata_input.py b/ear/core/metadata_input.py index 3f4dd0ef..b23324a5 100644 --- a/ear/core/metadata_input.py +++ b/ear/core/metadata_input.py @@ -1,21 +1,28 @@ from attr import attrib, attrs, Factory from attr.validators import instance_of, optional from fractions import Fraction -from ..common import list_of, default_screen -from ..fileio.adm.elements import (AudioProgramme, AudioContent, AudioObject, AudioPackFormat, - AudioChannelFormat, AudioBlockFormatObjects, AudioBlockFormatDirectSpeakers, - MatrixCoefficient, Frequency) +from typing import Optional +from ..common import list_of, default_screen, finite_float +from ..fileio.adm.elements import ( + AudioProgramme, + AudioContent, + AudioObject, + AudioPackFormat, + AudioChannelFormat, + AudioBlockFormatObjects, + AudioBlockFormatDirectSpeakers, + MatrixCoefficient, + Frequency, + PositionOffset, +) +from ..fileio.adm.elements.version import version_validator class MetadataSource(object): """A source of metadata for some input channels.""" - def get_next_block(self): - """Get the next metadata block, if one is available. - - Returns: - TypeMetadata - """ + def get_next_block(self) -> Optional["TypeMetadata"]: + """Get the next metadata block, if one is available.""" raise NotImplementedError() @@ -35,7 +42,7 @@ def get_next_block(self): @attrs(slots=True) class TypeMetadata(object): - """Base class for *TypeMetadata classes; these should represent all the + """Base class for \\*TypeMetadata classes; these should represent all the parameters needed to render some set of audio channels within some time bounds. """ @@ -43,8 +50,8 @@ class TypeMetadata(object): @attrs(slots=True) class RenderingItem(object): - """Base class for *RenderingItem classes; these should represent an item to - be rendered, combining a MetadataSource that produces a sequence of + """Base class for \\*RenderingItem classes; these should represent an item + to be rendered, combining a MetadataSource that produces a sequence of TypeMetadata objects, and some indices into the tracks that this metadata applies to. """ @@ -55,20 +62,42 @@ class ExtraData(object): """Common metadata from outside the ADM block format. Attributes: - object_start (Fraction or None): Start time of audioObject. - object_duration (Fraction or None): Duration of audioObject. + object_start (fractions.Fraction or None): Start time of audioObject. + object_duration (fractions.Fraction or None): Duration of audioObject. reference_screen (CartesianScreen or PolarScreen): Reference screen from audioProgramme. - channel_frequency (Frequency): Frequency information from audioChannel. + channel_frequency (Frequency): Frequency information from audioChannelFormat. + pack_absoluteDistance (float or None): absoluteDistance parameter from audioPackFormat. + object_gain (float): gain from audioObject or alternativeValueSet + object_mute (bool): mute from audioObject or alternativeValueSet + object_positionOffset (Optional[PositionOffset]): positionOffset from audioObject or alternativeValueSet + document_version (Version): version from AXML document """ object_start = attrib(validator=optional(instance_of(Fraction)), default=None) object_duration = attrib(validator=optional(instance_of(Fraction)), default=None) reference_screen = attrib(default=default_screen) channel_frequency = attrib(validator=instance_of(Frequency), default=Factory(Frequency)) + pack_absoluteDistance = attrib(validator=optional(finite_float()), default=None) + + object_gain = attrib(validator=finite_float(), default=1.0) + object_mute = attrib(validator=instance_of(bool), default=False) + object_positionOffset = attrib( + validator=optional(instance_of(PositionOffset)), default=None + ) + + document_version = attrib(validator=version_validator, default=None) @attrs(slots=True) class ADMPath(object): - """Pointers to the ADM objects which a rendering item is derived from.""" + """Pointers to the ADM objects which a rendering item is derived from. + + Attributes: + audioProgramme (Optional[AudioProgramme]) + audioContent (Optional[AudioContent]) + audioObjects (Optional[list[AudioObject]]) + audioPackFormats (Optional[list[AudioPackFormat]]) + audioChannelFormat (Optional[list[AudioChannelFormat]]) + """ audioProgramme = attrib(validator=optional(instance_of(AudioProgramme)), default=None) audioContent = attrib(validator=optional(instance_of(AudioContent)), default=None) audioObjects = attrib(validator=optional(list_of(AudioObject)), default=None) @@ -77,22 +106,26 @@ class ADMPath(object): @property def first_audioObject(self): - """The first audioObject of this track in the chain, or None""" + """Optional[AudioObject]: The first audioObject of this track in the + chain, or None""" return self.audioObjects[0] if self.audioObjects is not None else None @property def last_audioObject(self): - """The last audioObject of this track in the chain, or None""" + """Optional[AudioObject]: The last audioObject of this track in the + chain, or None""" return self.audioObjects[-1] if self.audioObjects is not None else None @property def first_audioPackFormat(self): - """The first audioPackFormat of this track in the chain, or None""" + """Optional[AudioPackFormat]: The first audioPackFormat of this track + in the chain, or None""" return self.audioPackFormats[0] if self.audioPackFormats is not None else None @property def last_audioPackFormat(self): - """The last audioPackFormat of this track in the chain, or None""" + """Optional[AudioPackFormat]: The last audioPackFormat of this track in + the chain, or None""" return self.audioPackFormats[-1] if self.audioPackFormats is not None else None @@ -157,6 +190,19 @@ class MixTrackSpec(TrackSpec): input_tracks = attrib(validator=list_of(TrackSpec)) +@attrs(slots=True) +class GainTrackSpec(TrackSpec): + """Track that applies a gain to a single input track + + Attributes: + input_track (TrackSpec): track spec to obtain samples from + gain (float): gain to apply + """ + + input_track = attrib(validator=instance_of(TrackSpec)) + gain = attrib(validator=finite_float()) + + ################################################# # type metadata and rendering items for each type ################################################# @@ -200,7 +246,7 @@ class DirectSpeakersTypeMetadata(TypeMetadata): """TypeMetadata for typeDefinition="DirectSpeakers" Attributes: - block_format (AudioBlockFormatDirectSpeakerss): Block format. + block_format (AudioBlockFormatDirectSpeakers): Block format. extra_data (ExtraData): Extra parameters from outside block format. """ block_format = attrib(validator=instance_of(AudioBlockFormatDirectSpeakers)) @@ -238,36 +284,51 @@ class HOATypeMetadata(TypeMetadata): normalization (str): Normalization for all channels. nfcRefDist (float or None): NFC Reference distance for all channels. screenRef (bool): Are these channels screen related? - rtime (Fraction or None): Start time of block. - duration (Fraction or None): Duration of block. + rtime (fractions.Fraction or None): Start time of block. + duration (fractions.Fraction or None): Duration of block. extra_data (ExtraData): Info from object and channels for all channels. + gains (list of float): Gain for each input channel; defaults to 1. + importances (list of int): Importance for each input channel; defaults to 10. """ orders = attrib(validator=list_of(int)) degrees = attrib(validator=list_of(int)) normalization = attrib() - nfcRefDist = attrib(validator=optional(instance_of(float)), default=None) + nfcRefDist = attrib(validator=optional(finite_float()), default=None) screenRef = attrib(validator=instance_of(bool), default=False) rtime = attrib(default=None, validator=optional(instance_of(Fraction))) duration = attrib(default=None, validator=optional(instance_of(Fraction))) extra_data = attrib(validator=instance_of(ExtraData), default=Factory(ExtraData)) + gains = attrib(validator=list_of(float)) + importances = attrib(validator=list_of(int)) + + @gains.default + def _(self): + return [1.0] * len(self.orders) + + @importances.default + def _(self): + return [10] * len(self.orders) + @attrs(slots=True) class HOARenderingItem(RenderingItem): """RenderingItem for typeDefinition="HOA" Attributes: - track_specs (list of TrackSpec): Specification of n tracks of input samples. + track_specs (list[TrackSpec]): Specification of n tracks of input samples. metadata_source (MetadataSource): Source of HOATypeMetadata objects; will usually contain only one object. - importances (list of ImportanceData or None): Importance data for each track. - adm_paths (list of ADMPath or None): Pointers to the ADM objects which each track is derived from. + importances (Optional[list[ImportanceData]]): Importance data for each + track. + adm_paths (Optional[list[ADMPath]]): Pointers to the ADM objects which + each track is derived from. """ track_specs = attrib(validator=list_of(TrackSpec)) metadata_source = attrib(validator=instance_of(MetadataSource)) - importances = attrib(validator=optional(list_of(ImportanceData)), default=None) + importances = attrib(validator=optional(list_of(ImportanceData))) adm_paths = attrib(validator=optional(list_of(ADMPath)), repr=False, default=None) @importances.validator @@ -275,6 +336,10 @@ def importances_valid(self, attribute, value): if value is not None and len(value) != len(self.track_specs): raise ValueError("wrong number of ImportanceDatas provided") + @importances.default + def _(self): + return [ImportanceData() for i in range(len(self.track_specs))] + @adm_paths.validator def adm_paths_valid(self, attribute, value): if value is not None and len(value) != len(self.track_specs): diff --git a/ear/core/objectbased/conversion.py b/ear/core/objectbased/conversion.py index 0ef5dd4a..3f6abf69 100644 --- a/ear/core/objectbased/conversion.py +++ b/ear/core/objectbased/conversion.py @@ -1,10 +1,22 @@ from attr import evolve, attrib, attrs -from ...fileio.adm.elements import ObjectPolarPosition, ObjectCartesianPosition +from ...fileio.adm.elements import ( + AudioBlockFormatObjects, + ObjectPolarPosition, + ObjectCartesianPosition, +) from ..geom import azimuth, inside_angle_range, local_coordinate_system, relative_angle import numpy as np -def to_polar(block_format): +def to_polar(block_format: AudioBlockFormatObjects) -> AudioBlockFormatObjects: + """Convert a block format to use polar coordinates according to ITU-R + BS.2127-0 section 10. + + The cartesian flag will be set to match the coordinates used. + + The position, width, height and depth will be converted; the rest of the + parameters will be unmodified. + """ block_format = _fix_cartesian_flag(block_format) if not block_format.cartesian: return block_format @@ -14,8 +26,8 @@ def to_polar(block_format): block_format.position.Y, block_format.position.Z, block_format.width, - block_format.height, - block_format.depth) + block_format.depth, + block_format.height) return evolve(block_format, position=ObjectPolarPosition(azimuth, elevation, distance, @@ -25,13 +37,21 @@ def to_polar(block_format): ) -def to_cartesian(block_format): +def to_cartesian(block_format: AudioBlockFormatObjects) -> AudioBlockFormatObjects: + """Convert a block format to use Cartesian coordinates according to ITU-R + BS.2127-0 section 10. + + The cartesian flag will be set to match the coordinates used. + + The position, width, height and depth will be converted; the rest of the + parameters will be unmodified. + """ block_format = _fix_cartesian_flag(block_format) if block_format.cartesian: return block_format else: (X, Y, Z, - width, height, depth) = extent_polar_to_cart(block_format.position.azimuth, + width, depth, height) = extent_polar_to_cart(block_format.position.azimuth, block_format.position.elevation, block_format.position.distance, block_format.width, @@ -110,6 +130,17 @@ def _find_sector(self, az): assert False def point_polar_to_cart(self, az, el, d): + """Convert a position from polar to Cartesian according to ITU-R + BS.2127-0 section 10. + + Parameters: + az (float): azimuth + el (float): elevation + d (float): distance + + Returns: + np.ndarray of shape (3,): converted Cartesian position + """ if np.abs(el) > self.el_top: el_tilde = self.el_top_tilde + (90.0 - self.el_top_tilde) * (np.abs(el) - self.el_top) / (90 - self.el_top) z = d * np.sign(el) @@ -139,6 +170,17 @@ def _find_cart_sector(self, az): assert False def point_cart_to_polar(self, x, y, z): + """Convert a position from Cartesian to polar according to ITU-R + BS.2127-0 section 10. + + Parameters: + x (float): X coordinate + y (float): Y coordinate + z (float): Z coordinate + + Returns: + (float, float, float): converted azimuth, elevation and distance + """ eps = 1e-10 if np.abs(x) < eps and np.abs(y) < eps: if np.abs(z) < eps: @@ -168,6 +210,21 @@ def point_cart_to_polar(self, x, y, z): return az, el, d def extent_polar_to_cart(self, az, el, dist, width, height, depth): + """Convert a position and extent parameters from polar to Cartesian + according to ITU-R BS.2127-0 section 10. + + Parameters: + az (float): azimuth + el (float): elevation + dist (float): distance + width (float): width parameter + height (float): height parameter + depth (float): depth parameter + + Returns: + (float, float, float, float, float, float): converted X, Y, Z, + width (X size), depth (Y size) and height (Z size) + """ x, y, z = self.point_polar_to_cart(az, el, dist) front_xs, front_ys, front_zs = self._whd2xyz(width, height, depth) @@ -177,6 +234,21 @@ def extent_polar_to_cart(self, az, el, dist, width, height, depth): return x, y, z, xs, ys, zs def extent_cart_to_polar(self, x, y, z, xs, ys, zs): + """Convert a position and extent parameters from Cartesian to polar + according to ITU-R BS.2127-0 section 10. + + Parameters: + x (float): X coordinate + y (float): Y coordinate + z (float): Z coordinate + xs (float): width (X size) + ys (float): depth (Y size) + zs (float): height (Z size) + + Returns: + (float, float, float, float, float, float): converted azimuth, + elevation, distance, width, height and depth + """ az, el, dist = self.point_cart_to_polar(x, y, z) M = local_coordinate_system(az, el).T * np.array([[xs], [ys], [zs]]) diff --git a/ear/core/objectbased/gain_calc.py b/ear/core/objectbased/gain_calc.py index 2700ea72..58c6604e 100644 --- a/ear/core/objectbased/gain_calc.py +++ b/ear/core/objectbased/gain_calc.py @@ -8,8 +8,10 @@ from .zone import ZoneExclusionDownmix from .. import allocentric from ...fileio.adm.elements import CartesianZone, PolarZone, ObjectCartesianPosition, ObjectPolarPosition +from ...fileio.adm.elements.version import version_at_least from ..screen_scale import ScreenScaleHandler from ..screen_edge_lock import ScreenEdgeLockHandler +from ..renderer_common import get_object_gain def coord_trans(position): @@ -45,7 +47,9 @@ def __init__(self, layout): # ties broken by elevation, absolute azimuth then azimuth. priority_order = np.lexsort((azimuths, np.abs(azimuths), elevations, np.abs(elevations))) - self.channel_priority = np.arange(len(layout.channels))[priority_order] + + self.channel_priority = np.zeros(len(layout.channels), dtype=int) + self.channel_priority[priority_order] = np.arange(len(layout.channels)) def handle(self, position, channelLock, excluded=None): """Apply channel lock to a position. @@ -124,7 +128,7 @@ def get_weighted_distances(self, channel_positions, position): class AlloChannelLockHandler(ChannelLockHandlerBase): """Channel lock specialised for allocentric; allocentric loudspeaker - positions are used, and the distance calculation is unweighted.""" + positions are used, and the distance calculation is weighted.""" def __init__(self, layout): super(AlloChannelLockHandler, self).__init__(layout) @@ -136,7 +140,7 @@ def get_weighted_distances(self, channel_positions, position): return np.sqrt(np.sum(w * (position - channel_positions) ** 2, axis=1)) -def diverge(position, objectDivergence, cartesian): +def diverge(position, objectDivergence, cartesian, document_version): """Implement object divergence by duplicating and modifying source directions. @@ -144,6 +148,7 @@ def diverge(position, objectDivergence, cartesian): position (array of length 3): Cartesian source position objectDivergence (fileio.adm.elements.ObjectDivergence): object divergence information cartesian (bool): Block format 'cartesian' flag. + document_version (Version): version from AXML document Returns: array of length n: gain for each position @@ -181,9 +186,13 @@ def diverge(position, objectDivergence, cartesian): if objectDivergence.positionRange is not None: warnings.warn("positionRange specified for blockFormat in polar mode; using polar divergence") - azimuthRange = (objectDivergence.azimuthRange - if objectDivergence.azimuthRange is not None - else 45.0) + azimuthRange = objectDivergence.azimuthRange + if azimuthRange is None: + warnings.warn( + "objectDivergence azimuthRange default changed between BS.2076-1 and -2, " + "so should be specified explicitly" + ) + azimuthRange = 0.0 if version_at_least(document_version, 2) else 45.0 distance = np.linalg.norm(position) p_l, p_r = cart(azimuthRange, 0, distance), cart(-azimuthRange, 0, distance) @@ -361,7 +370,13 @@ def __init__(self, layout, point_source_opts): def render(self, object_meta): block_format = object_meta.block_format - position = coord_trans(block_format.position) + position = block_format.position + + positionOffset = object_meta.extra_data.object_positionOffset + if positionOffset is not None: + position = positionOffset.apply(position) + + position = coord_trans(position) position = self.screen_scale_handler.handle(position, block_format.screenRef, object_meta.extra_data.reference_screen, @@ -391,7 +406,13 @@ def extent_pan(position, width, height, depth): extent_pan = self.polar_extent_panner.handle - diverged_gains, diverged_positions = diverge(position, block_format.objectDivergence, block_format.cartesian) + document_version = object_meta.extra_data.document_version + diverged_gains, diverged_positions = diverge( + position, + block_format.objectDivergence, + block_format.cartesian, + document_version, + ) gains_for_each_pos = np.apply_along_axis(extent_pan, 1, diverged_positions, block_format.width, block_format.height, block_format.depth) @@ -403,7 +424,7 @@ def extent_pan(position, width, height, depth): gains = np.nan_to_num(gains) - gains *= block_format.gain + gains *= block_format.gain * get_object_gain(object_meta) # add in silent LFE channels gains_full = np.zeros(len(self.is_lfe)) diff --git a/ear/core/objectbased/renderer.py b/ear/core/objectbased/renderer.py index e94b6928..86c8e7d3 100644 --- a/ear/core/objectbased/renderer.py +++ b/ear/core/objectbased/renderer.py @@ -102,11 +102,11 @@ def __init__(self, layout, gain_calc_opts, decorrelator_opts, block_size): # apply to the samples it produces. self.block_processing_channels = [] - decorrlation_filters = decorrelate.design_decorrelators(layout, **decorrelator_opts) - decorrelator_delay = (decorrlation_filters.shape[0] - 1) // 2 + decorrelation_filters = decorrelate.design_decorrelators(layout, **decorrelator_opts) + decorrelator_delay = (decorrelation_filters.shape[0] - 1) // 2 decorrelators = OverlapSaveConvolver( - block_size, self._nchannels, decorrlation_filters) + block_size, self._nchannels, decorrelation_filters) self.decorrelators_vbs = VariableBlockSizeAdapter( block_size, self._nchannels, decorrelators.filter_block) diff --git a/ear/core/objectbased/test/data/gain_calc_pvs/inputs.jsonl.xz b/ear/core/objectbased/test/data/gain_calc_pvs/inputs.jsonl.xz new file mode 100644 index 00000000..fdba495a Binary files /dev/null and b/ear/core/objectbased/test/data/gain_calc_pvs/inputs.jsonl.xz differ diff --git a/ear/core/objectbased/test/data/gain_calc_pvs/inputs.pickle b/ear/core/objectbased/test/data/gain_calc_pvs/inputs.pickle deleted file mode 100644 index e5a3b90b..00000000 Binary files a/ear/core/objectbased/test/data/gain_calc_pvs/inputs.pickle and /dev/null differ diff --git a/ear/core/objectbased/test/data/gain_calc_pvs/outputs.jsonl.xz b/ear/core/objectbased/test/data/gain_calc_pvs/outputs.jsonl.xz new file mode 100644 index 00000000..6cfd0a21 Binary files /dev/null and b/ear/core/objectbased/test/data/gain_calc_pvs/outputs.jsonl.xz differ diff --git a/ear/core/objectbased/test/data/gain_calc_pvs/outputs.npz b/ear/core/objectbased/test/data/gain_calc_pvs/outputs.npz index a519123e..b9829788 100644 Binary files a/ear/core/objectbased/test/data/gain_calc_pvs/outputs.npz and b/ear/core/objectbased/test/data/gain_calc_pvs/outputs.npz differ diff --git a/ear/core/objectbased/test/test_channel_lock.py b/ear/core/objectbased/test/test_channel_lock.py new file mode 100644 index 00000000..5f8d8ea6 --- /dev/null +++ b/ear/core/objectbased/test/test_channel_lock.py @@ -0,0 +1,38 @@ +from ..gain_calc import EgoChannelLockHandler +from ... import bs2051 + + +def test_priority(): + """check that the channel lock priority order is sensible""" + layout = bs2051.get_layout("9+10+3").without_lfe + handler = EgoChannelLockHandler(layout) + + priority_order = [ + "M+000", + "M-030", + "M+030", + "M-060", + "M+060", + "M-090", + "M+090", + "M-135", + "M+135", + "M+180", + "B+000", + "B-045", + "B+045", + "U+000", + "U-045", + "U+045", + "U-090", + "U+090", + "U-135", + "U+135", + "U+180", + "T+000", + ] + + for i, (name, priority) in enumerate( + zip(layout.channel_names, handler.channel_priority) + ): + assert priority_order.index(name) == priority diff --git a/ear/core/objectbased/test/test_conversion.py b/ear/core/objectbased/test/test_conversion.py index fd97fca2..3eb45269 100644 --- a/ear/core/objectbased/test/test_conversion.py +++ b/ear/core/objectbased/test/test_conversion.py @@ -1,5 +1,5 @@ from ..conversion import to_cartesian, to_polar, point_cart_to_polar, point_polar_to_cart, Conversion -from ....fileio.adm.elements import AudioBlockFormatObjects, ObjectPolarPosition +from ....fileio.adm.elements import AudioBlockFormatObjects, ObjectCartesianPosition, ObjectPolarPosition from attr import asdict import numpy as np import numpy.testing as npt @@ -61,23 +61,6 @@ def test_conversion_corners(): npt.assert_allclose(point_cart_to_polar(x*d, y*d, z*d), (az, el, d), atol=1e-10) -def test_conversion_poles(): - for sign in [-1, 1]: - for d in [0.5, 1, 2]: - npt.assert_allclose(point_polar_to_cart(0, sign * 90, d), - [0.0, 0.0, sign * d], atol=1e-10) - npt.assert_allclose(point_cart_to_polar(0.0, 0.0, sign * d), - (0, sign * 90, d), atol=1e-10) - -def test_conversion_centre(): - for az in [-90, 0, 90]: - for el in [-90, 0, 90]: - npt.assert_allclose(point_polar_to_cart(az, el, 0.0), - [0.0, 0.0, 0.0], atol=1e-10) - - _az, _el, dist = point_cart_to_polar(0.0, 0.0, 0.0) - assert dist == approx(0.0) - def test_conversion_poles(): for sign in [-1, 1]: @@ -116,3 +99,46 @@ def test_map_linear_az(): for az in np.linspace(0, 30): x = Conversion._map_az_to_linear(0, -30, az) assert Conversion._map_linear_to_az(0, -30, x) == approx(az) + + +# tests for mapping between width height and depth at different positions +whd_mappings = [ + # azimuth, elevation, Cartesian equivalent of Width, Height, Depth + (0.0, 0.0, "whd"), + (90.0, 0.0, "dhw"), # polar width -> Cartesian depth etc. + (-90.0, 0.0, "dhw"), + (180.0, 0.0, "whd"), + (0.0, 90.0, "wdh"), + (0.0, -90.0, "wdh"), +] + + +@pytest.mark.parametrize("az,el,whd", whd_mappings) +@pytest.mark.parametrize("polar_axis", "whd") +def test_whd_mapping_to_cartesian(az, el, whd, polar_axis): + cart_axis = "whd"[whd.index(polar_axis)] + + bf = AudioBlockFormatObjects( + position=ObjectPolarPosition(az, el, 1.0), + cartesian=False, + width=20.0 if polar_axis == "w" else 0.0, + height=20.0 if polar_axis == "h" else 0.0, + depth=0.2 if polar_axis == "d" else 0.0, + ) + bf_c = to_cartesian(bf) + assert cart_axis == "whd"[np.argmax([bf_c.width, bf_c.height, bf_c.depth])] + + +@pytest.mark.parametrize("az,el,whd", whd_mappings) +@pytest.mark.parametrize("polar_axis", "whd") +def test_whd_mapping_to_polar(az, el, whd, polar_axis): + cart_axis = "whd"[whd.index(polar_axis)] + bf = AudioBlockFormatObjects( + position=ObjectCartesianPosition(*point_polar_to_cart(az, el, 1.0)), + cartesian=True, + width=0.1 if cart_axis == "w" else 0.0, + height=0.1 if cart_axis == "h" else 0.0, + depth=0.1 if cart_axis == "d" else 0.0, + ) + bf_p = to_polar(bf) + assert polar_axis == "whd"[np.argmax([bf_p.width, bf_p.height, bf_p.depth * 10])] diff --git a/ear/core/objectbased/test/test_gain_calc.py b/ear/core/objectbased/test/test_gain_calc.py index b7920fa9..857d1d8e 100644 --- a/ear/core/objectbased/test/test_gain_calc.py +++ b/ear/core/objectbased/test/test_gain_calc.py @@ -1,10 +1,25 @@ from attr import evolve import numpy as np import numpy.testing as npt +import pytest from ... import bs2051 from ..gain_calc import GainCalc -from ....fileio.adm.elements import (AudioBlockFormatObjects, ChannelLock, ObjectDivergence, - CartesianZone, PolarZone, ScreenEdgeLock, ObjectPolarPosition, ObjectCartesianPosition) +from ....fileio.adm.elements import ( + AudioBlockFormatObjects, + ChannelLock, + ObjectDivergence, + CartesianZone, + PolarZone, + ScreenEdgeLock, + ObjectPolarPosition, + ObjectCartesianPosition, + CartesianPositionOffset, + PolarPositionOffset, +) +from ....fileio.adm.elements.version import ( + BS2076Version, + NoVersion, +) from ...metadata_input import ObjectTypeMetadata, ExtraData from ...geom import cart, elevation, PolarPosition from ....common import PolarScreen @@ -194,6 +209,16 @@ def test_channel_lock_no_max_exclude(layout, gain_calc): direct_gains=[("M-030", 1.0)]) +def test_channel_lock_centre_cart(): + layout = bs2051.get_layout("9+10+3").without_lfe + gain_calc = GainCalc(layout) + run_test(layout, gain_calc, + dict(channelLock=ChannelLock(), + cartesian=True, + position=dict(X=0.0, Y=0.0, Z=0.0)), + direct_gains=[("M-090", 1.0)]) + + def test_diverge_half(layout, gain_calc): run_test(layout, gain_calc, dict(position=dict(azimuth=0.0, elevation=0.0, distance=1.0), @@ -236,6 +261,37 @@ def test_diverge_azimuth_elevation(layout, gain_calc): direct_gains=[("U+030", np.sqrt(0.5)), ("U+110", np.sqrt(0.5))]) +@pytest.mark.parametrize( + "version,default", + [ + (NoVersion(), 45.0), + (BS2076Version(1), 45.0), + (BS2076Version(2), 0.0), + ], +) +def test_diverge_default(gain_calc, version, default): + extra_data = ExtraData(document_version=version) + + position = dict(azimuth=0.0, elevation=0.0, distance=1.0) + block_format_specified = AudioBlockFormatObjects( + position=position, objectDivergence=ObjectDivergence(0.5, azimuthRange=default) + ) + block_format_default = AudioBlockFormatObjects( + position=position, objectDivergence=ObjectDivergence(0.5) + ) + + gains_specified = gain_calc.render( + ObjectTypeMetadata(block_format=block_format_specified, extra_data=extra_data) + ) + with pytest.warns(UserWarning): + gains_default = gain_calc.render( + ObjectTypeMetadata(block_format=block_format_default, extra_data=extra_data) + ) + + npt.assert_allclose(gains_default.direct, gains_specified.direct, atol=1e-10) + npt.assert_allclose(gains_default.diffuse, gains_specified.diffuse, atol=1e-10) + + def test_zone_front(layout, gain_calc): run_test(layout, gain_calc, dict(position=dict(azimuth=0.0, elevation=0.0, distance=1.0), @@ -386,6 +442,50 @@ def test_screen_edge_lock_right_no_screen(layout, gain_calc): direct_gains=[("M+000", 1.0)]) +def test_object_gain(layout, gain_calc): + run_test( + layout, + gain_calc, + dict(position=dict(azimuth=0.0, elevation=0.0, distance=1.0)), + extra_data=ExtraData(object_gain=0.5), + direct_gains=[("M+000", 0.5)], + ) + + +def test_object_mute(layout, gain_calc): + run_test( + layout, + gain_calc, + dict(position=dict(azimuth=0.0, elevation=0.0, distance=1.0)), + extra_data=ExtraData(object_mute=True), + direct_gains=[], + ) + + +def test_object_PolarPositionOffset(layout, gain_calc): + run_test( + layout, + gain_calc, + dict(position=dict(azimuth=10.0, elevation=5.0, distance=1.0)), + extra_data=ExtraData( + object_positionOffset=PolarPositionOffset(azimuth=20.0, elevation=25.0) + ), + direct_gains=[("U+030", 1.0)], + ) + + +def test_object_CartesianPositionOffset(layout, gain_calc): + run_test( + layout, + gain_calc, + dict(position=dict(X=0.1, Y=0.2, Z=0.3), cartesian=True), + extra_data=ExtraData( + object_positionOffset=CartesianPositionOffset(X=0.9, Y=0.8, Z=0.7) + ), + direct_gains=[("U-030", 1.0)], + ) + + def test_objectbased_extent(layout, gain_calc): block_formats = [ AudioBlockFormatObjects(position=dict(azimuth=0, elevation=0, distance=1), diff --git a/ear/core/objectbased/test/test_gain_calc_changes.py b/ear/core/objectbased/test/test_gain_calc_changes.py index b12766e7..a3fa5df7 100644 --- a/ear/core/objectbased/test/test_gain_calc_changes.py +++ b/ear/core/objectbased/test/test_gain_calc_changes.py @@ -2,13 +2,43 @@ import random import numpy as np import numpy.testing as npt -from ....fileio.adm.elements import (AudioBlockFormatObjects, ObjectDivergence, - ObjectPolarPosition, ObjectCartesianPosition) +from ....fileio.adm.elements import (AudioBlockFormatObjects, CartesianZone, ChannelLock, ObjectDivergence, + ObjectPolarPosition, ObjectCartesianPosition, PolarZone) from ...metadata_input import ObjectTypeMetadata, ExtraData from ...geom import PolarPosition from ....common import PolarScreen +def generate_zone_exclusion(cartesian): + if cartesian: + split = random.choice(["X", "Y", "Z"] + [None] * 7) + if split is not None: + return [ + CartesianZone( + minX=0.0 if split == "X" else -1.0, + maxX=1.0, + minY=0.0 if split == "Y" else -1.0, + maxY=1.0, + minZ=0.5 if split == "Z" else -1.0, + maxZ=1.0, + ), + ] + return [] + else: + split = random.choice(["az", "el"] + [None] * 8) + + if split is not None: + return [ + PolarZone( + minElevation=45.0 if split == "el" else -90.0, + maxElevation=90.0, + minAzimuth=0.0 if split == "az" else -180.0, + maxAzimuth=180.0, + ), + ] + return [] + + def generate_random_ObjectTypeMetadata(cart_pos=None, cartesian=None, azimuth=None, elevation=None, distance=None, X=None, Y=None, Z=None, @@ -25,15 +55,15 @@ def generate_random_ObjectTypeMetadata(cart_pos=None, cartesian=None, if cartesian is None: cartesian = random.choice([False, True]) if cart_pos: - if azimuth is None: azimuth = random.uniform(-180, 180) - if elevation is None: elevation = random.uniform(-90, 90) - if distance is None: distance = random.uniform(0, 1) - position = ObjectPolarPosition(azimuth=azimuth, elevation=elevation, distance=distance) - else: if X is None: X = random.uniform(-1, 1) if Y is None: Y = random.uniform(-1, 1) if Z is None: Z = random.uniform(-1, 1) position = ObjectCartesianPosition(X=X, Y=Y, Z=Z) + else: + if azimuth is None: azimuth = random.uniform(-180, 180) + if elevation is None: elevation = random.uniform(-90, 90) + if distance is None: distance = random.uniform(0, 1) + position = ObjectPolarPosition(azimuth=azimuth, elevation=elevation, distance=distance) if screen_edge_lock_horizontal is None: position.screenEdgeLock.vertical = random.choice([None] * 8 + ["left", "right"]) @@ -42,15 +72,29 @@ def generate_random_ObjectTypeMetadata(cart_pos=None, cartesian=None, if screenRef is None: screenRef = bool(random.randrange(2)) reference_screen = PolarScreen(aspectRatio=random.uniform(1, 2), - centrePosition=PolarPosition(random.uniform(-180, 180), - random.uniform(-90, 90), + centrePosition=PolarPosition(random.uniform(-45, 45), + random.uniform(-45, 45), 1.0), - widthAzimuth=random.uniform(10, 100)) + widthAzimuth=random.uniform(10, 80)) if cartesian: - width, height, depth = random.uniform(0, 2), random.uniform(0, 2), random.uniform(0, 2) + if width is None: + width = random.uniform(0, 2) + if height is None: + height = random.uniform(0, 2) + if depth is None: + depth = random.uniform(0, 2) else: - width, height, depth = random.uniform(0, 360), random.uniform(0, 360), random.uniform(0, 1) + if width is None: + width = random.uniform(0, 360) + if height is None: + height = random.uniform(0, 360) + if depth is None: + depth = random.uniform(0, 1) + + channelLock = None + if random.randrange(15) == 0: + channelLock = ChannelLock(maxDistance=random.uniform(0, 2)) if has_divergence is None: has_divergence = random.choice([False, True]) if divergence_value is None: divergence_value = random.uniform(0, 1) @@ -64,7 +108,10 @@ def generate_random_ObjectTypeMetadata(cart_pos=None, cartesian=None, block_format = AudioBlockFormatObjects(position=position, width=width, height=height, depth=depth, cartesian=cartesian, - objectDivergence=objectDivergence if has_divergence else None) + channelLock=channelLock, + objectDivergence=objectDivergence if has_divergence else None, + screenRef=screenRef, + zoneExclusion=generate_zone_exclusion(cartesian)) return ObjectTypeMetadata(block_format=block_format, extra_data=ExtraData(reference_screen=reference_screen)) @@ -98,6 +145,36 @@ def generate_random_ObjectTypeMetadatas(): yield generate_random_ObjectTypeMetadata() +def load_jsonl_xz(file_name): + """load objects from a lzma-compressed newline-separated JSON (jsonl) file""" + from ....test.json import json_to_value + import json + import lzma + + objects = [] + with lzma.open(file_name, "rb") as f: + for line in f: + json_line = json.loads(line.decode("utf8")) + obj = json_to_value(json_line) + objects.append(obj) + + return objects + + +def dump_jsonl_xz(file_name, objects): + """dump objects to a lzma-compressed newline-separated JSON (jsonl) file""" + from ....test.json import value_to_json + import json + import lzma + + with lzma.open(file_name, "wb") as f: + for obj in objects: + json_obj = value_to_json(obj, include_defaults=False) + json_line = json.dumps(json_obj, sort_keys=True, separators=(",", ":")) + f.write(json_line.encode("utf8")) + f.write(b"\n") + + @pytest.mark.no_cover def test_changes_random(layout, gain_calc): """Check that the result of the gain calculator with a selection of @@ -106,33 +183,33 @@ def test_changes_random(layout, gain_calc): import py.path files_dir = py.path.local(__file__).dirpath() / "data" / "gain_calc_pvs" - import pickle - inputs_f = files_dir / "inputs.pickle" - outputs_f = files_dir / "outputs.npz" + inputs_f = files_dir / "inputs.jsonl.xz" + outputs_f = files_dir / "outputs.jsonl.xz" if inputs_f.check(): - with open(str(inputs_f), 'rb') as f: - inputs = pickle.load(f) + inputs = load_jsonl_xz(str(inputs_f)) else: inputs = list(generate_random_ObjectTypeMetadatas()) inputs_f.dirpath().ensure_dir() - with open(str(inputs_f), 'wb') as f: - pickle.dump(inputs, f, protocol=2) + dump_jsonl_xz(str(inputs_f), inputs) pvs = [gain_calc.render(input) for input in inputs] - direct = np.array([pv.direct for pv in pvs]) - diffuse = np.array([pv.diffuse for pv in pvs]) if outputs_f.check(): - loaded = np.load(str(outputs_f)) - loaded_directs = loaded["direct"] - loaded_diffuses = loaded["diffuse"] - - for input, pv, loaded_direct, loaded_diffuse in zip(inputs, pvs, loaded_directs, loaded_diffuses): - npt.assert_allclose(pv.direct, loaded_direct, err_msg=repr(input)) - npt.assert_allclose(pv.diffuse, loaded_diffuse, err_msg=repr(input)) + loaded = load_jsonl_xz(str(outputs_f)) + + for input, pv, expected in zip(inputs, pvs, loaded): + npt.assert_allclose( + pv.direct, expected["direct"], atol=1e-10, err_msg=repr(input) + ) + npt.assert_allclose( + pv.diffuse, expected["diffuse"], atol=1e-10, err_msg=repr(input) + ) else: outputs_f.dirpath().ensure_dir() - np.savez_compressed(str(outputs_f), direct=direct, diffuse=diffuse) + pvs_json = [ + dict(direct=pv.direct.tolist(), diffuse=pv.diffuse.tolist()) for pv in pvs + ] + dump_jsonl_xz(str(outputs_f), pvs_json) pytest.skip("generated pv file for gain calc") diff --git a/ear/core/plot_point_source.py b/ear/core/plot_point_source.py index bbcfd77a..27a72d6f 100644 --- a/ear/core/plot_point_source.py +++ b/ear/core/plot_point_source.py @@ -58,7 +58,7 @@ def plot_triangulation(point_source_panner): import mpl_toolkits.mplot3d.art3d # noqa fig = plt.figure() - ax = fig.add_subplot(111, projection='3d', aspect=1) + ax = fig.add_subplot(111, projection='3d', aspect='equal') if isinstance(point_source_panner, point_source.PointSourcePannerDownmix): point_source_panner = point_source_panner.psp diff --git a/ear/core/point_source.py b/ear/core/point_source.py index 8f4cc90b..5cb7e7fe 100644 --- a/ear/core/point_source.py +++ b/ear/core/point_source.py @@ -72,7 +72,7 @@ class Triplet(RegionHandler): output_channels = attrib(converter=as_array(dtype=int), validator=has_shape(3)) positions = attrib(converter=as_array(dtype=float), validator=has_shape(3, 3)) - _basis = attrib(init=False, cmp=False, repr=False) + _basis = attrib(init=False, eq=False, repr=False) def __attrs_post_init__(self): self._basis = np.linalg.inv(self.positions) @@ -113,7 +113,7 @@ class VirtualNgon(RegionHandler): centre_position = attrib(converter=as_array(dtype=float), validator=has_shape(3)) centre_downmix = attrib(converter=as_array(dtype=float), validator=has_shape(None)) - regions = attrib(init=False, cmp=False, repr=False) + regions = attrib(init=False, eq=False, repr=False) def __attrs_post_init__(self): n = len(self.output_channels) diff --git a/ear/core/renderer_common.py b/ear/core/renderer_common.py index 9e3c65a4..03dba502 100644 --- a/ear/core/renderer_common.py +++ b/ear/core/renderer_common.py @@ -36,8 +36,8 @@ class ProcessingBlock(object): # integer sample numbers of the first and last sample affected; sample # number s is affected if first_sample <= s < last_sample - first_sample = attrib(init=False, cmp=False) - last_sample = attrib(init=False, cmp=False) + first_sample = attrib(init=False, eq=False) + last_sample = attrib(init=False, eq=False) @first_sample.default def init_start_sample_round(self): @@ -67,8 +67,12 @@ def overlap(self, start_sample, num_samples): overlap_start_sample = max(start_sample, self.first_sample) overlap_end_sample = min(end_sample, self.last_sample) - return (slice(overlap_start_sample - self.first_sample, overlap_end_sample - self.first_sample), - slice(overlap_start_sample - start_sample, overlap_end_sample - start_sample)) + if overlap_start_sample <= overlap_end_sample: + return (slice(overlap_start_sample - self.first_sample, overlap_end_sample - self.first_sample), + slice(overlap_start_sample - start_sample, overlap_end_sample - start_sample)) + else: + # no overlap + return slice(0), slice(0) @attrs(slots=True, frozen=True) @@ -97,7 +101,7 @@ class InterpGains(ProcessingBlock): # interpolation coefficients: ramp from 0 to 1 between start_sample and # end_sample, sampled for each sample in range first_sample:last_sample - _interp_p = attrib(init=False, cmp=False) + _interp_p = attrib(init=False, eq=False) @_interp_p.default def init_interp_p(self): @@ -284,3 +288,10 @@ def is_lfe(frequency): if frequency.lowPass is not None or frequency.highPass is not None: warnings.warn("Not treating channel with frequency {!r} as LFE.".format(frequency)) return False + + +def get_object_gain(type_metadata): + """get the gain implied by the audioObject gain and mute parameters""" + extra_data = type_metadata.extra_data + + return 0.0 if extra_data.object_mute else extra_data.object_gain diff --git a/ear/core/scenebased/design.py b/ear/core/scenebased/design.py index b739216a..11530436 100644 --- a/ear/core/scenebased/design.py +++ b/ear/core/scenebased/design.py @@ -3,6 +3,7 @@ from .. import hoa from .. import point_source from ...options import OptionsHandler, Option, SubOptions +from ..renderer_common import get_object_gain class HOADecoderDesign(object): @@ -65,6 +66,11 @@ def design(self, type_metadata): type_metadata.extra_data.channel_frequency.highPass is not None): warnings.warn("frequency information for HOA is not implemented; ignoring") + if type_metadata.extra_data.object_positionOffset is not None: + # it would be possible to apply azimuth offset properly, but not + # elevation or distance, and it's not clear what the use-case is + raise ValueError("positionOffset is not supported with HOA") + n, m = np.array(type_metadata.orders), np.array(type_metadata.degrees) norm = hoa.norm_functions[type_metadata.normalization] @@ -95,4 +101,6 @@ def design(self, type_metadata): K_v = hoa.sph_harm(n[:, np.newaxis], m[:, np.newaxis], az[np.newaxis], el[np.newaxis], norm=norm) decoder /= np.sqrt(np.mean(np.sum(np.dot(decoder, K_v) ** 2, axis=0))) - return decoder + return decoder * ( + np.array(type_metadata.gains) * get_object_gain(type_metadata) + ) diff --git a/ear/core/scenebased/test/__init__.py b/ear/core/scenebased/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ear/core/scenebased/test/test_design.py b/ear/core/scenebased/test/test_design.py new file mode 100644 index 00000000..b729e01e --- /dev/null +++ b/ear/core/scenebased/test/test_design.py @@ -0,0 +1,82 @@ +import numpy as np +import pytest +from ....fileio.adm.elements import PolarPositionOffset +from ... import hoa, point_source +from ...bs2051 import get_layout +from ...metadata_input import HOATypeMetadata +from ..design import HOADecoderDesign + +# compare against a reference, as otherwise we'd end up reimplementing the +# whole thing, and still would not spot changes in allrad_design + +ref_decoder = np.array( + [ + [1.71634590e-01, 1.42431019e-01, -1.17545274e-01, 1.30305331e-01], + [1.71642551e-01, -1.42433751e-01, -1.17551622e-01, 1.30321095e-01], + [1.13881860e-01, 7.52101654e-06, -8.55161879e-02, 1.23448338e-01], + [3.68460920e-01, 2.13546281e-01, -1.68472356e-01, -2.38326574e-01], + [3.68450573e-01, -2.13530753e-01, -1.68465249e-01, -2.38333209e-01], + [1.30447818e-01, 6.67387033e-02, 1.45593901e-01, 9.44637862e-02], + [1.30433098e-01, -6.67320618e-02, 1.45594222e-01, 9.44437810e-02], + [1.76070328e-01, 9.23726786e-02, 2.01041658e-01, -6.00913147e-02], + [1.76077278e-01, -9.23760367e-02, 2.01043852e-01, -6.00980940e-02], + ] +) + + +@pytest.fixture(scope="module") +def layout(): + return get_layout("4+5+0").without_lfe + + +@pytest.fixture(scope="module") +def panner(layout): + return HOADecoderDesign(layout) + + +@pytest.fixture +def type_metadata(): + order = 1 + acn = np.arange((order + 1) ** 2) + orders, degrees = hoa.from_acn(acn) + + return HOATypeMetadata( + orders=orders.tolist(), + degrees=degrees.tolist(), + normalization="N3D", + ) + + +def test_basic(panner, type_metadata): + decoder = panner.design(type_metadata) + # print(repr(decoder)) + np.testing.assert_allclose(decoder, ref_decoder, atol=1e-6) + + +def test_gains(panner, type_metadata): + gains = np.linspace(0.1, 0.9, len(type_metadata.orders)) + type_metadata.gains = gains.tolist() + + decoder = panner.design(type_metadata) + np.testing.assert_allclose(decoder, ref_decoder * gains, atol=1e-6) + + +def test_object_gain(panner, type_metadata): + type_metadata.extra_data.object_gain = 0.5 + + decoder = panner.design(type_metadata) + np.testing.assert_allclose(decoder, ref_decoder * 0.5, atol=1e-6) + + +def test_object_mute(panner, type_metadata): + type_metadata.extra_data.object_mute = True + + decoder = panner.design(type_metadata) + np.testing.assert_allclose(decoder, np.zeros_like(ref_decoder), atol=1e-6) + + +def test_object_positionOffset(panner, type_metadata): + type_metadata.extra_data.object_positionOffset = PolarPositionOffset(azimuth=30.0) + + with pytest.raises(ValueError, match="positionOffset is not supported with HOA"): + panner.design(type_metadata) diff --git a/ear/core/screen_common.py b/ear/core/screen_common.py index 9da050ef..6800d595 100644 --- a/ear/core/screen_common.py +++ b/ear/core/screen_common.py @@ -46,8 +46,16 @@ def from_screen(cls, screen): else: assert False - return cls(left_azimuth=azimuth(centre - x_vec), - right_azimuth=azimuth(centre + x_vec), + left_azimuth = azimuth(centre - x_vec) + right_azimuth = azimuth(centre + x_vec) + if right_azimuth > left_azimuth: + raise ValueError("invalid screen specification: screen must not extend past -y") + + if (azimuth(centre - z_vec) - azimuth(centre + z_vec)) > 1e-3: + raise ValueError("invalid screen specification: screen must not extend past +z or -z") + + return cls(left_azimuth=left_azimuth, + right_azimuth=right_azimuth, bottom_elevation=elevation(centre - z_vec), top_elevation=elevation(centre + z_vec)) diff --git a/ear/core/screen_scale.py b/ear/core/screen_scale.py index 2fa560e0..fa6eb6d7 100644 --- a/ear/core/screen_scale.py +++ b/ear/core/screen_scale.py @@ -2,6 +2,7 @@ from .objectbased.conversion import point_cart_to_polar, point_polar_to_cart import numpy as np from .screen_common import PolarEdges, compensate_position +from .util import interp_sorted class PolarScreenScaler(object): @@ -17,12 +18,12 @@ def __init__(self, reference_screen, reproduction_screen): self.rep_screen_edges = PolarEdges.from_screen(reproduction_screen) def scale_az_el(self, az, el): - new_az = np.interp(az, - (-180, self.ref_screen_edges.right_azimuth, self.ref_screen_edges.left_azimuth, 180), - (-180, self.rep_screen_edges.right_azimuth, self.rep_screen_edges.left_azimuth, 180)) - new_el = np.interp(el, - (-90, self.ref_screen_edges.bottom_elevation, self.ref_screen_edges.top_elevation, 90), - (-90, self.rep_screen_edges.bottom_elevation, self.rep_screen_edges.top_elevation, 90)) + new_az = interp_sorted(az, + (-180, self.ref_screen_edges.right_azimuth, self.ref_screen_edges.left_azimuth, 180), + (-180, self.rep_screen_edges.right_azimuth, self.rep_screen_edges.left_azimuth, 180)) + new_el = interp_sorted(el, + (-90, self.ref_screen_edges.bottom_elevation, self.ref_screen_edges.top_elevation, 90), + (-90, self.rep_screen_edges.bottom_elevation, self.rep_screen_edges.top_elevation, 90)) return new_az, new_el diff --git a/ear/core/select_items/hoa.py b/ear/core/select_items/hoa.py index 37cb9e30..54fffc31 100644 --- a/ear/core/select_items/hoa.py +++ b/ear/core/select_items/hoa.py @@ -1,9 +1,10 @@ from functools import partial -from ...fileio.adm.exceptions import AdmError - +from .utils import get_path_param # functions which take a path through the audioPackFormats and an -# audioChannelFormat, and extract some parameter for a single HOA channel +# audioChannelFormat, and extract some parameter for a single HOA channel, for +# use with .utils.get_single_param and .utils.get_per_channel_param + def _get_pack_param(audioPackFormat_path, audioChannelFormat, name, default=None): """Get a parameter which can be defined in either audioPackFormats or @@ -12,21 +13,7 @@ def _get_pack_param(audioPackFormat_path, audioChannelFormat, name, default=None path from the root audioPackFormat to a single audioBlockFormat -- the consistency in the whole pack is checked in get_single_param.""" path = audioPackFormat_path + [audioChannelFormat.audioBlockFormats[0]] - all_values = [getattr(obj, name) for obj in path] - - not_none = [value for value in all_values if value is not None] - - if not_none: - if any(value != not_none[0] for value in not_none): - raise AdmError("Conflicting {name} values in path from {apf.id} to {acf.id}".format( - name=name, - apf=audioPackFormat_path[0], - acf=audioChannelFormat, - )) - - return not_none[0] - else: - return default + return get_path_param(path, name, default) def get_nfcRefDist(audioPackFormat_path, audioChannelFormat): @@ -47,37 +34,5 @@ def _get_block_format_attr(_audioPackFormat_path, audioChannelFormat, attr): get_degree = partial(_get_block_format_attr, attr="degree") get_rtime = partial(_get_block_format_attr, attr="rtime") get_duration = partial(_get_block_format_attr, attr="duration") - - -# functions to use the above definitions for multiple channels - -def get_single_param(pack_paths_channels, name, get_param): - """Get one parameter which must be consistent in all channels. - - Parameters: - pack_paths_channels (list): list of tuples of (audioPackFormat_path, - audioChannelFormat), one for each audioChannelFormat in the root - audioPackFormat. - name (str): name of parameter to be used in exceptions - get_param (callable): function from (audioPackFormat_path, - audioChannelFormat) to the value of the parameter. - """ - for pack_path_channel_a, pack_path_channel_b in zip(pack_paths_channels[:-1], pack_paths_channels[1:]): - pack_format_path_a, channel_a = pack_path_channel_a - pack_format_path_b, channel_b = pack_path_channel_b - if get_param(pack_format_path_a, channel_a) != get_param(pack_format_path_b, channel_b): - raise AdmError("All HOA audioChannelFormats in a single audioPackFormat must " - "share the same {name} value, but {acf_a.id} and {acf_b.id} differ.".format( - name=name, - acf_a=channel_a, - acf_b=channel_b, - )) - - pack_path, channel = pack_paths_channels[0] - return get_param(pack_path, channel) - - -def get_per_channel_param(pack_paths_channels, get_param): - """Get One value of a parameter per channel in pack_paths_channels. - See get_single_param.""" - return [get_param(pack_path, channel) for pack_path, channel in pack_paths_channels] +get_gain = partial(_get_block_format_attr, attr="gain") +get_importance = partial(_get_block_format_attr, attr="importance") diff --git a/ear/core/select_items/matrix.py b/ear/core/select_items/matrix.py index ecc79fe1..aad9e428 100644 --- a/ear/core/select_items/matrix.py +++ b/ear/core/select_items/matrix.py @@ -15,7 +15,7 @@ def type_of(apf): packs just have inputPackFormat and decode packs just have outputPackFormat. - These aren't the only attributes that cane be used to determine the type -- + These aren't the only attributes that can be used to determine the type -- it would be just as valid to look at the encode/decode relationship, but this is easiest, so validate the rest of the metadata against this. """ diff --git a/ear/core/select_items/pack_allocation.py b/ear/core/select_items/pack_allocation.py index a0c69955..76880154 100644 --- a/ear/core/select_items/pack_allocation.py +++ b/ear/core/select_items/pack_allocation.py @@ -1,5 +1,5 @@ from attr import attrs, attrib, evolve -from .utils import in_by_id +from .utils import in_by_id, index_by_id @attrs @@ -213,30 +213,40 @@ def could_possibly_allocate(pack): # filter out packs which couldn't possibly be allocated now or in any sub-calls. packs = [pack for pack in packs if could_possibly_allocate(pack)] + # try to allocate a pack/channel for the first track + track, remaining_tracks = tracks[0], tracks[1:] + def candidate_new_packs(): """Possible new packs (to be added to the partial solution) which could be allocated. Yields: - tuples of (pack_ref, remaining_pack_refs) + tuples of (pack_ref, remaining_packs, remaining_pack_refs) + + pack_ref is the pack to try to allocate - pack_ref is the pack to try to allocate; remaining_pack_refs is the - value of pack_refs for the next round + remaining_packs is the list of possible packs that could be + allocated in subsequent steps. when allocating a new pack for a + silent track, packs are allocated in order to avoid duplicate solutions + + remaining_pack_refs is the value of pack_refs for the next round """ if pack_refs is not None: - # try any pack which references a pack in pack_refs - for pack in packs: - for i, pack_ref in enumerate(pack_refs): - if pack_ref is pack.root_pack: - yield pack, pack_refs[:i] + pack_refs[i + 1:] - break + # try any pack which is referenced by pack_refs + if not pack_refs: + return + + for pack_i, pack in enumerate(packs): + ref_i = index_by_id(pack.root_pack, pack_refs) + if ref_i is not None: + remaining_pack_refs = pack_refs[:ref_i] + pack_refs[ref_i + 1:] + remaining_packs = packs[pack_i:] if track is None else packs + yield pack, remaining_packs, remaining_pack_refs else: # try any known pack - for pack in packs: - yield pack, None - - # try to allocate a pack/channel for the first track - track, remaining_tracks = tracks[0], tracks[1:] + for pack_i, pack in enumerate(packs): + remaining_packs = packs[pack_i:] if track is None else packs + yield pack, remaining_packs, None def try_allocate(allocation): """Assign the current track to an appropriate channel in an allocation @@ -262,7 +272,7 @@ def candidate_partial_solutions(): for i, existing_allocation in enumerate(partial_solution): new_allocation = try_allocate(existing_allocation) if new_allocation is not None: - yield partial_solution[:i] + [new_allocation] + partial_solution[i + 1:], pack_refs + yield partial_solution[:i] + [new_allocation] + partial_solution[i + 1:], packs, pack_refs # if track is silent, allocating it to any channel is # equivalent, and if it can be allocated to an existing channel # then it must be -- this prevents multiple equivalent @@ -272,15 +282,15 @@ def candidate_partial_solutions(): return # try allocating a new pack - for pack, remaining_pack_refs in candidate_new_packs(): + for pack, remaining_packs, remaining_pack_refs in candidate_new_packs(): empty_allocation = AllocatedPack(pack=pack, allocation=[(channel, _EMPTY) for channel in pack.channels]) new_allocation = try_allocate(empty_allocation) if new_allocation is not None: - yield partial_solution + [new_allocation], remaining_pack_refs + yield partial_solution + [new_allocation], remaining_packs, remaining_pack_refs - for new_partial, remaining_pack_refs in candidate_partial_solutions(): - for soln in _allocate_packs_impl_obvious(packs, remaining_tracks, remaining_pack_refs, new_partial): + for new_partial, remaining_packs, remaining_pack_refs in candidate_partial_solutions(): + for soln in _allocate_packs_impl_obvious(remaining_packs, remaining_tracks, remaining_pack_refs, new_partial): yield soln diff --git a/ear/core/select_items/pack_allocation_alternative.py b/ear/core/select_items/pack_allocation_alternative.py new file mode 100644 index 00000000..dd85d8d2 --- /dev/null +++ b/ear/core/select_items/pack_allocation_alternative.py @@ -0,0 +1,260 @@ +from .pack_allocation import AllocatedPack, _is_compatible +from .utils import index_by_id + + +def allocate_packs(packs, tracks, pack_refs, num_silent_tracks): + """alternative implementation of pack_allocation.allocate_packs + + this has the same interface and should produce the same results, but is + implemented in a different way, to hopefully have different bugs for + automated testing + """ + + # The overall strategy used is to iterate over all combinations of possible + # packs to allocate, and all possible track orders, and yield allocations + # for the ones which are valid. The correctness relies on both these loops + # (combinations of packs and track orders) producing only unique elements, + # so as not to produce duplicate solutions. + + def get_track(i): + return None if i is None else tracks[i] + + for alloc_packs in pack_subsets(packs, pack_refs, len(tracks) + num_silent_tracks): + # When finding an order for the tracks, and there are repeated packs, + # we must not find orders which just have the tracks assigned to + # repeated packs switched. For example, if there are two identical + # stereo packs and 4 tracks: + # + # alloc_packs = [p1(c1, c2), p1(c1, c2)] + # tracks = [t1(c1), t2(c2), t3(c1), t4(c2)] + # + # Then the following channel orders are equivalent, because the tracks + # in the identical packs are just swapped around: + # + # a: [t1(c1), t2(c2), t3(c1), t4(c2)] + # b: [t3(c1), t4(c2), t1(c1), t2(c2)] + # + # The following two are also equivalent to each other, but not + # equivalent to the above two: + # + # c: [t1(c1), t4(c2), t3(c1), t2(c2)] + # d: [t3(c1), t2(c2), t1(c1), t4(c2)] + # + # So, we only want to test one from each of these pairs (say, a and c), + # and a way to do that is to test only allocations where the tracks for + # repeated packs incense lexicographically. a is chosen because + # [1, 2] < [3, 4], and c is chosen because [1, 4] < [3, 2]. + # + # This also works when there are silent tracks, assuming that silent + # tracks are equal, and sorted after any real tracks. + # + # Note that because tracks are unique, when we are sequentially + # allocating tracks, we only have to consider this lexicographic order + # requirement when all tracks before the current channel in the + # previous identical pack are silent. For example, if we have: + # + # alloc_packs = [p1(c1, c2), p1(c1, c2)] + # tracks = [t1(c1), t2(c2), None, None] + # + # the following orders are possible + # + # a: [t1(c1), t2(c2), None, None] + # b: [None, None, t1(c1), t2(c2)] + # c: [t1(c1), None, None, t2(c2)] + # d: [None, t2(c2), None, t1(c1)] + # + # only a and c meet the lexicographic requirement + # + # b is rejected when allocating t1, because the first 0 tracks of the + # first pack (i.e. the empty list) are all None, and t1 is ordered + # before None (the first track in the second pack) + # + # d is rejected when allocating t1, because the first 1 tracks of the + # first pack (i.e. [None]) are all None, and t1 is ordered before t2 + # (the second track in the first pack) + + # This works in general because if the first n channels of the first + # pack are None, the first n channels of the second one will be too, as + # long as the order constraint is not violated in the previous step. + # + # So, ultimately we find a mapping here from the channel number in the + # allocation to the channel numbers in the previous pack to check, and + # use that to do the above checks. + + prev_same_pack_slice = {} + pack_to_channel_index = {} + + pack_channel_i = 0 + for pack in alloc_packs: + if id(pack) in pack_to_channel_index: + for channel_i, channel in enumerate(pack.channels): + prev_pack_channel_i = pack_to_channel_index[id(pack)] + + prev_slice = slice( + prev_pack_channel_i, prev_pack_channel_i + channel_i + ) + prev_same_pack_slice[pack_channel_i + channel_i] = prev_slice + + pack_to_channel_index[id(pack)] = pack_channel_i + pack_channel_i += len(pack.channels) + + channels = list(channels_in_packs(alloc_packs)) + + def track_possible_at_position(partial_alloc, track_i): + position = len(partial_alloc) + + if not _is_compatible(get_track(track_i), channels[position]): + return False + + if position in prev_same_pack_slice: + prev_slice = prev_same_pack_slice[position] + + # as above, if all channels before the current one in the + # previous identical pack are None, then the allocation of this + # track will determine if the tracks are in order or not + if all( + prev_pack_track_i is None + for prev_pack_track_i in partial_alloc[prev_slice] + ): + # yes, so this track must come after the corresponding one + # in the previous packs + alloc_after = partial_alloc[prev_slice.stop] + + # only silent comes after silent + if alloc_after is None and track_i is not None: + return False + + if track_i <= alloc_after: + return False + + return True + + for track_allocation_i in track_index_orders( + len(tracks), num_silent_tracks, track_possible_at_position + ): + track_i_iter = iter(track_allocation_i) + + yield [ + AllocatedPack( + pack=pack, + allocation=[ + (channel, get_track(next(track_i_iter))) + for channel in pack.channels + ], + ) + for pack in alloc_packs + ] + + +def channels_in_packs(alloc_packs): + """given a list of AllocationPack, yield the AllocationChannels it contains + in order + """ + for alloc_pack in alloc_packs: + for channel in alloc_pack.channels: + yield channel + + +def track_index_orders(num_real_tracks, num_silent_tracks, track_possible_at_position): + """all orders of the tracks (specified by number of real and silent) which + are valid according to track_possible_at_position + + This will yield lists of track indices, which are either an integer + refering to a track number, or None for a silent track + + track_possible_at_position will be called with a partial allocation and a + track index to check if it's OK to append that track to the allocation + """ + return track_index_orders_impl( + [], list(range(num_real_tracks)), num_silent_tracks, track_possible_at_position + ) + + +def track_index_orders_impl( + partial_alloc, tracks, num_silent_tracks, track_possible_at_position +): + if not tracks and num_silent_tracks == 0: + yield partial_alloc + + else: + + def pick_track_indices(): + for i, track in enumerate(tracks): + if track_possible_at_position(partial_alloc, track): + yield track, tracks[:i] + tracks[i + 1 :], num_silent_tracks + + if num_silent_tracks: + yield None, tracks, num_silent_tracks - 1 + + for track, tracks_left, num_silent_tracks_left in pick_track_indices(): + yield from track_index_orders_impl( + partial_alloc + [track], + tracks_left, + num_silent_tracks_left, + track_possible_at_position, + ) + + +def all_pack_subsets(packs, tracks_left): + """implementation of pack_subsets_with_refs for when there are no pack refs""" + if tracks_left == 0: + yield [] + elif packs: + pack, *tail = packs + + n = tracks_left // len(pack.channels) + + yield from all_pack_subsets(tail, tracks_left) + + for i in range(1, n + 1): + for tail_subset in all_pack_subsets( + tail, tracks_left - i * len(pack.channels) + ): + yield [pack] * i + tail_subset + + +def pack_subsets_with_refs(packs, pack_refs, num_tracks): + """implementation of pack_subsets_with_refs for when there are pack refs""" + if not pack_refs and num_tracks == 0: + # empty valid solution + yield [] + return + + if not packs: + # no packs to allocate + return + if not pack_refs: + # no pack refs to allocate to + return + if not num_tracks: + # no tracks left to allocate to + return + + try_pack = packs[0] + new_num_tracks = num_tracks - len(try_pack.channels) + + # try allocating this pack + if new_num_tracks >= 0: + pack_refs_idx = index_by_id(try_pack.root_pack, pack_refs) + if pack_refs_idx is not None: + new_pack_refs = pack_refs[:pack_refs_idx] + pack_refs[pack_refs_idx + 1 :] + for tail_subset in pack_subsets_with_refs( + packs, new_pack_refs, new_num_tracks + ): + yield [try_pack] + tail_subset + + # or skip it + yield from pack_subsets_with_refs(packs[1:], pack_refs, num_tracks) + + +def pack_subsets(packs, pack_refs, num_tracks): + """given the list of AllocationPacks, the pack references and total number + of tracks, yield possible lists of packs to allocate + + this only yields lists of packs that are unique, i.e. not just re-orderings + of each other + """ + if pack_refs is None: + return all_pack_subsets(packs, num_tracks) + else: + return pack_subsets_with_refs(packs, pack_refs, num_tracks) diff --git a/ear/core/select_items/select_items.py b/ear/core/select_items/select_items.py index e1e66ff1..d4728d28 100644 --- a/ear/core/select_items/select_items.py +++ b/ear/core/select_items/select_items.py @@ -11,16 +11,19 @@ Frequency, TypeDefinition, ) from .pack_allocation import allocate_packs, AllocationPack, AllocationChannel, AllocationTrack -from .utils import in_by_id, object_paths_from, pack_format_paths_from +from .utils import (get_path_param, get_per_channel_param, get_single_param, + in_by_id, object_paths_from, pack_format_packs, pack_format_paths_from) from .validate import (validate_structure, validate_selected_audioTrackUID, possible_reference_errors, ) from ..metadata_input import (ExtraData, ADMPath, MetadataSourceIter, - ObjectTypeMetadata, ObjectRenderingItem, - DirectSpeakersTypeMetadata, DirectSpeakersRenderingItem, - HOATypeMetadata, HOARenderingItem, ImportanceData, - TrackSpec, DirectTrackSpec, SilentTrackSpec, - MatrixCoefficientTrackSpec, MixTrackSpec + RenderingItem, ObjectTypeMetadata, + ObjectRenderingItem, DirectSpeakersTypeMetadata, + DirectSpeakersRenderingItem, HOATypeMetadata, + HOARenderingItem, ImportanceData, TrackSpec, + DirectTrackSpec, SilentTrackSpec, + MatrixCoefficientTrackSpec, MixTrackSpec, + GainTrackSpec, ) @@ -91,7 +94,7 @@ def audioObject(self): def _select_programme(state, audio_programme=None): """Select an audioProgramme to render. - If audio_programme_id is provided, use that to make the selection, + If audio_programme is provided, use that to make the selection, otherwise select the only audioProgramme, or the one with the lowest id. Parameters: @@ -295,14 +298,15 @@ class _PackAllocator(object): audioObject) and audioTrackUIDs (in either an audioObject or CHNA-only ADM) by pack_allocation.allocate_packs. - Once an allocation has been found it needs to the real audioPackFormat and - track/channel allocation for rendering. For most types this corresponds to - the pattern, but for matrix types the mapping is more complex. + Once an allocation has been found it determines the real audioPackFormat + and track/channel allocation to be used for rendering. For most types this + is the same as the matched pattern, but for matrix types the mapping is + more complex. - To achieve this, the pack_allocation.Allocaion* classes which are used to + To achieve this, the pack_allocation.Allocation* classes which are used to specify the patterns are subclassed to allow them to store information about this mapping. The 'input' allocation being the result of the pattern - patch, and the 'output' pack/allocation being the pack and track/channel + match, and the 'output' pack/allocation being the pack and track/channel allocation for the renderer. In RegularAllocationPack (used for normal types) this is a direct mapping, @@ -382,9 +386,15 @@ def get_track_spec(channel_format): # coefficients to the track specs of their input channels and # mix them together [block_format] = channel_format.audioBlockFormats - return MixTrackSpec([ - MatrixCoefficientTrackSpec(get_track_spec(coeff.inputChannelFormat), coeff) - for coeff in block_format.matrix]) + + matrix_track_specs = [ + MatrixCoefficientTrackSpec( + get_track_spec(coeff.inputChannelFormat), coeff + ) + for coeff in block_format.matrix + ] + mixed = MixTrackSpec(matrix_track_specs) + return GainTrackSpec(mixed, block_format.gain) def get_channel_allocation(matrix_channel): [block_format] = matrix_channel.audioBlockFormats @@ -467,10 +477,12 @@ def wrap_matrix_pack(self, audioPackFormat): @classmethod def channel_format_for_track_uid(cls, audioTrackUID): - return (audioTrackUID - .audioTrackFormat - .audioStreamFormat - .audioChannelFormat) + if audioTrackUID.audioTrackFormat is not None: + return audioTrackUID.audioTrackFormat.audioStreamFormat.audioChannelFormat + elif audioTrackUID.audioChannelFormat is not None: + return audioTrackUID.audioChannelFormat + else: + assert False # checked in validation def get_selected_packs_tracks_silent(self, state): """Get the audioPackFormats, audioTrackUIDs and silent tracks @@ -585,24 +597,83 @@ def _get_adm_path(state): ) -def _get_extra_data(state): +def _get_alternativeValueSet(state): + """get the referenced AlternativeValueSet, or None""" + audioObject = state.audioObject + if audioObject is None: + return + + selected_avs = None + for referring_obj in (state.audioProgramme, state.audioContent): + if referring_obj is None: + continue + for avs in referring_obj.alternativeValueSets: + if in_by_id(avs, audioObject.alternativeValueSets): + # already checked in validation + assert ( + selected_avs is None or selected_avs is avs + ), "more than one active alternativeValueSet" + selected_avs = avs + + return selected_avs + + +def _get_extra_data(state, pack_paths_channels=None): """Get an ExtraData object for this track/channel with extra information - from the programme/object/channel needed for rendering.""" - return ExtraData( - object_start=(state.audioObject.start - if state.audioObject is not None - else None), - object_duration=(state.audioObject.duration - if state.audioObject is not None - else None), - reference_screen=(state.audioProgramme.referenceScreen - if state.audioProgramme is not None - else default_screen), - channel_frequency=(state.audioChannelFormat.frequency - if state.audioChannelFormat is not None - else Frequency()), + from the programme/object/channel needed for rendering. + + This can be used in a single or multi-channel context: + + - For single channels, pack_paths_channels is None and + state.audioPackFormat_path and state.audioChannelFormat are used. + + - For multiple channels, pack_paths_channels is a list containing one tuple + per channel, each containing a list of audioPackFormats on the path + between the root audioPackFormat and the audioChannelFormat, and the + audioChannelFormat. + """ + if pack_paths_channels is None: + assert state.audioPackFormat_path is not None + assert state.audioChannelFormat is not None + pack_paths_channels = [(state.audioPackFormat_path, state.audioChannelFormat)] + + def get_absoluteDistance(audioPackFormat_path, audioChannelFormat): + return get_path_param(audioPackFormat_path, "absoluteDistance") + + extra_data = ExtraData() + + extra_data.document_version = state.adm.version + + if state.audioProgramme is not None: + extra_data.reference_screen = state.audioProgramme.referenceScreen + + if state.audioChannelFormat is not None: + extra_data.channel_frequency = state.audioChannelFormat.frequency + + extra_data.pack_absoluteDistance = get_single_param( + pack_paths_channels, "absoluteDistance", get_absoluteDistance ) + if state.audioObject is not None: + extra_data.object_start = state.audioObject.start + extra_data.object_duration = state.audioObject.duration + + extra_data.object_gain = state.audioObject.gain + extra_data.object_mute = state.audioObject.mute + extra_data.object_positionOffset = state.audioObject.positionOffset + + avs = _get_alternativeValueSet(state) + if avs is not None: + if avs.gain is not None: + extra_data.object_gain = avs.gain + if avs.mute is not None: + extra_data.object_mute = avs.mute + if avs.positionOffset is not None: + # TODO: currently if positionOffset is defined, the whole value is overridden + extra_data.object_positionOffset = avs.positionOffset + + return extra_data + def _get_pack_format_path(audioPackFormat, audioChannelFormat): """Get the pack formats along the path from a pack format to a channel format.""" @@ -663,9 +734,17 @@ def _get_RenderingItems_DirectSpeakers(state): def _get_RenderingItems_HOA(state): """Get a HOARenderingItem given an _ItemSelectionState.""" - from .hoa import (get_single_param, get_per_channel_param, - get_nfcRefDist, get_screenRef, get_normalization, - get_order, get_degree, get_rtime, get_duration) + from .hoa import ( + get_nfcRefDist, + get_screenRef, + get_normalization, + get_order, + get_degree, + get_rtime, + get_duration, + get_gain, + get_importance, + ) states = list(_select_single_channel(state)) @@ -677,10 +756,12 @@ def _get_RenderingItems_HOA(state): duration=get_single_param(pack_paths_channels, "duration", get_duration), orders=get_per_channel_param(pack_paths_channels, get_order), degrees=get_per_channel_param(pack_paths_channels, get_degree), + gains=get_per_channel_param(pack_paths_channels, get_gain), + importances=get_per_channel_param(pack_paths_channels, get_importance), normalization=get_single_param(pack_paths_channels, "normalization", get_normalization), nfcRefDist=get_single_param(pack_paths_channels, "nfcRefDist", get_nfcRefDist), screenRef=get_single_param(pack_paths_channels, "screenRef", get_screenRef), - extra_data=_get_extra_data(state), + extra_data=_get_extra_data(state, pack_paths_channels), ) metadata_source = MetadataSourceIter([type_metadata]) @@ -711,12 +792,15 @@ def select_rendering_items(adm, Parameters: adm (ADM): ADM to process - audio_programme (AudioProgramme): audioProgramme to select if there is - more than one in adm. - selected_complementary_objects (list): see _select_complementary_objects + audio_programme (Optional[AudioProgramme]): audioProgramme to select if + there is more than one in adm. + selected_complementary_objects (list[AudioObject]): Objects to select + from each complementary group. If there is no entry for an object + in a complementary object group, then the root is selected by + default. - Yields: - RenderingItem: selected rendering items + Returns: + list[RenderingItem]: selected rendering items """ validate_structure(adm) @@ -734,3 +818,22 @@ def select_rendering_items(adm, rendering_items.append(rendering_item) return rendering_items + + +class ObjectChannelMatcher(object): + """Interface for using the _PackAllocator to find the audioChannelFormats + referenced by audioObjects. + + This doesn't do anything special or interesting, but keeps the details of + item selection inside this module. + """ + + def __init__(self, adm): + self._adm = adm + self._pack_allocator = _PackAllocator(adm) + + def get_channel_formats_for_object(self, audioObject): + state = _ItemSelectionState(adm=self._adm, audioObjects=[audioObject]) + for state in self._pack_allocator.select_pack_mapping(state): + for channelFormat, _track_spec in state.channel_allocation: + yield channelFormat diff --git a/ear/core/select_items/test/test_hoa.py b/ear/core/select_items/test/test_hoa.py index 9ce6b9f1..2c77e4ac 100644 --- a/ear/core/select_items/test/test_hoa.py +++ b/ear/core/select_items/test/test_hoa.py @@ -6,8 +6,7 @@ class HOABuilder(ADMBuilder): - """ADMBuilder with pre-defined encode and decode matrix, with references to - the various components""" + """ADMBuilder with pre-defined first and second order HOA packs""" def __init__(self): super(HOABuilder, self).__init__() @@ -127,6 +126,7 @@ def test_hoa_pack_params(): pack.normalization = "N3D" pack.nfcRefDist = 1.0 pack.screenRef = True + pack.absoluteDistance = 2.0 for i, track in enumerate(builder.first_tracks, 1): builder.create_track_uid(audioPackFormat=builder.first_pack, audioTrackFormat=track, @@ -139,3 +139,45 @@ def test_hoa_pack_params(): assert meta.normalization == "N3D" assert meta.nfcRefDist == 1.0 assert meta.screenRef is True + assert meta.extra_data.pack_absoluteDistance == 2.0 + + +def test_hoa_gains(): + builder = HOABuilder() + + gains = [0.1, 0.2, 0.3, 0.4] + + for (channel, gain) in zip(builder.first_pack.audioChannelFormats, gains): + channel.audioBlockFormats[0].gain = gain + + for i, track in enumerate(builder.first_tracks, 1): + builder.create_track_uid(audioPackFormat=builder.first_pack, audioTrackFormat=track, + trackIndex=i) + + generate_ids(builder.adm) + + [item] = select_rendering_items(builder.adm) + meta = item.metadata_source.get_next_block() + + assert meta.gains == gains + + +def test_hoa_importances(): + builder = HOABuilder() + + importances = [1, 2, 3, 4] + + for channel, importance in zip(builder.first_pack.audioChannelFormats, importances): + channel.audioBlockFormats[0].importance = importance + + for i, track in enumerate(builder.first_tracks, 1): + builder.create_track_uid( + audioPackFormat=builder.first_pack, audioTrackFormat=track, trackIndex=i + ) + + generate_ids(builder.adm) + + [item] = select_rendering_items(builder.adm) + meta = item.metadata_source.get_next_block() + + assert meta.importances == importances diff --git a/ear/core/select_items/test/test_matrix.py b/ear/core/select_items/test/test_matrix.py index 400357b8..9b7895db 100644 --- a/ear/core/select_items/test/test_matrix.py +++ b/ear/core/select_items/test/test_matrix.py @@ -1,9 +1,25 @@ import pytest from ....fileio.adm.builder import ADMBuilder +from ....fileio.adm.elements import ( + AudioBlockFormatMatrix, + FormatDefinition, + MatrixCoefficient, + TypeDefinition, +) from ....fileio.adm.generate_ids import generate_ids +from ...metadata_input import ( + DirectTrackSpec, + GainTrackSpec, + MatrixCoefficientTrackSpec, + MixTrackSpec, +) from .. import select_rendering_items -from ....fileio.adm.elements import TypeDefinition, FormatDefinition, AudioBlockFormatMatrix, MatrixCoefficient -from ...metadata_input import DirectTrackSpec, MixTrackSpec, MatrixCoefficientTrackSpec + + +def gain_and_mix(gain, *track_specs): + """mix track_specs and apply GainTrackSpec; this is the typical structure + produced for matrix""" + return GainTrackSpec(MixTrackSpec(list(track_specs)), gain) @pytest.mark.parametrize("usage", ["direct", "pre_applied", "mono"]) @@ -12,77 +28,133 @@ def test_openhouse(usage): builder.load_common_definitions() mono_pack = builder.adm["AP_00010001"] - treo_pack = builder.create_pack(audioPackFormatName="treo", type=TypeDefinition.DirectSpeakers, - audioChannelFormats=[builder.adm["AC_00010001"], - builder.adm["AC_00010002"], - builder.adm["AC_00010003"], - ]) + treo_pack = builder.create_pack( + audioPackFormatName="treo", + type=TypeDefinition.DirectSpeakers, + audioChannelFormats=[ + builder.adm["AC_00010001"], + builder.adm["AC_00010002"], + builder.adm["AC_00010003"], + ], + ) matrix_pack = builder.create_pack( - audioPackFormatName="matrix", type=TypeDefinition.Matrix, - inputPackFormat=mono_pack, outputPackFormat=treo_pack, + audioPackFormatName="matrix", + type=TypeDefinition.Matrix, + inputPackFormat=mono_pack, + outputPackFormat=treo_pack, ) + # block format gains here don't make sense, they are just for testing mc1 = MatrixCoefficient(inputChannelFormat=builder.adm["AC_00010003"], gain=0.3) channel_left = builder.create_channel( - matrix_pack, audioChannelFormatName="matrix_c1", type=TypeDefinition.Matrix, - audioBlockFormats=[AudioBlockFormatMatrix( - outputChannelFormat=builder.adm["AC_00010001"], - matrix=[mc1])]) + matrix_pack, + audioChannelFormatName="matrix_c1", + type=TypeDefinition.Matrix, + audioBlockFormats=[ + AudioBlockFormatMatrix( + outputChannelFormat=builder.adm["AC_00010001"], + matrix=[mc1], + gain=1.5, + ) + ], + ) mc2 = MatrixCoefficient(inputChannelFormat=builder.adm["AC_00010003"], gain=0.3) channel_right = builder.create_channel( - matrix_pack, audioChannelFormatName="matrix_c2", type=TypeDefinition.Matrix, - audioBlockFormats=[AudioBlockFormatMatrix( - outputChannelFormat=builder.adm["AC_00010002"], - matrix=[mc2])]) + matrix_pack, + audioChannelFormatName="matrix_c2", + type=TypeDefinition.Matrix, + audioBlockFormats=[ + AudioBlockFormatMatrix( + outputChannelFormat=builder.adm["AC_00010002"], + matrix=[mc2], + gain=2.5, + ) + ], + ) mc3 = MatrixCoefficient(inputChannelFormat=builder.adm["AC_00010003"], gain=1.0) channel_centre = builder.create_channel( - matrix_pack, audioChannelFormatName="matrix_c3", type=TypeDefinition.Matrix, - audioBlockFormats=[AudioBlockFormatMatrix( - outputChannelFormat=builder.adm["AC_00010003"], - matrix=[mc3])]) + matrix_pack, + audioChannelFormatName="matrix_c3", + type=TypeDefinition.Matrix, + audioBlockFormats=[ + AudioBlockFormatMatrix( + outputChannelFormat=builder.adm["AC_00010003"], + matrix=[mc3], + gain=3.5, + ) + ], + ) - builder.create_stream(audioStreamFormatName="left PCM", format=FormatDefinition.PCM, - audioChannelFormat=channel_left) - left_track = builder.create_track(audioTrackFormatName="left PCM", format=FormatDefinition.PCM) + builder.create_stream( + audioStreamFormatName="left PCM", + format=FormatDefinition.PCM, + audioChannelFormat=channel_left, + ) + left_track = builder.create_track( + audioTrackFormatName="left PCM", format=FormatDefinition.PCM + ) - builder.create_stream(audioStreamFormatName="right PCM", format=FormatDefinition.PCM, - audioChannelFormat=channel_right) - right_track = builder.create_track(audioTrackFormatName="right PCM", format=FormatDefinition.PCM) + builder.create_stream( + audioStreamFormatName="right PCM", + format=FormatDefinition.PCM, + audioChannelFormat=channel_right, + ) + right_track = builder.create_track( + audioTrackFormatName="right PCM", format=FormatDefinition.PCM + ) - builder.create_stream(audioStreamFormatName="centre PCM", format=FormatDefinition.PCM, - audioChannelFormat=channel_centre) - centre_track = builder.create_track(audioTrackFormatName="centre PCM", format=FormatDefinition.PCM) + builder.create_stream( + audioStreamFormatName="centre PCM", + format=FormatDefinition.PCM, + audioChannelFormat=channel_centre, + ) + centre_track = builder.create_track( + audioTrackFormatName="centre PCM", format=FormatDefinition.PCM + ) builder.create_programme(audioProgrammeName="MyProgramme") builder.create_content(audioContentName="MyContent") if usage == "direct": - builder.create_object(audioObjectName="MyObject", - audioPackFormats=[matrix_pack]) - builder.create_track_uid(trackIndex=1, - audioTrackFormat=builder.adm["AT_00010003_01"], - audioPackFormat=matrix_pack) + builder.create_object( + audioObjectName="MyObject", audioPackFormats=[matrix_pack] + ) + builder.create_track_uid( + trackIndex=1, + audioTrackFormat=builder.adm["AT_00010003_01"], + audioPackFormat=matrix_pack, + ) expected = [ - ("AC_00010001", MixTrackSpec([MatrixCoefficientTrackSpec(DirectTrackSpec(0), mc1)])), - ("AC_00010002", MixTrackSpec([MatrixCoefficientTrackSpec(DirectTrackSpec(0), mc2)])), - ("AC_00010003", MixTrackSpec([MatrixCoefficientTrackSpec(DirectTrackSpec(0), mc3)])), + ( + "AC_00010001", + gain_and_mix(1.5, MatrixCoefficientTrackSpec(DirectTrackSpec(0), mc1)), + ), + ( + "AC_00010002", + gain_and_mix(2.5, MatrixCoefficientTrackSpec(DirectTrackSpec(0), mc2)), + ), + ( + "AC_00010003", + gain_and_mix(3.5, MatrixCoefficientTrackSpec(DirectTrackSpec(0), mc3)), + ), ] elif usage == "pre_applied": - builder.create_object(audioObjectName="MyObject", - audioPackFormats=[matrix_pack]) - builder.create_track_uid(trackIndex=1, - audioTrackFormat=left_track, - audioPackFormat=matrix_pack) - builder.create_track_uid(trackIndex=2, - audioTrackFormat=right_track, - audioPackFormat=matrix_pack) - builder.create_track_uid(trackIndex=3, - audioTrackFormat=centre_track, - audioPackFormat=matrix_pack) + builder.create_object( + audioObjectName="MyObject", audioPackFormats=[matrix_pack] + ) + builder.create_track_uid( + trackIndex=1, audioTrackFormat=left_track, audioPackFormat=matrix_pack + ) + builder.create_track_uid( + trackIndex=2, audioTrackFormat=right_track, audioPackFormat=matrix_pack + ) + builder.create_track_uid( + trackIndex=3, audioTrackFormat=centre_track, audioPackFormat=matrix_pack + ) expected = [ ("AC_00010001", DirectTrackSpec(0)), @@ -90,11 +162,12 @@ def test_openhouse(usage): ("AC_00010003", DirectTrackSpec(2)), ] elif usage == "mono": - builder.create_object(audioObjectName="MyObject", - audioPackFormats=[mono_pack]) - builder.create_track_uid(trackIndex=1, - audioTrackFormat=builder.adm["AT_00010003_01"], - audioPackFormat=mono_pack) + builder.create_object(audioObjectName="MyObject", audioPackFormats=[mono_pack]) + builder.create_track_uid( + trackIndex=1, + audioTrackFormat=builder.adm["AT_00010003_01"], + audioPackFormat=mono_pack, + ) expected = [ ("AC_00010003", DirectTrackSpec(0)), ] @@ -105,8 +178,10 @@ def test_openhouse(usage): selected_items = select_rendering_items(builder.adm) - results = sorted((item.adm_path.audioChannelFormat.id, item.track_spec) - for item in selected_items) + results = sorted( + (item.adm_path.audioChannelFormat.id, item.track_spec) + for item in selected_items + ) assert results == expected @@ -123,49 +198,126 @@ def __init__(self): self.left_channel = self.adm["AC_00010001"] self.right_channel = self.adm["AC_00010002"] - self.encode_pack = self.create_pack(audioPackFormatName="m/s encode", type=TypeDefinition.Matrix, - inputPackFormat=self.stereo_pack) - self.encode_mid = self.create_channel(audioChannelFormatName="encode mid", type=TypeDefinition.Matrix, audioBlockFormats=[ - AudioBlockFormatMatrix(matrix=[ - MatrixCoefficient(inputChannelFormat=self.left_channel, gain=0.5 ** 0.5), - MatrixCoefficient(inputChannelFormat=self.right_channel, gain=0.5 ** 0.5), - ])]) - self.encode_side = self.create_channel(audioChannelFormatName="encode side", type=TypeDefinition.Matrix, audioBlockFormats=[ - AudioBlockFormatMatrix(matrix=[ - MatrixCoefficient(inputChannelFormat=self.left_channel, gain=-0.5 ** 0.5), - MatrixCoefficient(inputChannelFormat=self.right_channel, gain=0.5 ** 0.5), - ])]) - - self.decode_pack = self.create_pack(audioPackFormatName="m/s decode stereo", type=TypeDefinition.Matrix, - outputPackFormat=self.stereo_pack) - self.decode_left = self.create_channel(audioChannelFormatName="decode left", type=TypeDefinition.Matrix, audioBlockFormats=[ - AudioBlockFormatMatrix(outputChannelFormat=self.left_channel, matrix=[ - MatrixCoefficient(inputChannelFormat=self.encode_mid, gain=0.5 ** 0.5), - MatrixCoefficient(inputChannelFormat=self.encode_side, gain=-0.5 ** 0.5), - ])]) - self.decode_right = self.create_channel(audioChannelFormatName="decode right", type=TypeDefinition.Matrix, audioBlockFormats=[ - AudioBlockFormatMatrix(outputChannelFormat=self.right_channel, matrix=[ - MatrixCoefficient(inputChannelFormat=self.encode_mid, gain=0.5 ** 0.5), - MatrixCoefficient(inputChannelFormat=self.encode_side, gain=0.5 ** 0.5), - ])]) + # block format gains here don't make sense, they are just for testing + self.encode_pack = self.create_pack( + audioPackFormatName="m/s encode", + type=TypeDefinition.Matrix, + inputPackFormat=self.stereo_pack, + ) + self.encode_mid = self.create_channel( + audioChannelFormatName="encode mid", + type=TypeDefinition.Matrix, + audioBlockFormats=[ + AudioBlockFormatMatrix( + matrix=[ + MatrixCoefficient( + inputChannelFormat=self.left_channel, gain=0.5**0.5 + ), + MatrixCoefficient( + inputChannelFormat=self.right_channel, gain=0.5**0.5 + ), + ], + gain=1.5, + ) + ], + ) + self.encode_side = self.create_channel( + audioChannelFormatName="encode side", + type=TypeDefinition.Matrix, + audioBlockFormats=[ + AudioBlockFormatMatrix( + matrix=[ + MatrixCoefficient( + inputChannelFormat=self.left_channel, gain=-(0.5**0.5) + ), + MatrixCoefficient( + inputChannelFormat=self.right_channel, gain=0.5**0.5 + ), + ], + gain=2.5, + ) + ], + ) + + self.decode_pack = self.create_pack( + audioPackFormatName="m/s decode stereo", + type=TypeDefinition.Matrix, + outputPackFormat=self.stereo_pack, + ) + self.decode_left = self.create_channel( + audioChannelFormatName="decode left", + type=TypeDefinition.Matrix, + audioBlockFormats=[ + AudioBlockFormatMatrix( + outputChannelFormat=self.left_channel, + matrix=[ + MatrixCoefficient( + inputChannelFormat=self.encode_mid, gain=0.5**0.5 + ), + MatrixCoefficient( + inputChannelFormat=self.encode_side, gain=-(0.5**0.5) + ), + ], + gain=3.5, + ) + ], + ) + self.decode_right = self.create_channel( + audioChannelFormatName="decode right", + type=TypeDefinition.Matrix, + audioBlockFormats=[ + AudioBlockFormatMatrix( + outputChannelFormat=self.right_channel, + matrix=[ + MatrixCoefficient( + inputChannelFormat=self.encode_mid, gain=0.5**0.5 + ), + MatrixCoefficient( + inputChannelFormat=self.encode_side, gain=0.5**0.5 + ), + ], + gain=4.5, + ) + ], + ) self.decode_pack.encodePackFormats = [self.encode_pack] - self.create_stream(audioStreamFormatName="mid PCM", format=FormatDefinition.PCM, - audioChannelFormat=self.encode_mid) - self.mid_track = self.create_track(audioTrackFormatName="mid PCM", format=FormatDefinition.PCM) - - self.create_stream(audioStreamFormatName="side PCM", format=FormatDefinition.PCM, - audioChannelFormat=self.encode_side) - self.side_track = self.create_track(audioTrackFormatName="side PCM", format=FormatDefinition.PCM) - - self.create_stream(audioStreamFormatName="decode left PCM", format=FormatDefinition.PCM, - audioChannelFormat=self.decode_left) - self.decode_left_track = self.create_track(audioTrackFormatName="decode left PCM", format=FormatDefinition.PCM) - - self.create_stream(audioStreamFormatName="decode right PCM", format=FormatDefinition.PCM, - audioChannelFormat=self.decode_right) - self.decode_right_track = self.create_track(audioTrackFormatName="decode right PCM", format=FormatDefinition.PCM) + self.create_stream( + audioStreamFormatName="mid PCM", + format=FormatDefinition.PCM, + audioChannelFormat=self.encode_mid, + ) + self.mid_track = self.create_track( + audioTrackFormatName="mid PCM", format=FormatDefinition.PCM + ) + + self.create_stream( + audioStreamFormatName="side PCM", + format=FormatDefinition.PCM, + audioChannelFormat=self.encode_side, + ) + self.side_track = self.create_track( + audioTrackFormatName="side PCM", format=FormatDefinition.PCM + ) + + self.create_stream( + audioStreamFormatName="decode left PCM", + format=FormatDefinition.PCM, + audioChannelFormat=self.decode_left, + ) + self.decode_left_track = self.create_track( + audioTrackFormatName="decode left PCM", format=FormatDefinition.PCM + ) + + self.create_stream( + audioStreamFormatName="decode right PCM", + format=FormatDefinition.PCM, + audioChannelFormat=self.decode_right, + ) + self.decode_right_track = self.create_track( + audioTrackFormatName="decode right PCM", format=FormatDefinition.PCM + ) @pytest.mark.parametrize("usage", ["decode", "pre_decoded", "stereo", "encode_decode"]) @@ -180,48 +332,79 @@ def matrix(channel_format): builder.create_content(audioContentName="MyContent") if usage == "decode": - builder.create_object(audioObjectName="MyObject", - audioPackFormats=[builder.decode_pack]) - builder.create_track_uid(trackIndex=1, - audioTrackFormat=builder.mid_track, - audioPackFormat=builder.decode_pack) - builder.create_track_uid(trackIndex=2, - audioTrackFormat=builder.side_track, - audioPackFormat=builder.decode_pack) + builder.create_object( + audioObjectName="MyObject", audioPackFormats=[builder.decode_pack] + ) + builder.create_track_uid( + trackIndex=1, + audioTrackFormat=builder.mid_track, + audioPackFormat=builder.decode_pack, + ) + builder.create_track_uid( + trackIndex=2, + audioTrackFormat=builder.side_track, + audioPackFormat=builder.decode_pack, + ) expected = [ - (builder.left_channel.id, MixTrackSpec([ - MatrixCoefficientTrackSpec(DirectTrackSpec(0), matrix(builder.decode_left)[0]), - MatrixCoefficientTrackSpec(DirectTrackSpec(1), matrix(builder.decode_left)[1]), - ])), - (builder.right_channel.id, MixTrackSpec([ - MatrixCoefficientTrackSpec(DirectTrackSpec(0), matrix(builder.decode_right)[0]), - MatrixCoefficientTrackSpec(DirectTrackSpec(1), matrix(builder.decode_right)[1]), - ])), + ( + builder.left_channel.id, + gain_and_mix( + 3.5, + MatrixCoefficientTrackSpec( + DirectTrackSpec(0), matrix(builder.decode_left)[0] + ), + MatrixCoefficientTrackSpec( + DirectTrackSpec(1), matrix(builder.decode_left)[1] + ), + ), + ), + ( + builder.right_channel.id, + gain_and_mix( + 4.5, + MatrixCoefficientTrackSpec( + DirectTrackSpec(0), matrix(builder.decode_right)[0] + ), + MatrixCoefficientTrackSpec( + DirectTrackSpec(1), matrix(builder.decode_right)[1] + ), + ), + ), ] elif usage == "pre_decoded": - builder.create_object(audioObjectName="MyObject", - audioPackFormats=[builder.decode_pack]) - builder.create_track_uid(trackIndex=1, - audioTrackFormat=builder.decode_left_track, - audioPackFormat=builder.decode_pack) - builder.create_track_uid(trackIndex=2, - audioTrackFormat=builder.decode_right_track, - audioPackFormat=builder.decode_pack) + builder.create_object( + audioObjectName="MyObject", audioPackFormats=[builder.decode_pack] + ) + builder.create_track_uid( + trackIndex=1, + audioTrackFormat=builder.decode_left_track, + audioPackFormat=builder.decode_pack, + ) + builder.create_track_uid( + trackIndex=2, + audioTrackFormat=builder.decode_right_track, + audioPackFormat=builder.decode_pack, + ) expected = [ (builder.left_channel.id, DirectTrackSpec(0)), (builder.right_channel.id, DirectTrackSpec(1)), ] elif usage == "stereo": - builder.create_object(audioObjectName="MyObject", - audioPackFormats=[builder.stereo_pack]) - builder.create_track_uid(trackIndex=1, - audioTrackFormat=builder.adm["AT_00010001_01"], - audioPackFormat=builder.stereo_pack) - builder.create_track_uid(trackIndex=2, - audioTrackFormat=builder.adm["AT_00010002_01"], - audioPackFormat=builder.stereo_pack) + builder.create_object( + audioObjectName="MyObject", audioPackFormats=[builder.stereo_pack] + ) + builder.create_track_uid( + trackIndex=1, + audioTrackFormat=builder.adm["AT_00010001_01"], + audioPackFormat=builder.stereo_pack, + ) + builder.create_track_uid( + trackIndex=2, + audioTrackFormat=builder.adm["AT_00010002_01"], + audioPackFormat=builder.stereo_pack, + ) expected = [ (builder.left_channel.id, DirectTrackSpec(0)), @@ -229,33 +412,60 @@ def matrix(channel_format): ] elif usage == "encode_decode": - builder.create_object(audioObjectName="MyObject", - audioPackFormats=[builder.decode_pack]) - builder.create_track_uid(trackIndex=1, - audioTrackFormat=builder.adm["AT_00010001_01"], - audioPackFormat=builder.encode_pack) - builder.create_track_uid(trackIndex=2, - audioTrackFormat=builder.adm["AT_00010002_01"], - audioPackFormat=builder.encode_pack) + builder.create_object( + audioObjectName="MyObject", audioPackFormats=[builder.decode_pack] + ) + builder.create_track_uid( + trackIndex=1, + audioTrackFormat=builder.adm["AT_00010001_01"], + audioPackFormat=builder.encode_pack, + ) + builder.create_track_uid( + trackIndex=2, + audioTrackFormat=builder.adm["AT_00010002_01"], + audioPackFormat=builder.encode_pack, + ) input_ts = [DirectTrackSpec(0), DirectTrackSpec(1)] encoded_ts = [ - MixTrackSpec([MatrixCoefficientTrackSpec(input_ts[0], matrix(builder.encode_mid)[0]), - MatrixCoefficientTrackSpec(input_ts[1], matrix(builder.encode_mid)[1])]), - MixTrackSpec([MatrixCoefficientTrackSpec(input_ts[0], matrix(builder.encode_side)[0]), - MatrixCoefficientTrackSpec(input_ts[1], matrix(builder.encode_side)[1])]), + gain_and_mix( + 1.5, + MatrixCoefficientTrackSpec(input_ts[0], matrix(builder.encode_mid)[0]), + MatrixCoefficientTrackSpec(input_ts[1], matrix(builder.encode_mid)[1]), + ), + gain_and_mix( + 2.5, + MatrixCoefficientTrackSpec(input_ts[0], matrix(builder.encode_side)[0]), + MatrixCoefficientTrackSpec(input_ts[1], matrix(builder.encode_side)[1]), + ), ] expected = [ - (builder.left_channel.id, MixTrackSpec([ - MatrixCoefficientTrackSpec(encoded_ts[0], matrix(builder.decode_left)[0]), - MatrixCoefficientTrackSpec(encoded_ts[1], matrix(builder.decode_left)[1]), - ])), - (builder.right_channel.id, MixTrackSpec([ - MatrixCoefficientTrackSpec(encoded_ts[0], matrix(builder.decode_right)[0]), - MatrixCoefficientTrackSpec(encoded_ts[1], matrix(builder.decode_right)[1]), - ])), + ( + builder.left_channel.id, + gain_and_mix( + 3.5, + MatrixCoefficientTrackSpec( + encoded_ts[0], matrix(builder.decode_left)[0] + ), + MatrixCoefficientTrackSpec( + encoded_ts[1], matrix(builder.decode_left)[1] + ), + ), + ), + ( + builder.right_channel.id, + gain_and_mix( + 4.5, + MatrixCoefficientTrackSpec( + encoded_ts[0], matrix(builder.decode_right)[0] + ), + MatrixCoefficientTrackSpec( + encoded_ts[1], matrix(builder.decode_right)[1] + ), + ), + ), ] else: assert False @@ -264,7 +474,9 @@ def matrix(channel_format): selected_items = select_rendering_items(builder.adm) - results = sorted((item.adm_path.audioChannelFormat.id, item.track_spec) - for item in selected_items) + results = sorted( + (item.adm_path.audioChannelFormat.id, item.track_spec) + for item in selected_items + ) assert results == expected diff --git a/ear/core/select_items/test/test_pack_allocation.py b/ear/core/select_items/test/test_pack_allocation.py index ff8c1c5e..847098ba 100644 --- a/ear/core/select_items/test/test_pack_allocation.py +++ b/ear/core/select_items/test/test_pack_allocation.py @@ -1,21 +1,157 @@ from ..pack_allocation import allocate_packs, AllocationPack, AllocationChannel, AllocationTrack, AllocatedPack +from ..pack_allocation_alternative import allocate_packs as allocate_packs_alt +from ..utils import index_by_id import pytest -p1, p2 = "p1", "p2" + +p1, p2, p3 = "p1", "p2", "p3" c1, c2, c3, c4, c5 = "c1", "c2", "c3", "c4", "c5" -def allocation_eq(a, b): - if type(a) is not type(b): - return False - if isinstance(a, list): - return len(a) == len(b) and all(allocation_eq(ai, bi) for ai, bi in zip(a, b)) - if isinstance(a, AllocatedPack): - return (a.pack is b.pack and - len(a.allocation) == len(b.allocation) and - all(channel_a is channel_b and track_a is track_b - for (channel_a, track_a), (channel_b, track_b) in zip(a.allocation, b.allocation))) - assert False +def pack_allocation_to_ids(pack: AllocatedPack): + """turn an AllocatedPack into a structure of object IDs that can be + compared/sorted normally""" + return id(pack.pack), [ + (id(channel), id(track)) for channel, track in pack.allocation + ] + + +def normalise_allocation(allocation): + """normalise an allocation by sorting the packs, so that equal allocations + are equal""" + return sorted(allocation, key=pack_allocation_to_ids) + + +def normalise_allocations(allocations): + """normalise a list of allocations, so that equal lists of allocations are + equal""" + normalised_allocations = [ + normalise_allocation(allocation) for allocation in allocations + ] + + return sorted( + normalised_allocations, + key=lambda allocation: [pack_allocation_to_ids(pack) for pack in allocation], + ) + + +def allocations_eq(a, b): + """are two allocations equal?""" + return normalise_allocations(a) == normalise_allocations(b) + + +def allocate_packs_reverse(packs, tracks, pack_refs, num_silent_tracks): + """call allocate_packs with reversed tracks, which should give the same + result""" + return allocate_packs(packs, tracks[::-1], pack_refs, num_silent_tracks) + + +# test these implementations against each other +default_implementations = [ + ("real", allocate_packs), + ("reverse", allocate_packs_reverse), + ("alternative", allocate_packs_alt), +] + +fast_implementations = [ + (name, fn) for (name, fn) in default_implementations if name != "alternative" +] + + +def print_problem(allocation_args, indent): + packs, tracks, pack_refs, num_silent = allocation_args + + def p(this_indent, s): + print(" " * (indent + this_indent) + str(s)) + + p(0, "packs:") + for pack in packs: + p(1, pack.root_pack) + for channel in pack.channels: + p(2, channel) + p(0, "tracks:") + for track in tracks: + p(1, track) + p(0, f"pack_refs: {pack_refs}") + p(0, f"num_silent: {num_silent}") + + +def print_solution(allocation_args, soln, indent): + packs, tracks, pack_refs, num_silent = allocation_args + + def p(this_indent, s): + print(" " * (indent + this_indent) + str(s)) + + for pack in soln: + p(0, f"{index_by_id(pack.pack, packs)} {pack.pack.root_pack}") + for channel, track in pack.allocation: + p(1, f"channel {index_by_id(channel, pack.pack.channels)} {channel}") + + track_id = "silent" if track is None else index_by_id(track, tracks) + p(2, f"track {track_id} {track}") + + +def check_allocation( + allocation_args, + count=None, + expected=None, + implementations=default_implementations, +): + """given the arguments to allocate_packs (packs, tracks, pack_refs, + num_silent_tracks), check the number of results (count) or actual results + (expected) for all implementations, and check that all implementations + return the same results + """ + + def check_result(impl_name, result): + if count is not None: + assert ( + len(result) == count + ), f"{impl_name} returned the wrong number of results" + if expected is not None: + assert allocations_eq( + result, expected + ), f"{impl_name} returned an incorrect allocation" + + # TODO: check that the result actually meets the requirements, to + # improve count-only tests + + results = [] + for impl_name, impl in implementations: + result = list(impl(*allocation_args)) + + results.append((impl_name, result)) + + try: + for impl_name, result in results: + check_result(impl_name, result) + + first_impl_name, first_result = results[0] + for impl_name, result in results[1:]: + assert allocations_eq(first_result, result) + + except AssertionError as e: + + def first_matching_solution(to_find, results): + for impl_name, result in results: + if allocations_eq(result, to_find): + return impl_name + + print("problem:") + print_problem(allocation_args, 1) + for result_i, (impl_name, result) in enumerate(results): + print(f"solutions for {impl_name}") + matching = first_matching_solution(result, results[:result_i]) + if matching is not None: + print(f" same as {matching}") + else: + for i, soln in enumerate(result): + print(f" {i}:") + print_solution(allocation_args, soln, 2) + + raise + + return results[0][1] @pytest.mark.parametrize("packs", @@ -36,25 +172,31 @@ def allocation_eq(a, b): ]) def test_simple_one_track(packs, tracks, num_silent_tracks, expected_track): # one pack reference specified; one solution - assert allocation_eq( - list(allocate_packs(packs, tracks, [p1], num_silent_tracks)), - [[AllocatedPack(packs[0], [(packs[0].channels[0], expected_track(tracks))])]]) + check_allocation( + (packs, tracks, [p1], num_silent_tracks), + expected=[ + [AllocatedPack(packs[0], [(packs[0].channels[0], expected_track(tracks))])] + ], + ) # two pack references specified, no solutions - assert allocation_eq( - list(allocate_packs(packs, tracks, [p1, p1], num_silent_tracks)), - []) + check_allocation((packs, tracks, [p1, p1], num_silent_tracks), expected=[]) # zero pack references; no solution - assert allocation_eq( - list(allocate_packs(packs, tracks, [], num_silent_tracks)), - []) + check_allocation((packs, tracks, [], num_silent_tracks), expected=[]) # no pack references; one solution as long as there are no silent tracks if num_silent_tracks == 0: - assert allocation_eq( - list(allocate_packs(packs, tracks, None, num_silent_tracks)), - [[AllocatedPack(packs[0], [(packs[0].channels[0], expected_track(tracks))])]]) + check_allocation( + (packs, tracks, None, num_silent_tracks), + expected=[ + [ + AllocatedPack( + packs[0], [(packs[0].channels[0], expected_track(tracks))] + ) + ] + ], + ) @pytest.mark.parametrize("tracks", @@ -96,26 +238,18 @@ def test_nested(tracks, num_silent_tracks): expected = [AllocatedPack(packs[1], expected_allocation)] # one correct pack reference; one solution - assert allocation_eq( - list(allocate_packs(packs, tracks, [p2], num_silent_tracks)), - [expected]) + check_allocation((packs, tracks, [p2], num_silent_tracks), expected=[expected]) # no pack references; one solution. Note that cases there num_silent_tracks # > 0 never happen in real use, as the CHNA chunk doesn't specify silent # tracks. - assert allocation_eq( - list(allocate_packs(packs, tracks, None, num_silent_tracks)), - [expected]) + check_allocation((packs, tracks, None, num_silent_tracks), expected=[expected]) # zero pack references; no solution - assert allocation_eq( - list(allocate_packs(packs, tracks, [], num_silent_tracks)), - []) + check_allocation((packs, tracks, [], num_silent_tracks), expected=[]) # reference to sub-pack; no solution - assert allocation_eq( - list(allocate_packs(packs, tracks, [p1], num_silent_tracks)), - []) + check_allocation((packs, tracks, [p1], num_silent_tracks), expected=[]) def test_multiple_identical_mono(): @@ -132,32 +266,32 @@ def test_multiple_identical_mono(): ] # two identical mono tracks - expected = [AllocatedPack(packs[1], [(packs[1].channels[0], tracks[0])]), - AllocatedPack(packs[1], [(packs[1].channels[0], tracks[1])])] - assert allocation_eq( - list(allocate_packs(packs, tracks, [p2, p2], 0)), - [expected]) + expected = [ + AllocatedPack(packs[1], [(packs[1].channels[0], tracks[0])]), + AllocatedPack(packs[1], [(packs[1].channels[0], tracks[1])]), + ] + check_allocation((packs, tracks, [p2, p2], 0), expected=[expected]) # two identical silent mono tracks - expected = [AllocatedPack(packs[1], [(packs[1].channels[0], None)]), - AllocatedPack(packs[1], [(packs[1].channels[0], None)])] - assert allocation_eq( - list(allocate_packs(packs, [], [p2, p2], 2)), - [expected]) + expected = [ + AllocatedPack(packs[1], [(packs[1].channels[0], None)]), + AllocatedPack(packs[1], [(packs[1].channels[0], None)]), + ] + check_allocation((packs, [], [p2, p2], 2), expected=[expected]) # one real, one silent - expected = [AllocatedPack(packs[1], [(packs[1].channels[0], tracks[0])]), - AllocatedPack(packs[1], [(packs[1].channels[0], None)])] - assert allocation_eq( - list(allocate_packs(packs, tracks[:1], [p2, p2], 1)), - [expected]) + expected = [ + AllocatedPack(packs[1], [(packs[1].channels[0], tracks[0])]), + AllocatedPack(packs[1], [(packs[1].channels[0], None)]), + ] + check_allocation((packs, tracks[:1], [p2, p2], 1), expected=[expected]) # chna-only case - expected = [AllocatedPack(packs[1], [(packs[1].channels[0], tracks[0])]), - AllocatedPack(packs[1], [(packs[1].channels[0], tracks[1])])] - assert allocation_eq( - list(allocate_packs(packs, tracks, None, 0)), - [expected]) + expected = [ + AllocatedPack(packs[1], [(packs[1].channels[0], tracks[0])]), + AllocatedPack(packs[1], [(packs[1].channels[0], tracks[1])]), + ] + check_allocation((packs, tracks, None, 0), expected=[expected]) # duplicate stereo is still ambiguous tracks = [ @@ -166,14 +300,19 @@ def test_multiple_identical_mono(): AllocationTrack(c1, p1), AllocationTrack(c2, p1), ] - assert len(list(allocate_packs(packs, tracks, [p1, p1], 0))) == 2 - assert len(list(allocate_packs(packs, tracks, None, 0))) == 2 + check_allocation((packs, tracks, [p1, p1], 0), count=2) + check_allocation((packs, tracks, None, 0), count=2) @pytest.mark.parametrize("channels", [[c1], [c1, c2]], ids=["mono", "stereo"]) @pytest.mark.parametrize("silent", [True, False]) -def test_many_packs(channels, silent): +@pytest.mark.parametrize( + "allocate_packs_impl", + [fn for (name, fn) in fast_implementations], + ids=[name for (name, fn) in fast_implementations], +) +def test_many_packs(channels, silent, allocate_packs_impl): """Test allocating lots of packs, to check that it's not unreasonably slow.""" packs = [ AllocationPack(p1, [AllocationChannel(channel, [p1]) @@ -192,7 +331,7 @@ def test_many_packs(channels, silent): for channel in channels] num_silent = 0 - res = allocate_packs(packs, tracks, pack_refs, num_silent) + res = allocate_packs_impl(packs, tracks, pack_refs, num_silent) res1 = next(res, None) res2 = next(res, None) @@ -222,9 +361,198 @@ def test_lots_of_channels(chna_only): for pack in [p1, p2] for channel in channels] - res = allocate_packs(packs, tracks, None if chna_only else pack_refs, 0) - res1 = next(res, None) - res2 = next(res, None) + res = check_allocation( + (packs, tracks, None if chna_only else pack_refs, 0), + count=1, + implementations=fast_implementations, + ) - assert res1 is not None - assert res2 is None + +def test_silent_stereo_51(): + """stereo and 5.1 pack structure, with all silent channels""" + packs = [ + AllocationPack(p1, [AllocationChannel(c1, [p1]), AllocationChannel(c2, [p1])]), + AllocationPack( + p2, + [ + AllocationChannel(c1, [p2, p1]), + AllocationChannel(c2, [p2, p1]), + AllocationChannel(c3, [p2]), + AllocationChannel(c4, [p2]), + AllocationChannel(c5, [p2]), + ], + ), + ] + + check_allocation((packs, [], [p1, p2], 7), count=1) + + +def test_onlysilent_simple(): + """simplified version of test_silent_stereo_51""" + packs = [ + AllocationPack(p1, [AllocationChannel(c1, [p1])]), + AllocationPack(p2, [AllocationChannel(c1, [p2])]), + ] + + check_allocation((packs, [], [p1, p2], 2), count=1) + + +def test_duplicate_packs_different_channels(): + """test duplicate pack IDs with different channels""" + packs = [ + AllocationPack(p1, [AllocationChannel(c1, [p1])]), + AllocationPack(p1, [AllocationChannel(c2, [p1])]), + ] + + tracks = [AllocationTrack(c1, p1), AllocationTrack(c2, p1)] + pack_refs = [p1, p1] + + check_allocation((packs, tracks, pack_refs, 0), count=1) + + +def test_duplicate_packs(): + """duplicate pack IDs and channels""" + packs = [ + AllocationPack(p1, [AllocationChannel(c1, [p1])]), + AllocationPack(p1, [AllocationChannel(c1, [p1])]), + ] + + tracks = [AllocationTrack(c1, p1), AllocationTrack(c1, p1)] + + check_allocation((packs, tracks, [p1, p1], 0), count=4) + + +# tests below represent interesting cases from randomised testing on buggy +# implementations during development + + +def test_complex_pack_order(): + """randomised test with three packs and ambiguity""" + packs = [ + AllocationPack( + p1, + [ + AllocationChannel(channel_format=c4, pack_formats=[p1, p2]), + AllocationChannel(channel_format=c3, pack_formats=[p2]), + ], + ), + AllocationPack( + p3, + [ + AllocationChannel(channel_format=c5, pack_formats=[p2]), + AllocationChannel(channel_format=c2, pack_formats=[p1, p2]), + AllocationChannel(channel_format=c4, pack_formats=[p1, p3]), + ], + ), + AllocationPack( + p2, + [ + AllocationChannel(channel_format=c1, pack_formats=[p1, p3]), + AllocationChannel(channel_format=c2, pack_formats=[p1, p3]), + ], + ), + ] + tracks = [ + AllocationTrack(channel_format=c1, pack_format=p1), + AllocationTrack(channel_format=c2, pack_format=p3), + AllocationTrack(channel_format=c2, pack_format=p1), + AllocationTrack(channel_format=c4, pack_format=p2), + ] + pack_refs = [p1, p2, p2] + num_silent = 2 + + check_allocation((packs, tracks, pack_refs, num_silent), count=2) + + +def test_ambiguous_pack_silent(): + """randomised test with ambiguity between two identically-named packs""" + packs = [ + AllocationPack( + p1, + [ + AllocationChannel(channel_format=c1, pack_formats=[p1]), + ], + ), + AllocationPack( + p1, + [ + AllocationChannel(channel_format=c2, pack_formats=[p1]), + ], + ), + ] + tracks = [ + AllocationTrack(channel_format=c1, pack_format=p1), + ] + pack_refs = [p1, p1] + num_silent = 1 + + check_allocation((packs, tracks, pack_refs, num_silent), count=2) + + +def test_one_silent_in_two_identical_pack_refs(): + """randomised test with duplicate packs, one silent channel each (but not + the first channel), but not actually ambiguous""" + packs = [ + AllocationPack( + p1, + [ + AllocationChannel(channel_format=c1, pack_formats=[p1]), + AllocationChannel(channel_format=c2, pack_formats=[p1]), + ], + ), + ] + tracks = [ + AllocationTrack(channel_format=c2, pack_format=p1), + AllocationTrack(channel_format=c2, pack_format=p1), + ] + pack_refs = [p1, p1] + num_silent = 2 + + check_allocation((packs, tracks, pack_refs, num_silent), count=1) + + +def test_random_packs(): + """test randomly generated allocation problems + + this is not guided in any way, so most cases have no solutions, but this is + useful to find new situations that we wouldn't otherwise consider + """ + from random import seed, randrange, sample, choice + + available_packs = [p1, p2, p3] + available_channels = [c1, c2, c3, c4, c5] + + def make_pack(): + return AllocationPack( + choice(available_packs), + [ + AllocationChannel(channel, sample(available_packs, randrange(1, 3))) + for channel in sample(available_channels, randrange(1, 4)) + ], + ) + + counts = {} + # this is not really enough to find anything interesting, but keeping it + # enabled to make sure it works if needed + for i in range(5000): + seed(i) + packs = [make_pack() for i in range(randrange(1, 4))] + tracks = [ + AllocationTrack(choice(available_channels), choice(available_packs)) + for i in range(randrange(0, 5)) + ] + + chna_only = choice([False, True]) + if chna_only: + pack_refs = None + num_silent = 0 + else: + pack_refs = [choice(available_packs) for i in range(randrange(0, 4))] + num_silent = randrange(0, 3) + + results = check_allocation((packs, tracks, pack_refs, num_silent)) + + counts.setdefault(len(results), 0) + counts[len(results)] += 1 + + print("result counts", counts) diff --git a/ear/core/select_items/test/test_select_items.py b/ear/core/select_items/test/test_select_items.py index 45b12e18..5a819b48 100644 --- a/ear/core/select_items/test/test_select_items.py +++ b/ear/core/select_items/test/test_select_items.py @@ -1,10 +1,23 @@ import pytest +from ....common import PolarPosition, PolarScreen from ....fileio.adm.builder import ADMBuilder from ....fileio.adm.generate_ids import generate_ids from .. import select_rendering_items -from ....fileio.adm.elements import TypeDefinition +from ....fileio.adm.elements import ( + AlternativeValueSet, + AudioBlockFormatDirectSpeakers, + AudioBlockFormatObjects, + BoundCoordinate, + DirectSpeakerPolarPosition, + Frequency, + ObjectPolarPosition, + PolarPositionOffset, + TypeDefinition, +) +from ....fileio.adm.elements.version import BS2076Version from ....fileio.adm.exceptions import AdmError from ...metadata_input import DirectTrackSpec, SilentTrackSpec +from fractions import Fraction def test_basic(): @@ -403,3 +416,217 @@ def test_silent_track_stereo(): assert item_l.adm_path.audioChannelFormat is builder.adm["AC_00010001"] assert item_r.track_spec == SilentTrackSpec() assert item_r.adm_path.audioChannelFormat is builder.adm["AC_00010002"] + + +def test_absoluteDisance_objects(): + builder = ADMBuilder() + builder.load_common_definitions() + builder.create_programme(audioProgrammeName="MyProgramme") + builder.create_content(audioContentName="MyContent") + + obj = builder.create_item_objects( + 0, + "obj", + block_formats=[ + AudioBlockFormatObjects(position=ObjectPolarPosition(0.0, 0.0, 1.0)), + ], + ) + + # replace pack formats with two nested formats + pack_1 = builder.create_pack( + audioPackFormatName="pack_1", type=TypeDefinition.Objects, parent=None + ) + pack_2 = builder.create_pack( + audioPackFormatName="pack_2", + type=TypeDefinition.Objects, + parent=pack_1, + audioChannelFormats=[obj.channel_format], + ) + + obj.audio_object.audioPackFormats = [pack_1] + obj.track_uid.audioPackFormat = pack_1 + + # selection should work + generate_ids(builder.adm) + [item] = select_rendering_items(builder.adm) + + def extra_data(item): + return item.metadata_source.get_next_block().extra_data + + # absoluteDistance should be picked up from either pack + for pack in pack_1, pack_2: + pack_1.absoluteDistance = None + pack_2.absoluteDistance = None + pack.absoluteDistance = 5.0 + + [item] = select_rendering_items(builder.adm) + assert extra_data(item).pack_absoluteDistance == 5.0 + + # if they are both specified but the same that's ok too + pack_1.absoluteDistance = 5.0 + pack_2.absoluteDistance = 5.0 + [item] = select_rendering_items(builder.adm) + assert extra_data(item).pack_absoluteDistance == 5.0 + + # if they are different you get an error + pack_1.absoluteDistance = 5.0 + pack_2.absoluteDistance = 2.0 + + expected = ( + f"Conflicting absoluteDistance values in path from {pack_1.id} to {pack_2.id}" + ) + with pytest.raises(AdmError, match=expected): + select_rendering_items(builder.adm) + + +def test_object_params(): + builder = ADMBuilder.for_version(2) + builder.load_common_definitions() + builder.create_programme(audioProgrammeName="MyProgramme") + builder.create_content(audioContentName="MyContent") + + obj = builder.create_item_objects( + 0, + "obj", + block_formats=[ + AudioBlockFormatObjects(position=ObjectPolarPosition(0.0, 0.0, 1.0)), + ], + ) + + # defaults + [item] = select_rendering_items(builder.adm) + extra_data = item.metadata_source.get_next_block().extra_data + + assert extra_data.object_gain == 1.0 + assert extra_data.object_mute is False + assert extra_data.object_start is None + assert extra_data.object_duration is None + + # non-default + obj.audio_object.gain = 1.5 + obj.audio_object.mute = True + obj.audio_object.start = Fraction(1) + obj.audio_object.duration = Fraction(2) + + [item] = select_rendering_items(builder.adm) + extra_data = item.metadata_source.get_next_block().extra_data + + assert extra_data.object_gain == 1.5 + assert extra_data.object_mute is True + assert extra_data.object_start == Fraction(1) + assert extra_data.object_duration == Fraction(2) + + +def test_extra_data(): + """ExtraData content not in other tests""" + builder = ADMBuilder.for_version(2) + builder.load_common_definitions() + prog = builder.create_programme(audioProgrammeName="MyProgramme") + builder.create_content(audioContentName="MyContent") + + obj = builder.create_item_direct_speakers( + 0, + "obj", + block_formats=[ + AudioBlockFormatDirectSpeakers( + position=DirectSpeakerPolarPosition( + bounded_azimuth=BoundCoordinate(0.0), + bounded_elevation=BoundCoordinate(0.0), + ), + speakerLabel=["LFE1"], + ), + ], + ) + + # defaults + [item] = select_rendering_items(builder.adm) + extra_data = item.metadata_source.get_next_block().extra_data + + assert extra_data.document_version == BS2076Version(2) + assert extra_data.channel_frequency == Frequency() + assert extra_data.reference_screen == prog.referenceScreen + + # non-default + obj.channel_format.frequency = Frequency(lowPass=120.0) + + screen = PolarScreen( + aspectRatio=1.0, + centrePosition=PolarPosition(azimuth=0.0, elevation=0.0, distance=1.0), + widthAzimuth=58.0, + ) + prog.referenceScreen = screen + + [item] = select_rendering_items(builder.adm) + extra_data = item.metadata_source.get_next_block().extra_data + + assert extra_data.channel_frequency == Frequency(lowPass=120.0) + assert extra_data.reference_screen == screen + + +def test_alternativeValueSet(): + builder = ADMBuilder() + builder.load_common_definitions() + programme = builder.create_programme(audioProgrammeName="MyProgramme") + content = builder.create_content(audioContentName="MyContent") + + obj = builder.create_item_objects( + 0, + "obj", + block_formats=[ + AudioBlockFormatObjects(position=ObjectPolarPosition(0.0, 0.0, 1.0)), + ], + ) + + avs = AlternativeValueSet( + gain=1.5, mute=True, positionOffset=PolarPositionOffset(azimuth=20.0) + ) + obj.audio_object.alternativeValueSets = [avs] + obj.audio_object.positionOffset = PolarPositionOffset(elevation=10.0) + + def check_avs_used(extra_data): + assert extra_data.object_gain == avs.gain + assert extra_data.object_mute == avs.mute + assert extra_data.object_positionOffset == avs.positionOffset + + def check_avs_not_used(extra_data): + assert extra_data.object_gain == 1.0 + assert extra_data.object_mute is False + assert extra_data.object_positionOffset == obj.audio_object.positionOffset + + # not referenced + [item] = select_rendering_items(builder.adm) + extra_data = item.metadata_source.get_next_block().extra_data + + check_avs_not_used(extra_data) + + # programme reference + programme.alternativeValueSets = [avs] + content.alternativeValueSets = [] + [item] = select_rendering_items(builder.adm) + extra_data = item.metadata_source.get_next_block().extra_data + + check_avs_used(extra_data) + + # content reference + programme.alternativeValueSets = [] + content.alternativeValueSets = [avs] + [item] = select_rendering_items(builder.adm) + extra_data = item.metadata_source.get_next_block().extra_data + + check_avs_used(extra_data) + + +def test_trackUID_channelFormat_reference(): + builder = ADMBuilder.for_version(2) + programme = builder.create_programme(audioProgrammeName="MyProgramme") + content = builder.create_content(audioContentName="MyContent", parent=programme) + item = builder.create_item_objects( + 1, "MyObject 1", parent=content, block_formats=[] + ) + generate_ids(builder.adm) + + selected_items = select_rendering_items(builder.adm) + + assert len(selected_items) == 1 + assert selected_items[0].track_spec == DirectTrackSpec(1) + assert selected_items[0].adm_path.audioChannelFormat == item.channel_format diff --git a/ear/core/select_items/test/test_validation.py b/ear/core/select_items/test/test_validation.py index 5a61574d..136f4997 100644 --- a/ear/core/select_items/test/test_validation.py +++ b/ear/core/select_items/test/test_validation.py @@ -1,9 +1,17 @@ from fractions import Fraction import pytest from ....fileio.adm.builder import ADMBuilder -from ....fileio.adm.elements import (TypeDefinition, Frequency, - AudioBlockFormatHoa, AudioBlockFormatObjects, - ObjectPolarPosition, ObjectCartesianPosition) +from ....fileio.adm.elements import ( + AlternativeValueSet, + AudioBlockFormatHoa, + AudioBlockFormatObjects, + Frequency, + ObjectCartesianPosition, + ObjectPolarPosition, + PolarPositionOffset, + TypeDefinition, +) +from ....fileio.adm.elements.version import NoVersion from ....fileio.adm.exceptions import AdmError from ....fileio.adm.generate_ids import generate_ids from ....fileio.adm.exceptions import AdmFormatRefError @@ -42,6 +50,34 @@ def test_object_loop_exception(): "loop detected in audioObjects: .*$") +@pytest.mark.parametrize( + "param_name,adm_name,value", + [ + ("start", "start", Fraction(1)), + ("duration", "duration", Fraction(1)), + ("gain", "gain", 0.5), + ("mute", "mute", True), + ("positionOffset", "positionOffset", PolarPositionOffset(azimuth=20.0)), + ("alternativeValueSets", "alternativeValueSet", [AlternativeValueSet()]), + ], +) +def test_object_parameters_in_leaves(param_name, adm_name, value): + builder = ADMBuilder() + programme = builder.create_programme(audioProgrammeName="programme") + content = builder.create_content(audioContentName="content", parent=programme) + parent_object = builder.create_object(audioObjectName="object 1", parent=content) + builder.create_item_objects(track_index=1, name="object 2", parent=parent_object) + + setattr(parent_object, param_name, value) + + check_select_items_raises( + builder, + "audioObject {parent_object.id} has both audioObject references and {adm_name}", + parent_object=parent_object, + adm_name=adm_name, + ) + + def test_pack_loop_exception(): builder = ADMBuilder() pack_1 = builder.create_pack(parent=None, audioPackFormatName="pack_1", type=TypeDefinition.Objects) @@ -142,10 +178,41 @@ def test_audioTrackUID_trackFormat_exception(): check_select_items_raises( builder, - "audioTrackUID {atu.id} is not linked to an audioTrackFormat", + "audioTrackUID {atu.id} is not linked to an audioTrackFormat or audioChannelFormat", atu=item.track_uid) +def test_audioTrackUID_channelFormat_exception(): + builder = ADMBuilder.for_version(2) + programme = builder.create_programme(audioProgrammeName="MyProgramme") + builder.create_content(audioContentName="MyContent", parent=programme) + item = builder.create_item_objects(track_index=1, name="MyObject") + + item.track_uid.audioChannelFormat = None + + check_select_items_raises( + builder, + "audioTrackUID {atu.id} is not linked to an audioTrackFormat or audioChannelFormat", + atu=item.track_uid, + ) + + +def test_audioTrackUID_trackFormat_and_channelFormat_exception(): + builder = ADMBuilder.for_version(2) + builder.use_track_uid_to_channel_format_ref = False + programme = builder.create_programme(audioProgrammeName="MyProgramme") + builder.create_content(audioContentName="MyContent", parent=programme) + item = builder.create_item_objects(track_index=1, name="MyObject") + + item.track_uid.audioChannelFormat = item.channel_format + + check_select_items_raises( + builder, + "audioTrackUID {atu.id} is linked to both an audioTrackFormat and a audioChannelFormat", + atu=item.track_uid, + ) + + def test_consistency_exception_1(): builder = ADMBuilder() programme = builder.create_programme(audioProgrammeName="MyProgramme") @@ -365,8 +432,8 @@ def test_hoa_pack_channel_mismatch(): check_select_items_raises( builder, - "Conflicting normalization values in path from {apf.id} to {acf.id}", - apf=builder.first_pack, acf=channel_format) + "Conflicting normalization values in path from {apf.id} to {abf.id}", + apf=builder.first_pack, abf=block_format) def test_hoa_channel_format_mismatch(): @@ -378,12 +445,31 @@ def test_hoa_channel_format_mismatch(): check_select_items_raises( builder, - "All HOA audioChannelFormats in a single audioPackFormat must " + "All audioChannelFormats in a single audioPackFormat must " "share the same normalization value, but {acf_a.id} and {acf_b.id} differ.", acf_a=builder.first_pack.audioChannelFormats[0], acf_b=builder.first_pack.audioChannelFormats[1]) +def test_hoa_pack_format_mismatch(): + builder = HOABuilder() + + builder.first_pack.absoluteDistance = 2.0 + + for i, track in enumerate(builder.first_tracks + builder.second_tracks, 1): + builder.create_track_uid( + audioPackFormat=builder.second_pack, audioTrackFormat=track, trackIndex=i + ) + + check_select_items_raises( + builder, + "All audioChannelFormats in a single audioPackFormat must " + "share the same absoluteDistance value, but {acf_a.id} and {acf_b.id} differ.", + acf_a=builder.second_pack.audioChannelFormats[-1], + acf_b=builder.first_pack.audioChannelFormats[0], + ) + + def test_matrix_one_block_format(): builder = EncodeDecodeBuilder() del builder.encode_mid.audioBlockFormats[0] @@ -394,16 +480,15 @@ def test_matrix_one_block_format(): acf=builder.encode_mid) -@pytest.mark.parametrize("name", ["rtime", "duration"]) -def test_matrix_timing(name): +def test_matrix_timing(): builder = EncodeDecodeBuilder() - setattr(builder.encode_mid.audioBlockFormats[0], name, Fraction(0)) + builder.encode_mid.audioBlockFormats[0].rtime = Fraction(0) + builder.encode_mid.audioBlockFormats[0].duration = Fraction(1) check_select_items_raises( builder, - "matrix audioBlockFormat {block_format.id} has an? {name} attribute", - block_format=builder.encode_mid.audioBlockFormats[0], - name=name) + "matrix audioBlockFormat {block_format.id} has rtime or duration attributes", + block_format=builder.encode_mid.audioBlockFormats[0]) @pytest.mark.parametrize("name,value", [("gainVar", "gain"), @@ -612,3 +697,130 @@ def test_matrix_non_matrix_with_encodePackFormats(): builder, "non-matrix audioPackFormat {apf.id} has encodePackFormat references", apf=builder.adm["AP_00010001"]) + + +@pytest.mark.parametrize("version", (NoVersion(), 1)) +def test_audioTrackUID_to_channel_version(version): + builder = ADMBuilder.for_version(version) + builder.load_common_definitions() + + builder.create_track_uid( + trackIndex=1, + audioPackFormat=builder.adm["AP_00010001"], + audioChannelFormat=builder.adm["AC_00010003"], + ) + + check_select_items_raises( + builder, + r"audioTrackUID \(or CHNA entry\) to audioChannelFormat references are not valid before BS.2076-2", + ) + + +def test_bad_avs_ref(): + """test exceptions when an alternativeValueSetIDRef does not point to an + alternativeValueSet in in a child audioObject + """ + builder = ADMBuilder() + programme_1 = builder.create_programme(audioProgrammeName="programme 1") + content_1 = builder.create_content(audioContentName="content 1", parent=programme_1) + item_1 = builder.create_item_objects( + track_index=1, name="object 1", parent=content_1 + ) + + programme_2 = builder.create_programme(audioProgrammeName="programme 2") + content_2 = builder.create_content(audioContentName="content 2", parent=programme_2) + builder.create_item_objects(track_index=2, name="object 2", parent=content_2) + + avs = AlternativeValueSet() + item_1.audio_object.alternativeValueSets = [avs] + + programme_2.alternativeValueSets = [avs] + check_select_items_raises( + builder, + "{programme_2.id} references {avs.id}, which is not in one of its constituent audioObjects", + programme_2=programme_2, + avs=avs, + ) + + programme_2.alternativeValueSets = [] + content_2.alternativeValueSets = [avs] + check_select_items_raises( + builder, + "{content_2.id} references {avs.id}, which is not in one of its constituent audioObjects", + content_2=content_2, + avs=avs, + ) + + +def test_conflicting_avs_ref(): + """test errors when alternativeValueSetIDRefs conflict""" + builder = ADMBuilder() + programme = builder.create_programme(audioProgrammeName="programme") + content = builder.create_content(audioContentName="content", parent=programme) + item = builder.create_item_objects(track_index=1, name="object", parent=content) + + avs_1 = AlternativeValueSet() + avs_2 = AlternativeValueSet() + item.audio_object.alternativeValueSets = [avs_1, avs_2] + + programme.alternativeValueSets = [avs_1, avs_2] + check_select_items_raises( + builder, + "multiple alternativeValueSets referenced in {item.audio_object.id}: " + "{avs_1.id} referenced from {programme.id}, and {avs_2.id} referenced from {programme.id}", + programme=programme, + item=item, + avs_1=avs_1, + avs_2=avs_2, + ) + + programme.alternativeValueSets = [avs_1] + content.alternativeValueSets = [avs_2] + check_select_items_raises( + builder, + "multiple alternativeValueSets referenced in {item.audio_object.id}: " + "{avs_1.id} referenced from {programme.id}, and {avs_2.id} referenced from {content.id}", + programme=programme, + content=content, + item=item, + avs_1=avs_1, + avs_2=avs_2, + ) + + +def test_duplicate_avs(): + """test errors when alternativeValueSetIDRefs are duplicated in audioProgramme or audioContent""" + builder = ADMBuilder() + programme = builder.create_programme(audioProgrammeName="programme") + content = builder.create_content(audioContentName="content", parent=programme) + item = builder.create_item_objects(track_index=1, name="object", parent=content) + + avs = AlternativeValueSet() + item.audio_object.alternativeValueSets = [avs] + + programme.alternativeValueSets = [avs, avs] + check_select_items_raises( + builder, + "duplicate references to {avs.id} in {programme.id}", + avs=avs, + programme=programme, + ) + + programme.alternativeValueSets = [] + content.alternativeValueSets = [avs, avs] + check_select_items_raises( + builder, + "duplicate references to {avs.id} in {content.id}", + avs=avs, + content=content, + ) + + programme.alternativeValueSets = [avs] + content.alternativeValueSets = [avs] + check_select_items_raises( + builder, + "alternativeValueSet {avs.id} is referenced by both {programme.id} and {content.id}", + avs=avs, + programme=programme, + content=content, + ) diff --git a/ear/core/select_items/utils.py b/ear/core/select_items/utils.py index 62a3f083..40880b71 100644 --- a/ear/core/select_items/utils.py +++ b/ear/core/select_items/utils.py @@ -1,8 +1,18 @@ +from ...fileio.adm.exceptions import AdmError + + def in_by_id(element, collection): """Check if element is in collection, by comparing identity rather than equality.""" return any(element is item for item in collection) +def index_by_id(element_to_find, collection): + """get the index of element in collection, comparing by id, or None""" + for i, element in enumerate(collection): + if element is element_to_find: + return i + + def _paths_from(start_node, get_children): """All paths through a tree structure starting at start_node.""" yield [start_node] @@ -72,3 +82,65 @@ def pack_format_packs(root_audioPackFormat): for sub_pack in root_audioPackFormat.audioPackFormats: for sub_sub_pack in pack_format_packs(sub_pack): yield sub_sub_pack + + +def get_path_param(path, name, default=None): + """Get a parameter which can be defined in any of the objects in path. Any + specified parameters must be consistent. If none were specified then the + default is returned. + """ + all_values = [getattr(obj, name) for obj in path] + + not_none = [value for value in all_values if value is not None] + + if not_none: + if any(value != not_none[0] for value in not_none): + raise AdmError( + "Conflicting {name} values in path from {start.id} to {end.id}".format( + name=name, + start=path[0], + end=path[-1], + ) + ) + + return not_none[0] + else: + return default + + +def get_single_param(pack_paths_channels, name, get_param): + """Get one parameter which must be consistent in all channels. + + Parameters: + pack_paths_channels (list): list of tuples of (audioPackFormat_path, + audioChannelFormat), one for each audioChannelFormat in the root + audioPackFormat. + name (str): name of parameter to be used in exceptions + get_param (callable): function from (audioPackFormat_path, + audioChannelFormat) to the value of the parameter. + """ + for pack_path_channel_a, pack_path_channel_b in zip( + pack_paths_channels[:-1], pack_paths_channels[1:] + ): + pack_format_path_a, channel_a = pack_path_channel_a + pack_format_path_b, channel_b = pack_path_channel_b + if get_param(pack_format_path_a, channel_a) != get_param( + pack_format_path_b, channel_b + ): + raise AdmError( + "All audioChannelFormats in a single audioPackFormat must " + "share the same {name} value, but {acf_a.id} and {acf_b.id} differ.".format( + name=name, + acf_a=channel_a, + acf_b=channel_b, + ) + ) + + pack_path, channel = pack_paths_channels[0] + return get_param(pack_path, channel) + + +def get_per_channel_param(pack_paths_channels, get_param): + """Get One value of a parameter per channel in pack_paths_channels. + See get_single_param.""" + return [get_param(pack_path, channel) for pack_path, channel in pack_paths_channels] diff --git a/ear/core/select_items/validate.py b/ear/core/select_items/validate.py index b4c25cb0..cbcf2578 100644 --- a/ear/core/select_items/validate.py +++ b/ear/core/select_items/validate.py @@ -1,7 +1,15 @@ from ...fileio.adm.elements import AudioPackFormat, AudioChannelFormat, TypeDefinition, ObjectCartesianPosition +from ...fileio.adm.elements.version import version_at_least from ...fileio.adm.exceptions import AdmError from . import matrix -from .utils import in_by_id, pack_format_channels, pack_format_packs, pack_format_paths_from +from .utils import ( + get_single_param, + in_by_id, + object_paths_from, + pack_format_channels, + pack_format_packs, + pack_format_paths_from, +) def _validate_loops(type_name, nodes, get_children): @@ -42,6 +50,35 @@ def _validate_object_loops(adm): return _validate_loops("audioObjects", adm.audioObjects, lambda audioObject: audioObject.audioObjects) +def _validate_object_parameters_in_leaves(adm): + for obj in adm.audioObjects: + if obj.audioObjects: + if obj.start is not None: + raise AdmError( + f"audioObject {obj.id} has both audioObject references and start" + ) + if obj.duration is not None: + raise AdmError( + f"audioObject {obj.id} has both audioObject references and duration" + ) + if obj.gain != 1.0: + raise AdmError( + f"audioObject {obj.id} has both audioObject references and gain" + ) + if obj.mute: + raise AdmError( + f"audioObject {obj.id} has both audioObject references and mute" + ) + if obj.positionOffset is not None: + raise AdmError( + f"audioObject {obj.id} has both audioObject references and positionOffset" + ) + if obj.alternativeValueSets: + raise AdmError( + f"audioObject {obj.id} has both audioObject references and alternativeValueSet" + ) + + def _validate_pack_channel_multitree(adm): """Check that audioPackFormat->audioPackFormat and audioPackFormat->audioChannelFormat references form a multitree; from each @@ -189,8 +226,8 @@ def _hoa_pack_format_paths_channels(adm): def _validate_hoa_parameters_consistent(adm): - from .hoa import (get_single_param, get_nfcRefDist, get_screenRef, - get_normalization, get_rtime, get_duration) + from .hoa import (get_nfcRefDist, get_screenRef, get_normalization, + get_rtime, get_duration) for pack_paths_channels in _hoa_pack_format_paths_channels(adm): get_single_param(pack_paths_channels, "rtime", get_rtime) @@ -359,13 +396,8 @@ def _validate_matrix_channel(acf): [block_format] = acf.audioBlockFormats - if block_format.rtime is not None: - raise AdmError("matrix audioBlockFormat {block_format.id} has an rtime attribute".format( - block_format=block_format, - )) - - if block_format.duration is not None: - raise AdmError("matrix audioBlockFormat {block_format.id} has a duration attribute".format( + if block_format.rtime is not None or block_format.duration is not None: + raise AdmError("matrix audioBlockFormat {block_format.id} has rtime or duration attributes".format( block_format=block_format, )) @@ -400,9 +432,115 @@ def _validate_matrix_types(adm): _validate_non_matrix_pack(apf) +def _validate_track_channel_ref_only_in_v2(adm): + # allow in CHNA-only mode + v2_allowed = adm.version is None or version_at_least(adm.version, 2) + if not v2_allowed and any( + atu.audioChannelFormat is not None for atu in adm.audioTrackUIDs + ): + raise AdmError( + "audioTrackUID (or CHNA entry) to audioChannelFormat references are not valid before BS.2076-2" + ) + + +def _validate_track_uid_track_or_channel_ref(adm): + for atu in adm.audioTrackUIDs: + if atu.audioTrackFormat is None and atu.audioChannelFormat is None: + raise AdmError( + f"audioTrackUID {atu.id} is not linked to an audioTrackFormat or audioChannelFormat" + ) + if atu.audioTrackFormat is not None and atu.audioChannelFormat is not None: + raise AdmError( + f"audioTrackUID {atu.id} is linked to both an audioTrackFormat and a audioChannelFormat" + ) + + +def _find_object_for_avs(avs, objects): + """given an AlternativeValueSet and a list of AudioObject, return the + AudioObject that contains the AlternativeValueSet, or None + """ + for obj in objects: + if in_by_id(avs, obj.alternativeValueSets): + return obj + + +def _validate_avs_references_contained(referring_object, objects): + """check that all alternativeValueSets in referring_object (AudioProgramme + or AudioContent) are contained in one of the objects (list of AudioObject) + """ + for avs in referring_object.alternativeValueSets: + obj = _find_object_for_avs(avs, objects) + if obj is None: + raise AdmError( + f"{referring_object.id} references {avs.id}, " + "which is not in one of its constituent audioObjects" + ) + + +def _validate_avs_references_conflict(referring_objects, objects): + """check that the objects in referring_objects (list containing an + AudioProgramme and its AudioObjects) refer to zero or one + alternativeValueSets in each AudioObject in objects + """ + references_by_object_id = {} # id(object) -> (referring object, AVS) + + for referring_object in referring_objects: + for avs in referring_object.alternativeValueSets: + obj = _find_object_for_avs(avs, objects) + assert obj is not None # already checked + + if id(obj) in references_by_object_id: + (prev_referring_object, prev_avs) = references_by_object_id[id(obj)] + + if prev_avs is avs and prev_referring_object is referring_object: + raise AdmError( + f"duplicate references to {avs.id} in {referring_object.id}" + ) + elif prev_avs is avs: + raise AdmError( + f"alternativeValueSet {avs.id} is referenced by both " + f"{prev_referring_object.id} and {referring_object.id}" + ) + else: + raise AdmError( + f"multiple alternativeValueSets referenced in {obj.id}: " + f"{prev_avs.id} referenced from {prev_referring_object.id}, and " + f"{avs.id} referenced from {referring_object.id}" + ) + else: + references_by_object_id[id(obj)] = (referring_object, avs) + + +def _validate_avs_references(adm): + """check that references to alternativeValueSets are to constituent + audioObjects, are not duplicated, and do not conflict + """ + for audioProgramme in adm.audioProgrammes: + programme_objects = [ + object_path[-1] + for audioContent in audioProgramme.audioContents + for root_object in audioContent.audioObjects + for object_path in object_paths_from(root_object) + ] + _validate_avs_references_contained(audioProgramme, programme_objects) + + for audioContent in audioProgramme.audioContents: + content_objects = [ + object_path[-1] + for root_object in audioContent.audioObjects + for object_path in object_paths_from(root_object) + ] + _validate_avs_references_contained(audioContent, content_objects) + + _validate_avs_references_conflict( + [audioProgramme] + audioProgramme.audioContents, programme_objects + ) + + def validate_structure(adm): adm.validate() _validate_object_loops(adm) + _validate_object_parameters_in_leaves(adm) _validate_pack_channel_types(adm) _validate_pack_subpack_types(adm) _validate_pack_channel_multitree(adm) @@ -411,6 +549,9 @@ def validate_structure(adm): _validate_hoa_order_degree(adm) _validate_hoa_parameters_consistent(adm) _validate_matrix_types(adm) + _validate_track_channel_ref_only_in_v2(adm) + _validate_track_uid_track_or_channel_ref(adm) + _validate_avs_references(adm) def validate_selected_audioTrackUID(audioTrackUID): @@ -421,12 +562,6 @@ def validate_selected_audioTrackUID(audioTrackUID): atu=audioTrackUID, )) - if audioTrackUID.audioTrackFormat is None: - raise AdmError("audioTrackUID {self.id} is not linked " - "to an audioTrackFormat".format( - self=audioTrackUID, - )) - if audioTrackUID.audioPackFormat is None: raise AdmError("audioTrackUID {atu.id} does not have an audioPackFormat " "reference. This may be used in coded formats, which are not " @@ -434,14 +569,17 @@ def validate_selected_audioTrackUID(audioTrackUID): atu=audioTrackUID, )) - audioStreamFormat = audioTrackUID.audioTrackFormat.audioStreamFormat - - if audioStreamFormat.audioChannelFormat is None: - raise AdmError("audioStreamFormat {asf.id} does not have an audioChannelFormat " - "reference. This may be used in coded formats, which are not " - "currently supported.".format( - asf=audioStreamFormat, - )) + if audioTrackUID.audioTrackFormat is not None: + audioStreamFormat = audioTrackUID.audioTrackFormat.audioStreamFormat + + if audioStreamFormat.audioChannelFormat is None: + raise AdmError( + "audioStreamFormat {asf.id} does not have an audioChannelFormat " + "reference. This may be used in coded formats, which are not " + "currently supported.".format( + asf=audioStreamFormat, + ) + ) def possible_audioTrackUID_errors(audioTrackUID): diff --git a/ear/core/test/test_hoa.py b/ear/core/test/test_hoa.py index 558652a9..fc6aee9b 100644 --- a/ear/core/test/test_hoa.py +++ b/ear/core/test/test_hoa.py @@ -1,5 +1,6 @@ import numpy as np import numpy.testing as npt +import pytest from .. import hoa @@ -105,6 +106,7 @@ def test_allrad_design(): assert np.argmax(decoded) == i +@pytest.mark.filterwarnings("ignore::SyntaxWarning") def test_maxRE(): """Check that the approximate and numeric versions of maxRE are compatible.""" for order in range(1, 5): diff --git a/ear/core/test/test_importance.py b/ear/core/test/test_importance.py index 30874589..e63b288d 100644 --- a/ear/core/test/test_importance.py +++ b/ear/core/test/test_importance.py @@ -1,8 +1,27 @@ -from ..metadata_input import ObjectRenderingItem, HOARenderingItem, ImportanceData, MetadataSourceIter, MetadataSource -from ..metadata_input import ObjectTypeMetadata -from ..metadata_input import DirectTrackSpec -from ...fileio.adm.elements import AudioBlockFormatObjects -from ..importance import filter_by_importance, filter_audioObject_by_importance, filter_audioPackFormat_by_importance, MetadataSourceImportanceFilter +from ..metadata_input import ( + ObjectRenderingItem, + ObjectTypeMetadata, + DirectSpeakersRenderingItem, + DirectSpeakersTypeMetadata, + HOARenderingItem, + HOATypeMetadata, + DirectTrackSpec, + ImportanceData, + MetadataSourceIter, + MetadataSource, +) +from ...fileio.adm.elements import ( + AudioBlockFormatObjects, + AudioBlockFormatDirectSpeakers, + DirectSpeakerPolarPosition, + BoundCoordinate, +) +from ..importance import ( + filter_by_importance, + filter_audioObject_by_importance, + filter_audioPackFormat_by_importance, +) +from attrs import evolve from fractions import Fraction import pytest @@ -25,14 +44,18 @@ def rendering_items(): ], track_specs=[DTS(8), DTS(9), DTS(10), DTS(11)], metadata_source=dummySource), + DirectSpeakersRenderingItem( + importance=ImportanceData(audio_object=3, audio_pack_format=5), + track_spec=DTS(12), + metadata_source=dummySource), ] @pytest.mark.parametrize('threshold,expected_indizes', [ - (0, [0, 1, 2, 3, 4, 5, 6, 7]), - (1, [0, 1, 2, 3, 4, 5, 6, 7]), - (2, [1, 2, 3, 4, 5, 6, 7]), - (3, [3, 4, 5, 6, 7]), + (0, [0, 1, 2, 3, 4, 5, 6, 7, 8]), + (1, [0, 1, 2, 3, 4, 5, 6, 7, 8]), + (2, [1, 2, 3, 4, 5, 6, 7, 8]), + (3, [3, 4, 5, 6, 7, 8]), (4, [4, 5, 6, 7]), (5, [4, 6, 7]), (6, [4, 6, 7]), @@ -48,12 +71,12 @@ def test_importance_filter_objects(rendering_items, threshold, expected_indizes) @pytest.mark.parametrize('threshold,expected_indizes', [ - (0, [0, 1, 2, 3, 4, 5, 6, 7]), - (1, [0, 1, 2, 3, 4, 5, 6, 7]), - (2, [0, 1, 2, 3, 4, 5, 6, 7]), - (3, [1, 2, 3, 5, 6, 7]), - (4, [2, 3, 5, 7]), - (5, [2, 3, 5, 7]), + (0, [0, 1, 2, 3, 4, 5, 6, 7, 8]), + (1, [0, 1, 2, 3, 4, 5, 6, 7, 8]), + (2, [0, 1, 2, 3, 4, 5, 6, 7, 8]), + (3, [1, 2, 3, 5, 6, 7, 8]), + (4, [2, 3, 5, 7, 8]), + (5, [2, 3, 5, 7, 8]), (6, [2, 3, 5]), (7, [2, 3, 5]), (8, [2, 3]), @@ -67,10 +90,10 @@ def test_importance_filter_packs(rendering_items, threshold, expected_indizes): @pytest.mark.parametrize('threshold,expected_indizes', [ - (0, [0, 1, 2, 3, 4, 5, 6, 7]), - (1, [0, 1, 2, 3, 4, 5, 6, 7]), - (2, [1, 2, 3, 4, 5, 6, 7]), - (3, [3, 5, 6, 7]), + (0, [0, 1, 2, 3, 4, 5, 6, 7, 8]), + (1, [0, 1, 2, 3, 4, 5, 6, 7, 8]), + (2, [1, 2, 3, 4, 5, 6, 7, 8]), + (3, [3, 5, 6, 7, 8]), (4, [5, 7]), (5, [7]), (6, []), @@ -104,25 +127,109 @@ def track_specs(item): ] -@pytest.mark.parametrize('threshold,muted_indizes', [ - (0, []), - (1, []), - (2, []), - (3, []), - (4, [5]), - (5, [2, 4, 5]), - (6, [2, 4, 5]), - (7, [2, 4, 5]), - (8, [2, 4, 5]), - (9, [2, 4, 5, 7]), - (10, [2, 4, 5, 7]) -]) -def test_importance_filter_source(threshold, muted_indizes): +def get_blocks(metadata_source): + """get the blocks from a metadata_source as a list""" + blocks = [] + while True: + block = metadata_source.get_next_block() + if block is None: + break + blocks.append(block) + + return blocks + + +def make_objects_type_metadata(**kwargs): + return ObjectTypeMetadata( + block_format=AudioBlockFormatObjects( + position={"azimuth": 0, "elevation": 0}, **kwargs + ) + ) + + +def make_direct_speakers_type_metadata(**kwargs): + return DirectSpeakersTypeMetadata( + block_format=AudioBlockFormatDirectSpeakers( + position=DirectSpeakerPolarPosition( + bounded_azimuth=BoundCoordinate(0.0), + bounded_elevation=BoundCoordinate(0.0), + ), + **kwargs, + ) + ) + + +@pytest.mark.parametrize( + "make_type_metadata,make_rendering_item", + [ + (make_objects_type_metadata, ObjectRenderingItem), + (make_direct_speakers_type_metadata, DirectSpeakersRenderingItem), + ], +) +def test_importance_filter_blocks_single_channel(make_type_metadata, make_rendering_item): + """check that blocks are modified to apply importance filtering for single-channel types""" + type_metadatas = [ + make_type_metadata(rtime=Fraction(0)), + make_type_metadata(rtime=Fraction(1), importance=5), + make_type_metadata(rtime=Fraction(2), importance=6), + ] + expected = [ + make_type_metadata(rtime=Fraction(0)), + make_type_metadata(rtime=Fraction(1), importance=5, gain=0.0), + make_type_metadata(rtime=Fraction(2), importance=6), + ] + source = MetadataSourceIter(type_metadatas) - adapted = MetadataSourceImportanceFilter(source, threshold=threshold) - for idx in range(len(type_metadatas)): - block = adapted.get_next_block() - if idx in muted_indizes: - assert block.block_format.gain == 0.0 - else: - assert block == type_metadatas[idx] + rendering_items = [ + make_rendering_item(track_spec=DirectTrackSpec(1), metadata_source=source), + ] + + rendering_items_out = filter_by_importance(rendering_items, 6) + [rendering_item_out] = rendering_items_out + assert get_blocks(rendering_item_out.metadata_source) == expected + + +@pytest.mark.parametrize( + "gains", + [ + [1.0, 1.0, 1.0, 1.0], + [0.5, 0.25, 0.25, 0.25], + ], +) +def test_importance_filter_hoa(gains): + type_metadatas = [ + HOATypeMetadata( # all but first channel muted + orders=[0, 1, 1, 1], + degrees=[0, -1, 0, 1], + importances=[6, 5, 5, 5], + normalization="SN3D", + gains=gains, + ), + HOATypeMetadata( # not modified + orders=[0, 1, 1, 1], + degrees=[0, -1, 0, 1], + importances=[6, 6, 6, 6], + normalization="SN3D", + gains=gains, + ), + ] + expected = [ + evolve( + type_metadatas[0], + gains=[gains[0], 0.0, 0.0, 0.0], + ), + evolve( + type_metadatas[1], + gains=gains, + ), + ] + rendering_items = [ + HOARenderingItem( + track_specs=[DirectTrackSpec(i) for i in range(4)], + metadata_source=MetadataSourceIter(type_metadatas), + ), + ] + + rendering_items_out = filter_by_importance(rendering_items, 6) + [rendering_item_out] = rendering_items_out + assert get_blocks(rendering_item_out.metadata_source) == expected diff --git a/ear/core/test/test_layout.py b/ear/core/test/test_layout.py index 1a5b17a3..b39d2b59 100644 --- a/ear/core/test/test_layout.py +++ b/ear/core/test/test_layout.py @@ -1,6 +1,7 @@ from ..layout import Channel, Layout, load_speakers, load_real_layout, Speaker, RealLayout from ..geom import cart, PolarPosition, CartesianPosition from ...common import PolarScreen, CartesianScreen +from ...compatibility import dump_yaml_str from attr import evolve import pytest import numpy as np @@ -140,9 +141,8 @@ def test_Layout_check_upmix_matrix(layout): def test_load_layout_info(): def run_test(yaml_obj, expected, func=load_real_layout): - from ruamel import yaml from six import StringIO - yaml_str = yaml.dump(yaml_obj) + yaml_str = dump_yaml_str(yaml_obj) result = func(StringIO(yaml_str)) diff --git a/ear/core/test/test_metadata_input.py b/ear/core/test/test_metadata_input.py new file mode 100644 index 00000000..81ba96cf --- /dev/null +++ b/ear/core/test/test_metadata_input.py @@ -0,0 +1,15 @@ +from .. import metadata_input + + +def test_HOARenderingItem(): + metadata_source = metadata_input.MetadataSourceIter([]) + track_specs = [metadata_input.DirectTrackSpec(i) for i in range(4)] + ri = metadata_input.HOARenderingItem( + track_specs=track_specs, + metadata_source=metadata_source, + ) + + assert len(ri.importances) == 4 + assert all( + importance == metadata_input.ImportanceData() for importance in ri.importances + ) diff --git a/ear/core/test/test_monitor.py b/ear/core/test/test_monitor.py index b467269f..5fe4640a 100644 --- a/ear/core/test/test_monitor.py +++ b/ear/core/test/test_monitor.py @@ -27,6 +27,6 @@ def test_peak_monitor(): assert mon.has_overloaded() import pytest - with pytest.warns(None) as record: + with pytest.warns(UserWarning) as record: mon.warn_overloaded() assert len(record) == 1 and str(record[0].message) == "overload in channel 1; peak level was 20.0dBFS" diff --git a/ear/core/test/test_renderer_common.py b/ear/core/test/test_renderer_common.py index 6147c2c7..f1866ad7 100644 --- a/ear/core/test/test_renderer_common.py +++ b/ear/core/test/test_renderer_common.py @@ -1,7 +1,10 @@ +from collections import namedtuple from fractions import Fraction import numpy as np import numpy.testing as npt -from ..renderer_common import FixedGains, InterpGains +import pytest +from ..renderer_common import BlockProcessingChannel, FixedGains, InterpGains +from ..metadata_input import MetadataSourceIter def test_FixedGains(): @@ -44,3 +47,64 @@ def test_InterpGains(): g.process(0, input_samples, output_samples) npt.assert_allclose(output_samples, expected) + + +@pytest.mark.parametrize("block_size", [5, 6, 10, 20, 60]) +def test_block_processing_channel(block_size): + # BlockProcessingChannel transforms metadata blocks into processing blocks; + # use a fake metadata block type which just holds the processing block. + DummyBlock = namedtuple("DummyBlock", "block") + + # Check gap at start, between blocks, and at end. Try both FixedGains and + # InterpGains, since the behaviour with bad sample ranges may be different. + # The actual interpolation behaviour of these is tested above. + blocks = [ + DummyBlock( + FixedGains( + start_sample=Fraction(10), + end_sample=Fraction(20), + gains=np.array([0.1]), + ) + ), + DummyBlock( + FixedGains( + start_sample=Fraction(20), + end_sample=Fraction(30), + gains=np.array([0.2]), + ) + ), + DummyBlock( + InterpGains( + start_sample=Fraction(40), + end_sample=Fraction(50), + gains_start=np.array([0.3]), + gains_end=np.array([0.3]), + ) + ), + ] + expected_gains = np.repeat([0.0, 0.1, 0.2, 0.0, 0.3, 0.0], 10) + n_samples = 60 + fs = 48000 + + num_interpret_calls = 0 + + def interpret(block_fs, block): + nonlocal num_interpret_calls + num_interpret_calls += 1 + + assert block_fs == fs + + return [block.block] + + channel = BlockProcessingChannel(MetadataSourceIter(blocks), interpret) + + input_samples = 1.0 + np.random.random_sample(n_samples) + expected_output = (expected_gains * input_samples)[:, np.newaxis] + + output_samples = np.zeros((n_samples, 1)) + for start in range(0, n_samples, block_size): + end = start + block_size + channel.process(fs, start, input_samples[start:end], output_samples[start:end]) + + assert num_interpret_calls == 3 + npt.assert_allclose(output_samples, expected_output) diff --git a/ear/core/test/test_screen_common.py b/ear/core/test/test_screen_common.py index 8ffaee70..158b20c8 100644 --- a/ear/core/test/test_screen_common.py +++ b/ear/core/test/test_screen_common.py @@ -1,5 +1,5 @@ import numpy as np -from pytest import approx +from pytest import approx, raises from ..screen_common import PolarEdges, PolarScreen, CartesianScreen, compensate_position from ...common import default_screen, PolarPosition, CartesianPosition, cart, azimuth from ..objectbased.conversion import point_polar_to_cart @@ -49,6 +49,38 @@ def test_polar_edges_from_polar_up(): assert np.isclose(screen_edges.bottom_elevation, -default_edge_elevation + 10) +def test_polar_edge_error_az(): + screen = PolarScreen( + aspectRatio=1.78, + centrePosition=PolarPosition( + azimuth=161.0, + elevation=0.0, + distance=1.0), + widthAzimuth=40.0) + expected = "invalid screen specification: screen must not extend past -y" + with raises(ValueError, match=expected): + PolarEdges.from_screen(screen) + + screen.centrePosition.azimuth = 159.0 + PolarEdges.from_screen(screen) + + +def test_polar_edge_error_el(): + screen = PolarScreen( + aspectRatio=1.0, + centrePosition=PolarPosition( + azimuth=30.0, + elevation=71.0, + distance=1.0), + widthAzimuth=40.0) + expected = "invalid screen specification: screen must not extend past \\+z or -z" + with raises(ValueError, match=expected): + PolarEdges.from_screen(screen) + + screen.centrePosition.elevation = 69.0 + PolarEdges.from_screen(screen) + + def test_polar_edges_from_cart_default(): cartesian_default_screen = CartesianScreen( aspectRatio=1.78, diff --git a/ear/core/test/test_track_processor.py b/ear/core/test/test_track_processor.py index 516a60d7..337e4d33 100644 --- a/ear/core/test/test_track_processor.py +++ b/ear/core/test/test_track_processor.py @@ -1,7 +1,14 @@ import numpy as np +import numpy.testing as npt import pytest from ...fileio.adm.elements import AudioChannelFormat, MatrixCoefficient, TypeDefinition -from ..metadata_input import SilentTrackSpec, DirectTrackSpec, MatrixCoefficientTrackSpec, MixTrackSpec +from ..metadata_input import ( + SilentTrackSpec, + DirectTrackSpec, + MatrixCoefficientTrackSpec, + MixTrackSpec, + GainTrackSpec, +) from ..track_processor import TrackProcessor, MultiTrackProcessor @@ -71,6 +78,18 @@ def test_matrix_coeff_delay(sample_rate, delay, expected_delay): assert np.all(processed == expected) +@pytest.mark.parametrize("gain", [1.0, 0.5]) +def test_gain(gain): + input_samples = np.random.random((100, 10)) + + p = TrackProcessor(GainTrackSpec(DirectTrackSpec(0), gain)) + + processed = p.process(48000, input_samples) + expected = input_samples[:, 0] * gain + + npt.assert_allclose(processed, expected, atol=1e-6) + + def test_simplify_sum_silence(): input_samples = np.random.random((100, 10)) diff --git a/ear/core/track_processor.py b/ear/core/track_processor.py index 6cb3cabf..db3e919a 100644 --- a/ear/core/track_processor.py +++ b/ear/core/track_processor.py @@ -4,13 +4,22 @@ import math from multipledispatch import Dispatcher from .delay import Delay -from .metadata_input import TrackSpec, DirectTrackSpec, SilentTrackSpec, MatrixCoefficientTrackSpec, MixTrackSpec +from .metadata_input import ( + TrackSpec, + DirectTrackSpec, + SilentTrackSpec, + MatrixCoefficientTrackSpec, + MixTrackSpec, + GainTrackSpec, +) class TrackProcessorBase(object): """Base class for processors which can be used to obtain samples for a single track spec given multi-track input samples (from a WAV file for example). + + Use :func:`TrackProcessor` to create these. """ def __init__(self, track_spec): """ @@ -38,7 +47,7 @@ def TrackProcessor(track_spec): track_spec (TrackSpec): Track spec to render. Returns: - TrackSpecProcessorBase: processor to obtain samples for track_spec + TrackProcessorBase: processor to obtain samples for track_spec """ return _track_spec_processor(_simplify_track_spec(track_spec)) @@ -194,3 +203,28 @@ def process(self, sample_rate, input_samples): samples = self.delay.process(samples[:, np.newaxis])[:, 0] return samples + + +# gain + + +@_simplify_track_spec.register(GainTrackSpec) +def _simplify_gain(track_spec): + input_track = _simplify_track_spec(track_spec.input_track) + + if track_spec.gain == 1.0: + return input_track + else: + return evolve(track_spec, input_track=input_track) + + +@_track_spec_processor.register(GainTrackSpec) +class GainProcessor(TrackProcessorBase): + def __init__(self, track_spec): + self.input_processor = _track_spec_processor(track_spec.input_track) + self.gain = track_spec.gain + + def process(self, sample_rate, input_samples): + samples = self.input_processor.process(sample_rate, input_samples) + + return samples * self.gain diff --git a/ear/core/util.py b/ear/core/util.py index d667f37f..ff8b4679 100644 --- a/ear/core/util.py +++ b/ear/core/util.py @@ -65,3 +65,10 @@ def safe_norm_position(position): return np.array([0.0, 1.0, 0.0]) else: return position / norm + + +def interp_sorted(x, xp, yp): + """same as np.interp, but checks that xp is sorted""" + xp = np.array(xp) + assert np.all(xp[:-1] <= xp[1:]), "unsorted xp values in call to interp" + return np.interp(x, xp, yp) diff --git a/ear/fileio/adm/adm.py b/ear/fileio/adm/adm.py index b04617ae..d1541ad2 100644 --- a/ear/fileio/adm/adm.py +++ b/ear/fileio/adm/adm.py @@ -1,13 +1,20 @@ +from attr import attrs, attrib from itertools import chain import warnings from collections import OrderedDict from six import iteritems +from .elements.version import version_validator, Version from .exceptions import AdmIDError, AdmIDWarning +from . import elements +@attrs(cmp=False) class ADM(object): + """An ADM document.""" - def __init__(self): + version: Version = attrib(default=None, validator=version_validator) + + def __attrs_post_init__(self): self._ap = [] self._ac = [] self._ao = [] @@ -68,42 +75,83 @@ def lazy_lookup_references(self): for element in self.elements: element.lazy_lookup_references(self) + self._lazy_lookup_alternativeValueSets() + + def _lazy_lookup_alternativeValueSets(self): + avs_by_id = {} + for audioObject in self.audioObjects: + for avs in audioObject.alternativeValueSets: + if avs.id is not None: + id_upper = avs.id.upper() + if id_upper in avs_by_id: + raise AdmIDError(f"duplicate objects with id={avs.id}") + avs_by_id[id_upper] = avs + + def get_avs(avs_id): + try: + return avs_by_id[avs_id.upper()] + except KeyError: + raise KeyError(f"unknown alternativeValueSet {avs_id}") + + for element in chain(self.audioProgrammes, self.audioContents): + if element.alternativeValueSetIDRef is not None: + element.alternativeValueSets = [ + get_avs(avs_id) for avs_id in element.alternativeValueSetIDRef + ] + element.alternativeValueSetIDRef = None + def validate(self): + """Validate all elements, raising an exception if an error is found. + + Note: + This is not extensive. + """ for element in self.elements: - element.validate() + element.validate(adm=self) - def addAudioProgramme(self, programme): + def addAudioProgramme(self, programme: elements.AudioProgramme): + """Add an audioProgramme.""" self._ap.append(programme) - def addAudioContent(self, content): + def addAudioContent(self, content: elements.AudioContent): + """Add an audioContent.""" self._ac.append(content) - def addAudioObject(self, audioobject): + def addAudioObject(self, audioobject: elements.AudioObject): + """Add an audioObject.""" self._ao.append(audioobject) - def addAudioPackFormat(self, packformat): + def addAudioPackFormat(self, packformat: elements.AudioPackFormat): + """Add an audioPackFormat.""" self._apf.append(packformat) - def addAudioChannelFormat(self, channelformat): + def addAudioChannelFormat(self, channelformat: elements.AudioChannelFormat): + """Add an audioChannelFormat.""" self._acf.append(channelformat) - def addAudioStreamFormat(self, streamformat): + def addAudioStreamFormat(self, streamformat: elements.AudioStreamFormat): + """Add an audioStreamFormat.""" self._asf.append(streamformat) - def addAudioTrackFormat(self, trackformat): + def addAudioTrackFormat(self, trackformat: elements.AudioTrackFormat): + """Add an audioTrackFormat.""" self._atf.append(trackformat) - def addAudioTrackUID(self, trackUID): + def addAudioTrackUID(self, trackUID: elements.AudioTrackUID): + """Add an audioTrackUID.""" self._atu.append(trackUID) @property def elements(self): + """Iterator over all elements.""" return chain(*self._object_lists) def __getitem__(self, key): + """Get an element by ID.""" return self.lookup_element(key) def lookup_element(self, key): + """Get an element by ID.""" key_upper = key.upper() for element in self.elements: if element.id is not None and element.id.upper() == key_upper: @@ -112,32 +160,40 @@ def lookup_element(self, key): @property def audioProgrammes(self): + """list[AudioProgramme]: Get all audioProgramme elements.""" return self._ap @property def audioContents(self): + """list[AudioContent]: Get all audioContent elements.""" return self._ac @property def audioObjects(self): + """list[AudioObject]: Get all audioObject elements.""" return self._ao @property def audioPackFormats(self): + """list[AudioPackFormat]: Get all audioPackFormat elements.""" return self._apf @property def audioChannelFormats(self): + """list[AudioChannelFormat]: Get all audioChannelFormat elements.""" return self._acf @property def audioStreamFormats(self): + """list[AudioStreamFormat]: Get all audioStreamFormat elements.""" return self._asf @property def audioTrackFormats(self): + """list[AudioTrackFormat]: Get all audioTrackFormat elements.""" return self._atf @property def audioTrackUIDs(self): + """list[AudioTrackUID]: Get all audioTrackUID elements.""" return self._atu diff --git a/ear/fileio/adm/builder.py b/ear/fileio/adm/builder.py index 4a64f9c2..3f2da876 100644 --- a/ear/fileio/adm/builder.py +++ b/ear/fileio/adm/builder.py @@ -2,9 +2,54 @@ from .adm import ADM from .elements import AudioProgramme, AudioContent, AudioObject from .elements import TypeDefinition, FormatDefinition -from .elements import AudioChannelFormat, AudioPackFormat, AudioTrackFormat, AudioTrackUID, AudioStreamFormat +from .elements import ( + AudioChannelFormat, + AudioPackFormat, + AudioTrackFormat, + AudioTrackUID, + AudioStreamFormat, + AudioBlockFormatHoa, +) +from .elements.version import version_at_least, BS2076Version -DEFAULT = object() + +class _Default(object): + def __repr__(self): + return "DEFAULT" + + +DEFAULT = _Default() + + +def _make_format_property(attribute): + """Make a property which forwards gets/sets to format.attribute.""" + + def getter(self): + return getattr(self.format, attribute) + + def setter(self, new_val): + return setattr(self.format, attribute, new_val) + + return property(getter, setter, doc=f"accessor for format.{attribute}") + + +def _make_singlar_property(attribute, attribute_singular): + """Make a property which forwards gets/sets to attribute, assuming it is a + list with a single entry. + """ + + def getter(self): + items = getattr(self, attribute) + assert len(items) in (0, 1), f"expected 0 or 1 {attribute_singular}, got {len(items)}" + if items: + return items[0] + else: + return None + + def setter(self, new_val): + return setattr(self, attribute, [] if new_val is None else [new_val]) + + return property(getter, setter, doc=f"singular accessor for {attribute}") @attrs @@ -13,17 +58,26 @@ class ADMBuilder(object): Attributes: adm (ADM): ADM object to modify. - last_programme (AudioProgramme): The last programme created, to which - created audioContents will be linked. - last_content (AudioContent): The last content created, to which created - audioObjects will be linked by default. - last_object (AudioObject): The last object created, to which created - audioObjects or audioPackFormats will be linked by default. - last_pack_format (AudioPackFormat): The last pack_format created, to which - created audioChannelFormats will be linked by default. - last_stream_format (AudioStreamFormat): The last stream_format created, to - which created audioTrackFormats will be linked by default. + last_programme (Optional[AudioProgramme]): The last programme created, + to which created audioContents will be linked. + last_content (Optional[AudioContent]): The last content created, to + which created audioObjects will be linked by default. + last_object (Optional[AudioObject]): The last object created, to which + created audioObjects or audioPackFormats will be linked by default. + last_pack_format (Optional[AudioPackFormat]): The last pack_format + created, to which created audioChannelFormats will be linked by + default. + last_stream_format (Optional[AudioStreamFormat]): The last + stream_format created, to which created audioTrackFormats will be + linked by default. + item_parent (Optional[Union[AudioContent, AudioObject]]): The last + explicitly created audioContent or audioObject, used as the parent + for audioObjects created by create_item* functions. + use_track_uid_to_channel_format_ref (bool): Use audioTrackUID to + audioChannelFormat references; set by default when adm.version is + at least BS.2076-2 """ + adm = attrib(default=Factory(ADM)) last_programme = attrib(default=None) last_content = attrib(default=None) @@ -31,20 +85,37 @@ class ADMBuilder(object): last_pack_format = attrib(default=None) last_stream_format = attrib(default=None) item_parent = attrib(default=None) + use_track_uid_to_channel_format_ref = attrib() + + @use_track_uid_to_channel_format_ref.default + def _use_track_uid_to_channel_format_ref_default(self): + return version_at_least(self.adm.version, 2) + + @classmethod + def for_version(cls, version): + """Make a builder for a given ADM version (either int or BS2076Version). + + Args: + version (int or BS2076Version): version to set on ADM + """ + if isinstance(version, int): + version = BS2076Version(version) + return cls(ADM(version=version)) def load_common_definitions(self): """Load common definitions into adm.""" from .common_definitions import load_common_definitions + load_common_definitions(self.adm) def create_programme(self, **kwargs): """Create a new audioProgramme. Args: - kwargs: see AudioProgramme + kwargs: see :class:`.AudioProgramme` Returns: - AudioProgramme: created programme + AudioProgramme: created audioProgramme """ programme = AudioProgramme(**kwargs) self.adm.addAudioProgramme(programme) @@ -58,10 +129,10 @@ def create_content(self, parent=DEFAULT, **kwargs): Args: parent (AudioProgramme): parent programme; defaults to the last one created - kwargs: see AudioContent + kwargs: see :class:`.AudioContent` Returns: - AudioContent: created content + AudioContent: created audioContent """ content = AudioContent(**kwargs) self.adm.addAudioContent(content) @@ -80,12 +151,12 @@ def create_object(self, parent=DEFAULT, **kwargs): """Create a new audioObject. Args: - parent (AudioContent or AudioObject): parent content or object; - defaults to the last content created - kwargs: see AudioObject + parent (Union[AudioContent, AudioObject]): parent content or + object; defaults to the last content created + kwargs: see :class:`.AudioObject` Returns: - AudioObject: created object + AudioObject: created audioObject """ object = AudioObject(**kwargs) self.adm.addAudioObject(object) @@ -106,10 +177,10 @@ def create_pack(self, parent=DEFAULT, **kwargs): Args: parent (AudioObject or AudioPackFormat): parent object or packFormat; defaults to the last object created - kwargs: see AudioPackFormat + kwargs: see :class:`.AudioPackFormat` Returns: - AudioPackFormat: created pack_format + AudioPackFormat: created audioPackFormat """ pack_format = AudioPackFormat(**kwargs) self.adm.addAudioPackFormat(pack_format) @@ -129,10 +200,10 @@ def create_channel(self, parent=DEFAULT, **kwargs): Args: parent (AudioPackFormat): parent packFormat; defaults to the last packFormat created - kwargs: see AudioChannelFormat + kwargs: see :class:`.AudioChannelFormat` Returns: - AudioChannelFormat: created channel_format + AudioChannelFormat: created audioChannelFormat """ channel_format = AudioChannelFormat(**kwargs) self.adm.addAudioChannelFormat(channel_format) @@ -151,7 +222,7 @@ def create_stream(self, **kwargs): kwargs: see AudioChannelFormat Returns: - AudioStreamFormat: created stream_format + AudioStreamFormat: created audioStreamFormat """ stream_format = AudioStreamFormat(**kwargs) self.adm.addAudioStreamFormat(stream_format) @@ -169,7 +240,7 @@ def create_track(self, parent=DEFAULT, **kwargs): kwargs: see AudioTrackFormat Returns: - AudioTrackFormat: created track_format + AudioTrackFormat: created audioTrackFormat """ track_format = AudioTrackFormat(**kwargs) self.adm.addAudioTrackFormat(track_format) @@ -190,7 +261,7 @@ def create_track_uid(self, parent=DEFAULT, **kwargs): kwargs: see AudioTrackUID Returns: - AudioTrackUID: created track_uid + AudioTrackUID: created audioTrackUID """ track_uid = AudioTrackUID(**kwargs) self.adm.addAudioTrackUID(track_uid) @@ -203,70 +274,315 @@ def create_track_uid(self, parent=DEFAULT, **kwargs): return track_uid @attrs - class MonoItem(object): - """Structure referencing the ADM components created as part of a mono item.""" - channel_format = attrib() - track_format = attrib() + class Format(object): + """Structure referencing the ADM components of a format with a + particular channel layout. + + This holds an audioPackFormat, and one audioChannelFormat per channel + in the format. + + If use_track_uid_to_channel_format_ref is not set, it also holds one + audioTrackFormat and audioStreamFormat per channel; otherwise the + corresponding attributes contain empty lists. + + Attributes: + channel_formats (list[AudioChannelFormat]) + track_formats (list[AudioTrackFormat]) + pack_format (AudioPackFormat) + stream_formats (list[AudioStreamFormat]) + """ + + channel_formats = attrib() + track_formats = attrib() pack_format = attrib() - stream_format = attrib() - track_uid = attrib() + stream_formats = attrib() + + channel_format = _make_singlar_property("channel_formats", "channel_format") + track_format = _make_singlar_property("track_formats", "track_format") + stream_format = _make_singlar_property("stream_formats", "stream_format") + + @attrs + class Item(object): + """Structure referencing the ADM components of a created item. + + This holds an audioObject, one audioTrackUID per channel, and a + reference to :class:`Format` for the format parts of the item. + + Attributes: + format (Format) + track_uids (list[AudioTrackUID]) + audio_object (AudioObject) + parent (Union[AudioContent, AudioObject]) + """ + + format = attrib() + track_uids = attrib() audio_object = attrib() parent = attrib() - def create_item_mono(self, type, track_index, name, parent=DEFAULT, block_formats=[]): + track_uid = _make_singlar_property("track_uids", "track_uid") + + channel_formats = _make_format_property("channel_formats") + track_formats = _make_format_property("track_formats") + pack_format = _make_format_property("pack_format") + stream_formats = _make_format_property("stream_formats") + + channel_format = _make_format_property("channel_format") + track_format = _make_format_property("track_format") + stream_format = _make_format_property("stream_format") + + MonoItem = Item + """compatibility alias for users expecting \\*_mono to return MonoItem""" + + def create_format_mono( + self, + type, + name, + block_formats=None, + ): + """Create ADM components needed to represent a mono format. + + This makes: + + - an audioChannelFormat with the given block_formats + - an audioPackFormat linked to the audioChannelFormat + - an audioStreamFormat linked to the audioChannelFormat + - an audioTrackFormat linked to the audioStreamFormat + + Args: + type (TypeDefinition): type of channelFormat and packFormat + name (str): name used for all components + block_formats (list[AudioBlockFormat]): block formats to add to + the channel format + + Returns: + Format: the created components + """ + if block_formats is None: + block_formats = [] + + format = self.create_format_multichannel( + type=type, + name=name, + block_formats=[block_formats], + ) + + self.last_pack_format = format.pack_format + if not self.use_track_uid_to_channel_format_ref: + self.last_stream_format = format.stream_format + + return format + + def create_item_mono_from_format( + self, + format, + track_index, + name, + parent=DEFAULT, + ): + """Create ADM components needed to represent a mono channel given an + existing format. + + This makes: + + - an audioTrackUID linked to the audioTrackFormat and audioPackFormat + of format + - an audioObject linked to the audioTrackUID and audioPackFormat + + Args: + format (Format) + track_index (int): zero-based index of the track in the BWF file. + name (str): name used for all components + parent (Union[AudioContent, AudioObject]): parent of the created audioObject + defaults to the last content or explicitly created object + + Returns: + Item: the created components + """ + item = self.create_item_multichannel_from_format( + format=format, + track_indices=[track_index], + name=name, + parent=parent, + ) + + return item + + def create_item_mono( + self, type, track_index, name, parent=DEFAULT, block_formats=None, + ): """Create ADM components needed to represent a mono channel, either DirectSpeakers or Objects. + This makes: + + - an audioChannelFormat with the given block_formats + - an audioPackFormat linked to the audioChannelFormat + - an audioStreamFormat linked to the audioChannelFormat + - an audioTrackFormat linked to the audioStreamFormat + - an audioTrackUID linked to the audioTrackFormat and audioPackFormat + - an audioObject linked to the audioTrackUID and audioPackFormat + Args: type (TypeDefinition): type of channelFormat and packFormat track_index (int): zero-based index of the track in the BWF file. name (str): name used for all components - parent (AudioContent or AudioObject): parent of the created audioObject + parent (Union[AudioContent, AudioObject]): parent of the created audioObject defaults to the last content or explicitly created object - block_formats (list of AudioBlockFormat): block formats to add to + block_formats (list[AudioBlockFormat]): block formats to add to the channel format Returns: - MonoItem: the created components + Item: the created components """ - channel_format = AudioChannelFormat( - audioChannelFormatName=name, + if block_formats is None: + block_formats = [] + + item = self.create_item_multichannel( type=type, - audioBlockFormats=block_formats) - self.adm.addAudioChannelFormat(channel_format) + track_indices=[track_index], + name=name, + block_formats=[block_formats], + parent=parent, + ) + + return item + + def create_item_objects(self, *args, **kwargs): + """Create ADM components needed to represent an object channel. + + Wraps :func:`create_item_mono` with ``type=TypeDefinition.Objects``. + + Returns: + Item: the created components + """ + return self.create_item_mono(TypeDefinition.Objects, *args, **kwargs) + + def create_item_direct_speakers(self, *args, **kwargs): + """Create ADM components needed to represent a DirectSpeakers channel. + + Wraps :func:`create_item_mono` with ``type=TypeDefinition.DirectSpeakers``. + + Returns: + Item: the created components + """ + return self.create_item_mono(TypeDefinition.DirectSpeakers, *args, **kwargs) + + def create_format_multichannel(self, type, name, block_formats): + """Create ADM components representing a multi-channel format. + + This makes: + + - an audioChannelFormat for each channel + - an audioStreamFormat linked to each audioChannelFormat + - an audioTrackFormat linked to each audioStreamFormat + - an audioPackFormat linked to the audioChannelFormats + + Args: + type (TypeDefinition): type of channelFormat and packFormat + name (str): name used for all components + block_formats (list[list[AudioBlockFormat]]): list of audioBlockFormats + for each audioChannelFormat + + Returns: + Format: the created components + """ + channel_formats = [] + stream_formats = [] + track_formats = [] + + for i, channel_block_formats in enumerate(block_formats): + channel_name = f"{name}_{i + 1}" if len(block_formats) > 1 else name + + channel_format = AudioChannelFormat( + audioChannelFormatName=channel_name, + type=type, + audioBlockFormats=channel_block_formats, + ) + self.adm.addAudioChannelFormat(channel_format) + channel_formats.append(channel_format) + + if not self.use_track_uid_to_channel_format_ref: + stream_format = AudioStreamFormat( + audioStreamFormatName=channel_name, + format=FormatDefinition.PCM, + audioChannelFormat=channel_format, + ) + self.adm.addAudioStreamFormat(stream_format) + stream_formats.append(stream_format) + + track_format = AudioTrackFormat( + audioTrackFormatName=channel_name, + audioStreamFormat=stream_format, + format=FormatDefinition.PCM, + ) + self.adm.addAudioTrackFormat(track_format) + track_formats.append(track_format) pack_format = AudioPackFormat( audioPackFormatName=name, type=type, - audioChannelFormats=[channel_format], + audioChannelFormats=channel_formats[:], ) self.adm.addAudioPackFormat(pack_format) - stream_format = AudioStreamFormat( - audioStreamFormatName=name, - format=FormatDefinition.PCM, - audioChannelFormat=channel_format, + return self.Format( + channel_formats=channel_formats, + track_formats=track_formats, + pack_format=pack_format, + stream_formats=stream_formats, ) - self.adm.addAudioStreamFormat(stream_format) - track_format = AudioTrackFormat( - audioTrackFormatName=name, - audioStreamFormat=stream_format, - format=FormatDefinition.PCM, - ) - self.adm.addAudioTrackFormat(track_format) + def create_item_multichannel_from_format( + self, + format, + track_indices, + name, + parent=DEFAULT, + ): + """Create ADM components representing a multi-channel object, + referencing an existing format structure. - track_uid = AudioTrackUID( - trackIndex=track_index + 1, - audioTrackFormat=track_format, - audioPackFormat=pack_format, - ) - self.adm.addAudioTrackUID(track_uid) + This makes: + + - an audioTrackUID linked to each audioTrackFormat and the audioPackFormat in format + - an audioObject linked to the audioTrackUIDs and the audioPackFormat in format + + Args: + format (Format): format components to reference + track_indices (list[int]): zero-based indices of the tracks in the BWF file. + name (str): name used for all components + parent (Union[AudioContent, AudioObject]): parent of the created audioObject + defaults to the last content or explicitly created object + + Returns: + Item: the created components + """ + if len(track_indices) != len(format.channel_formats): + raise ValueError( + "track_indices and format must have the same number of channels" + ) + + track_uids = [] + + for i, track_index in enumerate(track_indices): + track_uid = AudioTrackUID( + trackIndex=track_index + 1, + audioPackFormat=format.pack_format, + ) + + if self.use_track_uid_to_channel_format_ref: + track_uid.audioChannelFormat = format.channel_formats[i] + else: + track_uid.audioTrackFormat = format.track_formats[i] + + self.adm.addAudioTrackUID(track_uid) + track_uids.append(track_uid) audio_object = AudioObject( audioObjectName=name, - audioPackFormats=[pack_format], - audioTrackUIDs=[track_uid], + audioPackFormats=[format.pack_format], + audioTrackUIDs=track_uids[:], ) self.adm.addAudioObject(audio_object) @@ -276,29 +592,135 @@ def create_item_mono(self, type, track_index, name, parent=DEFAULT, block_format parent.audioObjects.append(audio_object) self.last_object = audio_object - self.last_pack_format = pack_format - self.last_stream_format = stream_format + self.last_pack_format = format.pack_format - return self.MonoItem( - channel_format=channel_format, - track_format=track_format, - pack_format=pack_format, - stream_format=stream_format, - track_uid=track_uid, + return self.Item( + format=format, + track_uids=track_uids, audio_object=audio_object, parent=parent, ) - def create_item_objects(self, *args, **kwargs): - """Create ADM components needed to represent an object channel. + def create_item_multichannel( + self, + type, + track_indices, + name, + block_formats, + parent=DEFAULT, + ): + """Create ADM components representing a multi-channel object. + + This makes: + + - an audioChannelFormat for each channel + - an audioStreamFormat linked to each audioChannelFormat + - an audioTrackFormat linked to each audioStreamFormat + - an audioPackFormat linked to the audioChannelFormats + - an audioTrackUID linked to each audioTrackFormat and the audioPackFormat + - an audioObject linked to the audioTrackUIDs and the audioPackFormat + + Args: + type (TypeDefinition): type of channelFormat and packFormat + track_indices (list[int]): zero-based indices of the tracks in the BWF file. + name (str): name used for all components + block_formats (list[list[AudioBlockFormat]]): list of audioBlockFormats + for each audioChannelFormat + parent (Union[AudioContent, AudioObject]): parent of the created audioObject + defaults to the last content or explicitly created object - Wraps create_item_mono with type=TypeDefinition.Objects. + Returns: + Item: the created components """ - return self.create_item_mono(TypeDefinition.Objects, *args, **kwargs) + if len(track_indices) != len(block_formats): + raise ValueError("track_indices and block_formats must be the same length") - def create_item_direct_speakers(self, *args, **kwargs): - """Create ADM components needed to represent a DirectSpeakers channel. + format = self.create_format_multichannel( + type=type, + name=name, + block_formats=block_formats, + ) + + return self.create_item_multichannel_from_format( + format=format, + track_indices=track_indices, + name=name, + parent=parent, + ) + + def create_format_hoa( + self, + orders, + degrees, + name, + **kwargs, + ): + """Create ADM components representing the format of a HOA stream. + + This is a wrapper around :func:`create_format_multichannel`. - Wraps create_item_mono with type=TypeDefinition.DirectSpeakers. + Args: + orders (list[int]): order for each track + degrees (list[int]): degree for each track + name (str): name used for all components + kwargs: arguments for :class:`.AudioBlockFormatHoa` + + Returns: + Format: the created components """ - return self.create_item_mono(TypeDefinition.DirectSpeakers, *args, **kwargs) + block_formats = [ + [ + AudioBlockFormatHoa( + order=order, + degree=degree, + **kwargs, + ) + ] + for order, degree in zip(orders, degrees) + ] + + return self.create_format_multichannel( + type=TypeDefinition.HOA, + name=name, + block_formats=block_formats, + ) + + def create_item_hoa( + self, + track_indices, + orders, + degrees, + name, + parent=DEFAULT, + **kwargs, + ): + """Create ADM components representing a HOA stream. + + This is a wrapper around :func:`create_format_hoa` and + :func:`create_item_multichannel_from_format`. + + Args: + track_indices (list[int]): zero-based indices of the tracks in the BWF file. + orders (list[int]): order for each track + degrees (list[int]): degree for each track + name (str): name used for all components + parent (Union[AudioContent, AudioObject]): parent of the created audioObject + defaults to the last content or explicitly created object + kwargs: arguments for :class:`.AudioBlockFormatHoa` + + Returns: + Item: the created components + """ + format = self.create_format_hoa( + orders=orders, + degrees=degrees, + name=name, + **kwargs, + ) + + return self.create_item_multichannel_from_format( + format=format, + track_indices=track_indices, + name=name, + parent=parent, + ) diff --git a/ear/fileio/adm/chna.py b/ear/fileio/adm/chna.py index 9369d1e2..1a006526 100644 --- a/ear/fileio/adm/chna.py +++ b/ear/fileio/adm/chna.py @@ -1,7 +1,61 @@ +from collections import namedtuple import re from .elements import AudioTrackUID from ..bw64.chunks import AudioID +_TrackOrChannelRef = namedtuple("_TrackOrChannelRef", ("is_channel", "id")) + + +def _load_track_or_channel_ref(track, chna_entry): + """update an audioTrackUID with the track format or channel format reference in a CHNA entry""" + # chna and audioTrackUID both reference either a track or a channel format; + # for both, find which it is, and its ID + chna_track_ref = chna_entry.audioTrackFormatIDRef + chna_ref = _TrackOrChannelRef( + chna_track_ref.startswith("AC_"), chna_track_ref.upper() + ) + + track_ref = None + if track.audioTrackFormat is not None and track.audioChannelFormat is not None: + # this check is already in validation, but this code normally runs + # first, and if we don't check here then the exception below could be + # thrown first, which would be confusing + raise Exception( + f"audioTrackUID {track.id} is linked to both an audioTrackFormat and a audioChannelFormat" + ) + elif track.audioChannelFormat is not None: + track_ref = _TrackOrChannelRef(True, track.audioChannelFormat.id.upper()) + elif track.audioTrackFormat is not None: + track_ref = _TrackOrChannelRef(False, track.audioTrackFormat.id.upper()) + + # then assign if it has no reference, or check the reference is the same + if track_ref is None: + if chna_ref.is_channel: + track.audioChannelFormatIDRef = chna_ref.id + else: + track.audioTrackFormatIDRef = chna_ref.id + elif chna_ref != track_ref: + raise Exception( + f"Error in track UID {track.id}: CHNA entry references '{chna_ref.id}' " + f"but AXML references '{track_ref.id}'" + ) + + +def _load_pack_ref(track, chna_entry): + """update an audioTrackUID with the pack format reference in a CHNA entry""" + if chna_entry.audioPackFormatIDRef is not None: + if track.audioPackFormat is None: + track.audioPackFormatIDRef = chna_entry.audioPackFormatIDRef + elif ( + track.audioPackFormat.id.upper() != chna_entry.audioPackFormatIDRef.upper() + ): + raise Exception( + "Error in track UID {track.id}: audioPackFormatIDRef in CHNA, '{chna_entry.audioPackFormatIDRef}' " + "does not match value in AXML, '{track.audioPackFormat.id}'.".format( + track=track, chna_entry=chna_entry + ) + ) + def load_chna_chunk(adm, chna): """Add information from the chna chunk to the adm track UIDs structure. @@ -31,20 +85,8 @@ def load_chna_chunk(adm, chna): else: assert track.trackIndex == chna_entry.trackIndex - if track.audioTrackFormat is None: - track.audioTrackFormatIDRef = chna_entry.audioTrackFormatIDRef - elif track.audioTrackFormat.id.upper() != chna_entry.audioTrackFormatIDRef.upper(): - raise Exception("Error in track UID {track.id}: audioTrackFormatIDRef in CHNA, '{chna_entry.audioTrackFormatIDRef}' " - "does not match value in AXML, '{track.audioTrackFormat.id}'.".format( - track=track, chna_entry=chna_entry)) - - if chna_entry.audioPackFormatIDRef is not None: - if track.audioPackFormat is None: - track.audioPackFormatIDRef = chna_entry.audioPackFormatIDRef - elif track.audioPackFormat.id.upper() != chna_entry.audioPackFormatIDRef.upper(): - raise Exception("Error in track UID {track.id}: audioPackFormatIDRef in CHNA, '{chna_entry.audioPackFormatIDRef}' " - "does not match value in AXML, '{track.audioPackFormat.id}'.".format( - track=track, chna_entry=chna_entry)) + _load_track_or_channel_ref(track, chna_entry) + _load_pack_ref(track, chna_entry) for atu in adm.audioTrackUIDs: if atu.id is not None and atu.id.upper() == "ATU_00000000": @@ -58,17 +100,22 @@ def _get_chna_entries(adm): for track_uid in adm.audioTrackUIDs: if track_uid.trackIndex is None: raise Exception("Track UID {track_uid.id} has no track number.".format(track_uid=track_uid)) - if track_uid.audioTrackFormat is None: - raise Exception("Track UID {track_uid.id} has no track format.".format(track_uid=track_uid)) + if (track_uid.audioTrackFormat is None) and (track_uid.audioChannelFormat is None): + raise Exception("Track UID {track_uid.id} has no track or channel format.".format(track_uid=track_uid)) + if (track_uid.audioTrackFormat is not None) and (track_uid.audioChannelFormat is not None): + raise Exception("Track UID {track_uid.id} has both track and channel formats.".format(track_uid=track_uid)) assert track_uid.id is not None, "ids have not been generated" - assert track_uid.audioTrackFormat.id is not None, "ids have not been generated" + assert track_uid.audioTrackFormat is None or track_uid.audioTrackFormat.id is not None, "ids have not been generated" + assert track_uid.audioChannelFormat is None or track_uid.audioChannelFormat.id is not None, "ids have not been generated" assert track_uid.audioPackFormat is None or track_uid.audioPackFormat.id is not None, "ids have not been generated" yield AudioID( trackIndex=track_uid.trackIndex, audioTrackUID=track_uid.id, - audioTrackFormatIDRef=track_uid.audioTrackFormat.id, + audioTrackFormatIDRef=(track_uid.audioTrackFormat.id + if track_uid.audioTrackFormat is not None + else track_uid.audioChannelFormat.id), audioPackFormatIDRef=(track_uid.audioPackFormat.id if track_uid.audioPackFormat is not None else None)) @@ -84,7 +131,7 @@ def populate_chna_chunk(chna, adm): generated before calling this. Parameters: - adm (ADM): adm structure to get information to + adm (ADM): adm structure to get information from chna (ChnaChunk): chna chunk to populate """ chna.audioIDs = list(_get_chna_entries(adm)) @@ -110,3 +157,20 @@ def guess_track_indices(adm): raise Exception("Invalid track UID {}.".format(track_uid.id)) track_uid.trackIndex = int(match.group(1), 16) + + +def validate_trackIndex(adm, num_channels): + """Check that all audioTrackUIDs in adm have a trackIndex that is valid in + a file with num_channels. + + Parameters: + adm (ADM): adm structure containing audioTrackUIDs to check + num_channels (int): number of channels in the BW64 file + """ + for track_uid in adm.audioTrackUIDs: + if track_uid.trackIndex is not None and track_uid.trackIndex > num_channels: + tracks = "track" if num_channels == 1 else "tracks" + raise Exception( + f"audioTrackUID {track_uid.id} has track index {track_uid.trackIndex} " + f"(1-based) in a file with {num_channels} {tracks}" + ) diff --git a/ear/fileio/adm/common_definitions.py b/ear/fileio/adm/common_definitions.py index 2eeecc0a..8135cfc3 100644 --- a/ear/fileio/adm/common_definitions.py +++ b/ear/fileio/adm/common_definitions.py @@ -1,11 +1,19 @@ -import pkg_resources -from .xml import parse_adm_elements +import importlib_resources +from .xml import parse_audioFormatExtended import lxml.etree def load_common_definitions(adm): + """Load the common definitions file from IRU-R Rec. BS.2094-1, setting + is_common_definition=True on all loaded elements. + + Parameters: + adm (ADM): ADM structure to add to. + """ fname = "data/2094_common_definitions.xml" - with pkg_resources.resource_stream(__name__, fname) as stream: + path = importlib_resources.files("ear.fileio.adm") / fname + + with path.open() as stream: element = lxml.etree.parse(stream) - parse_adm_elements(adm, element, common_definitions=True) + parse_audioFormatExtended(adm, element, common_definitions=True) adm.lazy_lookup_references() diff --git a/ear/fileio/adm/elements/__init__.py b/ear/fileio/adm/elements/__init__.py index 504e0794..dbc7d195 100644 --- a/ear/fileio/adm/elements/__init__.py +++ b/ear/fileio/adm/elements/__init__.py @@ -1,10 +1,48 @@ # flake8: noqa -from .main_elements import (AudioChannelFormat, AudioPackFormat, AudioTrackFormat, - AudioStreamFormat, AudioProgramme, AudioContent, AudioObject, AudioTrackUID, - FormatDefinition, TypeDefinition, Frequency) -from .block_formats import (AudioBlockFormatObjects, ChannelLock, ObjectDivergence, - JumpPosition, AudioBlockFormatDirectSpeakers, AudioBlockFormatBinaural, AudioBlockFormatHoa, - AudioBlockFormatMatrix, MatrixCoefficient, - CartesianZone, PolarZone) -from .geom import (DirectSpeakerPolarPosition, DirectSpeakerCartesianPosition, BoundCoordinate, - ObjectPolarPosition, ObjectCartesianPosition, ScreenEdgeLock) +from .block_formats import ( + AudioBlockFormat, + AudioBlockFormatBinaural, + AudioBlockFormatDirectSpeakers, + AudioBlockFormatHoa, + AudioBlockFormatMatrix, + AudioBlockFormatObjects, + CartesianZone, + ChannelLock, + JumpPosition, + MatrixCoefficient, + ObjectDivergence, + PolarZone, +) +from .geom import ( + BoundCoordinate, + CartesianPositionInteractionRange, + CartesianPositionOffset, + DirectSpeakerCartesianPosition, + DirectSpeakerPolarPosition, + DirectSpeakerPosition, + InteractionRange, + ObjectCartesianPosition, + ObjectPolarPosition, + ObjectPosition, + PolarPositionInteractionRange, + PolarPositionOffset, + PositionInteractionRange, + PositionOffset, + ScreenEdgeLock, +) +from .main_elements import ( + AlternativeValueSet, + AudioChannelFormat, + AudioContent, + AudioObject, + AudioObjectInteraction, + AudioPackFormat, + AudioProgramme, + AudioStreamFormat, + AudioTrackFormat, + AudioTrackUID, + FormatDefinition, + Frequency, + LoudnessMetadata, + TypeDefinition, +) diff --git a/ear/fileio/adm/elements/block_formats.py b/ear/fileio/adm/elements/block_formats.py index 6e13c497..de1400d5 100644 --- a/ear/fileio/adm/elements/block_formats.py +++ b/ear/fileio/adm/elements/block_formats.py @@ -1,33 +1,67 @@ from attr import attrs, attrib, Factory, validate from attr.validators import instance_of, optional from fractions import Fraction -from ....common import list_of +from ....common import finite_float, list_of from .geom import convert_object_position, DirectSpeakerPosition, ObjectPosition from .main_elements import AudioChannelFormat, TypeDefinition @attrs(slots=True) -class BlockFormat(object): +class AudioBlockFormat(object): + """ADM audioBlockFormat base class + + Attributes: + id (Optional[str]) + rtime (Optional[fractions.Fraction]) + duration (Optional[fractions.Fraction]) + gain (float) + importance (int) + """ + id = attrib(default=None) rtime = attrib(validator=optional(instance_of(Fraction)), default=None) duration = attrib(validator=optional(instance_of(Fraction)), default=None) + gain = attrib(validator=finite_float(), default=1.0) + importance = attrib(default=10, validator=instance_of(int)) def lazy_lookup_references(self, adm): pass - def validate(self): + def validate(self, adm=None, audioChannelFormat=None): validate(self) + if not ( + (self.rtime is None and self.duration is None) + or (self.rtime is not None and self.duration is not None) + ): + raise ValueError("rtime and duration must be used together") + + +BlockFormat = AudioBlockFormat +"""Compatibility alias for AudioBlockFormat""" + @attrs(slots=True) class MatrixCoefficient(object): + """ADM audioBlockFormat Matrix coefficient element + + Attributes: + inputChannelFormat (Optional[AudioChannelFormat]) + gain (Optional[float]) + gainVar (Optional[str]) + phase (Optional[float]) + phaseVar (Optional[str]) + delay (Optional[float]) + delayVar (Optional[str]) + """ + inputChannelFormat = attrib(default=None, validator=optional(instance_of(AudioChannelFormat))) - gain = attrib(default=None, validator=optional(instance_of(float))) + gain = attrib(default=None, validator=optional(finite_float())) gainVar = attrib(default=None, validator=optional(instance_of(str))) - phase = attrib(default=None, validator=optional(instance_of(float))) + phase = attrib(default=None, validator=optional(finite_float())) phaseVar = attrib(default=None, validator=optional(instance_of(str))) - delay = attrib(default=None, validator=optional(instance_of(float))) + delay = attrib(default=None, validator=optional(finite_float())) delayVar = attrib(default=None, validator=optional(instance_of(str))) inputChannelFormatIDRef = attrib(default=None) @@ -37,14 +71,21 @@ def lazy_lookup_references(self, adm): self.inputChannelFormat = adm.lookup_element(self.inputChannelFormatIDRef) self.inputChannelFormatIDRef = None - def validate(self): + def validate(self, adm=None, audioChannelFormat=None, audioBlockFormat=None): validate(self) if self.inputChannelFormat is None: raise ValueError("MatrixCoefficient must have an inputChannelFormat attribute") @attrs(slots=True) -class AudioBlockFormatMatrix(BlockFormat): +class AudioBlockFormatMatrix(AudioBlockFormat): + """ADM audioBlockFormat with typeDefinition == "Matrix" + + Attributes: + outputChannelFormat (Optional[AudioChannelFormat]) + matrix (list[MatrixCoefficient]) + """ + outputChannelFormat = attrib(default=None, validator=optional(instance_of(AudioChannelFormat))) matrix = attrib(default=Factory(list), validator=list_of(MatrixCoefficient)) @@ -58,83 +99,156 @@ def lazy_lookup_references(self, adm): for coefficient in self.matrix: coefficient.lazy_lookup_references(adm) - def validate(self): - super(AudioBlockFormatMatrix, self).validate() + def validate(self, adm=None, audioChannelFormat=None): + super(AudioBlockFormatMatrix, self).validate(adm=adm, audioChannelFormat=audioChannelFormat) for coefficient in self.matrix: - coefficient.validate() + coefficient.validate(adm=adm, audioChannelFormat=audioChannelFormat, audioBlockFormat=self) @attrs(slots=True) class ChannelLock(object): - maxDistance = attrib(default=None, validator=optional(instance_of(float))) + """ADM channelLock element + + Attributes: + maxDistance (Optional[float]) + """ + + maxDistance = attrib(default=None, validator=optional(finite_float())) @attrs(slots=True) class ObjectDivergence(object): - value = attrib(validator=instance_of(float)) - azimuthRange = attrib(default=None, validator=optional(instance_of(float))) - positionRange = attrib(default=None, validator=optional(instance_of(float))) + """ADM objectDivergence element + + Attributes: + value (float) + azimuthRange (Optional[float]) + positionRange (Optional[float]) + """ + + value = attrib(validator=finite_float()) + azimuthRange = attrib(default=None, validator=optional(finite_float())) + positionRange = attrib(default=None, validator=optional(finite_float())) @attrs(slots=True) class JumpPosition(object): + """ADM jumpPosition element + + Attributes: + flag (bool): contents of the jumpPosition element + interpolationLength (Optional[fractions.Fraction]) + """ + flag = attrib(default=False, validator=instance_of(bool)) interpolationLength = attrib(default=None, validator=optional(instance_of(Fraction))) @attrs(slots=True) class CartesianZone(object): - minX = attrib(validator=instance_of(float)) - minY = attrib(validator=instance_of(float)) - minZ = attrib(validator=instance_of(float)) - maxX = attrib(validator=instance_of(float)) - maxY = attrib(validator=instance_of(float)) - maxZ = attrib(validator=instance_of(float)) + """ADM zoneExclusion zone element with Cartesian coordinates + + Attributes: + minX (float) + minY (float) + minZ (float) + maxX (float) + maxY (float) + maxZ (float) + """ + + minX = attrib(validator=finite_float()) + minY = attrib(validator=finite_float()) + minZ = attrib(validator=finite_float()) + maxX = attrib(validator=finite_float()) + maxY = attrib(validator=finite_float()) + maxZ = attrib(validator=finite_float()) @attrs(slots=True) class PolarZone(object): - minElevation = attrib(validator=instance_of(float)) - maxElevation = attrib(validator=instance_of(float)) - minAzimuth = attrib(validator=instance_of(float)) - maxAzimuth = attrib(validator=instance_of(float)) + """ADM zoneExclusion zone element with polar coordinates + + Attributes: + minElevation (float) + maxElevation (float) + minAzimuth (float) + maxAzimuth (float) + """ + + minElevation = attrib(validator=finite_float()) + maxElevation = attrib(validator=finite_float()) + minAzimuth = attrib(validator=finite_float()) + maxAzimuth = attrib(validator=finite_float()) @attrs(slots=True) -class AudioBlockFormatObjects(BlockFormat): +class AudioBlockFormatObjects(AudioBlockFormat): + """ADM audioBlockFormat with typeDefinition == "Objects" + + Attributes: + position (Optional[ObjectPosition]) + cartesian (bool) + width (float) + height (float) + depth (float) + diffuse (float) + channelLock (Optional[ChannelLock]) + objectDivergence (Optional[ObjectDivergence]) + jumpPosition (JumpPosition) + screenRef (bool) + zoneExclusion (list[Union[CartesianZone, PolarZone]]) + """ + position = attrib(default=None, validator=instance_of(ObjectPosition), converter=convert_object_position) cartesian = attrib(converter=bool, default=False) width = attrib(converter=float, default=0.) height = attrib(converter=float, default=0.) depth = attrib(converter=float, default=0.) - gain = attrib(converter=float, default=1.) diffuse = attrib(converter=float, default=0.) channelLock = attrib(default=None, validator=optional(instance_of(ChannelLock))) objectDivergence = attrib(default=None, validator=optional(instance_of(ObjectDivergence))) jumpPosition = attrib(default=Factory(JumpPosition)) screenRef = attrib(converter=bool, default=False) - importance = attrib(default=10, validator=instance_of(int)) zoneExclusion = attrib(default=Factory(list), validator=list_of((CartesianZone, PolarZone))) @attrs(slots=True) -class AudioBlockFormatDirectSpeakers(BlockFormat): +class AudioBlockFormatDirectSpeakers(AudioBlockFormat): + """ADM audioBlockFormat with typeDefinition == "DirectSpeakers" + + Attributes: + position (DirectSpeakerPosition) + speakerLabel (list[str]) + """ + position = attrib(default=None, validator=instance_of(DirectSpeakerPosition)) speakerLabel = attrib(default=Factory(list)) @attrs(slots=True) -class AudioBlockFormatHoa(BlockFormat): +class AudioBlockFormatHoa(AudioBlockFormat): + """ADM audioBlockFormat with typeDefinition == "HOA" + + Attributes: + equation (Optional[str]) + order (Optional[int]) + degree (Optional[int]) + normalization (Optional[str]) + nfcRefDist (Optional[float]) + screenRef (Optional[bool]) + """ + equation = attrib(default=None, validator=optional(instance_of(str))) order = attrib(default=None, validator=optional(instance_of(int))) degree = attrib(default=None, validator=optional(instance_of(int))) normalization = attrib(default=None, validator=optional(instance_of(str))) - nfcRefDist = attrib(default=None, validator=optional(instance_of(float))) + nfcRefDist = attrib(default=None, validator=optional(finite_float())) screenRef = attrib(default=None, validator=optional(instance_of(bool))) @attrs(slots=True) -class AudioBlockFormatBinaural(BlockFormat): +class AudioBlockFormatBinaural(AudioBlockFormat): pass diff --git a/ear/fileio/adm/elements/geom.py b/ear/fileio/adm/elements/geom.py index b3439d57..9def88df 100644 --- a/ear/fileio/adm/elements/geom.py +++ b/ear/fileio/adm/elements/geom.py @@ -1,6 +1,14 @@ -from attr import attrs, attrib, Factory +from attr import attrs, attrib, evolve, Factory from attr.validators import instance_of, optional -from ....common import PolarPositionMixin, CartesianPositionMixin, PolarPosition, CartesianPosition, cart, validate_range +from ....common import ( + PolarPositionMixin, + CartesianPositionMixin, + PolarPosition, + CartesianPosition, + cart, + finite_float, + validate_range, +) try: # moved in py3.3 @@ -27,20 +35,47 @@ def convert_object_position(value): @attrs(slots=True) class ScreenEdgeLock(object): + """ADM screenEdgeLock information from position elements + + Attributes: + horizontal (Optional[str]): screenEdgeLock from azimuth or X + coordinates; must be ``left`` or ``right``. + vertical (Optional[str]): screenEdgeLock from elevation or Z + coordinates; must be ``top`` or ``bottom``. + """ + horizontal = attrib(default=None) vertical = attrib(default=None) class ObjectPosition(object): """Base for classes representing data contained in `audioBlockFormat` - `position` elements for Objects.""" + `position` elements for Objects. + + See Also: + :class:`ObjectPolarPosition` and :class:`ObjectCartesianPosition` + """ + __slots__ = () @attrs(slots=True) class ObjectPolarPosition(ObjectPosition, PolarPositionMixin): """Represents data contained in `audioBlockFormat` `position` elements for - Objects where polar coordinates are used.""" + Objects where polar coordinates are used. + + Attributes are formatted according to the ADM coordinate convention. + + Attributes: + azimuth (float): anti-clockwise azimuth in degrees, measured from the + front + elevation (float): elevation in degrees, measured upwards from the + equator + distance (float): distance relative to the audioPackFormat + absoluteDistance parameter + screenEdgeLock (ScreenEdgeLock) + """ + azimuth = attrib(converter=float, validator=validate_range(-180, 180)) elevation = attrib(converter=float, validator=validate_range(-90, 90)) distance = attrib(converter=float, validator=validate_range(0, float('inf')), @@ -55,7 +90,17 @@ def from_PolarPosition(cls, position): @attrs(slots=True) class ObjectCartesianPosition(ObjectPosition, CartesianPositionMixin): """Represents data contained in `audioBlockFormat` `position` elements for - Objects where Cartesian coordinates are used.""" + Objects where Cartesian coordinates are used. + + Attributes are formatted according to the ADM coordinate convention. + + Attributes: + X (float): left-to-right position, from -1 to 1 + Y (float): back-to-front position, from -1 to 1 + Z (float): bottom-to-top position, from -1 to 1 + screenEdgeLock (ScreenEdgeLock) + """ + X = attrib(converter=float) Y = attrib(converter=float) Z = attrib(converter=float) @@ -68,21 +113,58 @@ def from_CartesianPosition(cls, position): @attrs(slots=True) class BoundCoordinate(object): - value = attrib(validator=instance_of(float)) - min = attrib(validator=optional(instance_of(float)), default=None) - max = attrib(validator=optional(instance_of(float)), default=None) + """ADM position coordinate for DirectSpeakers + + This represents multiple position elements with the same coordinate, so for + azimuth this translates to: + + .. code-block:: xml + + {value} + {min} + {max} + + Attributes are formatted according to the ADM coordinate convention. + + Attributes: + value (float): value for unbounded position element + min (Optional[float]): value for position element with ``bound="min"`` + max (Optional[float]): value for position element with ``bound="max"`` + """ + + value = attrib(validator=finite_float()) + min = attrib(validator=optional(finite_float()), default=None) + max = attrib(validator=optional(finite_float()), default=None) class DirectSpeakerPosition(object): """Base for classes representing data contained in `audioBlockFormat` - `position` elements for DirestSpeakers.""" + `position` elements for DirectSpeakers. + + See Also: + :class:`DirectSpeakerPolarPosition` and + :class:`DirectSpeakerCartesianPosition` + """ __slots__ = () @attrs(slots=True) class DirectSpeakerPolarPosition(DirectSpeakerPosition, PolarPositionMixin): """Represents data contained in `audioBlockFormat` `position` elements for - DirectSpeakers where polar coordinates are used.""" + DirectSpeakers where polar coordinates are used. + + Attributes are formatted according to the ADM coordinate convention. + + Attributes: + bounded_azimuth (BoundCoordinate): data for position elements with + ``coordinate="azimuth"`` + bounded_elevation (BoundCoordinate): data for position elements with + ``coordinate="elevation"`` + bounded_distance (BoundCoordinate): data for position elements with + ``coordinate="distance"`` + screenEdgeLock (ScreenEdgeLock) + """ + bounded_azimuth = attrib(validator=instance_of(BoundCoordinate)) bounded_elevation = attrib(validator=instance_of(BoundCoordinate)) bounded_distance = attrib(validator=instance_of(BoundCoordinate), @@ -91,14 +173,18 @@ class DirectSpeakerPolarPosition(DirectSpeakerPosition, PolarPositionMixin): @property def azimuth(self): + """float: anti-clockwise azimuth in degrees, measured from the front""" return self.bounded_azimuth.value @property def elevation(self): + """float: elevation in degrees, measured upwards from the equator""" return self.bounded_elevation.value @property def distance(self): + """float: distance relative to the audioPackFormat absoluteDistance + parameter""" return self.bounded_distance.value def as_cartesian_array(self): @@ -108,7 +194,18 @@ def as_cartesian_array(self): @attrs(slots=True) class DirectSpeakerCartesianPosition(DirectSpeakerPosition, CartesianPositionMixin): """Represents data contained in `audioBlockFormat` `position` elements for - DirectSpeakers where Cartesian coordinates are used.""" + DirectSpeakers where Cartesian coordinates are used. + + Attributes: + bounded_X (BoundCoordinate): data for position elements with + ``coordinate="X"`` + bounded_Y (BoundCoordinate): data for position elements with + ``coordinate="Y"`` + bounded_Z (BoundCoordinate): data for position elements with + ``coordinate="Z"`` + screenEdgeLock (ScreenEdgeLock) + """ + bounded_X = attrib(validator=instance_of(BoundCoordinate)) bounded_Y = attrib(validator=instance_of(BoundCoordinate)) bounded_Z = attrib(validator=instance_of(BoundCoordinate)) @@ -116,12 +213,126 @@ class DirectSpeakerCartesianPosition(DirectSpeakerPosition, CartesianPositionMix @property def X(self): + """float: left-to-right position, from -1 to 1""" return self.bounded_X.value @property def Y(self): + """float: back-to-front position, from -1 to 1""" return self.bounded_Y.value @property def Z(self): + """float: bottom-to-top position, from -1 to 1""" return self.bounded_Z.value + + +class PositionOffset: + """representation of positionOffset elements in audioObject or alternativeValueSet""" + + __slots__ = () + + +@attrs(slots=True) +class PolarPositionOffset(PositionOffset): + """representation of a polar positionOffset""" + + azimuth = attrib(default=0.0, validator=finite_float()) + elevation = attrib(default=0.0, validator=finite_float()) + distance = attrib(default=0.0, validator=finite_float()) + + def apply(self, pos): + if not isinstance(pos, ObjectPolarPosition): + raise ValueError( + "can only apply a polar position offset to a polar position" + ) + return evolve( + pos, + azimuth=pos.azimuth + self.azimuth, + elevation=pos.elevation + self.elevation, + distance=pos.distance + self.distance, + ) + + +@attrs(slots=True) +class CartesianPositionOffset(PositionOffset): + """representation of a cartesian positionOffset""" + + X = attrib(default=0.0, validator=finite_float()) + Y = attrib(default=0.0, validator=finite_float()) + Z = attrib(default=0.0, validator=finite_float()) + + def apply(self, pos): + if not isinstance(pos, ObjectCartesianPosition): + raise ValueError( + "can only apply a cartesian position offset to a cartesian position" + ) + return evolve( + pos, + X=pos.X + self.X, + Y=pos.Y + self.Y, + Z=pos.Z + self.Z, + ) + + +@attrs(slots=True) +class InteractionRange(object): + """a minimum and maximum bound for a single number + + Attributes: + min (Optional[float]): lower bound + max (Optional[float]): upper bound + """ + + min = attrib(validator=optional(finite_float()), default=None) + max = attrib(validator=optional(finite_float()), default=None) + + +class PositionInteractionRange: + """representation of a set of positionInteractionRange elements, for either + Cartesian or polar coordinates + """ + + __slots__ = () + + +@attrs(slots=True) +class PolarPositionInteractionRange(PositionInteractionRange): + """polar positionInteractionRange elements + + Attributes: + azimuth (InteractionRange): upper and lower bound for azimuth + elevation (InteractionRange): upper and lower bound for elevation + distance (InteractionRange): upper and lower bound for distance + """ + + azimuth = attrib( + validator=instance_of(InteractionRange), default=Factory(InteractionRange) + ) + elevation = attrib( + validator=instance_of(InteractionRange), default=Factory(InteractionRange) + ) + distance = attrib( + validator=instance_of(InteractionRange), default=Factory(InteractionRange) + ) + + +@attrs(slots=True) +class CartesianPositionInteractionRange(PositionInteractionRange): + """Cartesian positionInteractionRange elements + + Attributes: + X (InteractionRange): upper and lower bound for X + Y (InteractionRange): upper and lower bound for Y + Z (InteractionRange): upper and lower bound for Z + """ + + X = attrib( + validator=instance_of(InteractionRange), default=Factory(InteractionRange) + ) + Y = attrib( + validator=instance_of(InteractionRange), default=Factory(InteractionRange) + ) + Z = attrib( + validator=instance_of(InteractionRange), default=Factory(InteractionRange) + ) diff --git a/ear/fileio/adm/elements/main_elements.py b/ear/fileio/adm/elements/main_elements.py index 58c4e65d..ae04a44a 100644 --- a/ear/fileio/adm/elements/main_elements.py +++ b/ear/fileio/adm/elements/main_elements.py @@ -1,11 +1,18 @@ from attr import attrs, attrib, Factory, validate -from attr.validators import instance_of, optional +from attr.validators import and_, gt, instance_of, optional from enum import Enum from fractions import Fraction from six import string_types +from .geom import PositionOffset, InteractionRange, PositionInteractionRange from ..exceptions import AdmError -from ....common import CartesianScreen, PolarScreen, default_screen, list_of +from ....common import ( + CartesianScreen, + PolarScreen, + default_screen, + finite_float, + list_of, +) def _lookup_elements(adm, idRefs): @@ -24,6 +31,10 @@ def _link_track_stream_format(audioTrackFormat, audioStreamFormat): class TypeDefinition(Enum): + """ADM type definitions, representing ``typeDefintion`` and ``typeLabel`` + attributes. + """ + DirectSpeakers = 1 Matrix = 2 Objects = 3 @@ -32,11 +43,104 @@ class TypeDefinition(Enum): class FormatDefinition(Enum): + """ADM format definitions, representing ``formatDefintion`` and + ``formatLabel`` attributes. + """ + PCM = 1 +@attrs(slots=True) +class LoudnessMetadata(object): + """ADM loudnessMetadata + + Attributes: + loudnessMethod (Optional[str]) + loudnessRecType (Optional[str]) + loudnessCorrectionType (Optional[str]) + integratedLoudness (Optional[float]) + loudnessRange (Optional[float]) + maxTruePeak (Optional[float]) + maxMomentary (Optional[float]) + maxShortTerm (Optional[float]) + dialogueLoudness (Optional[float]) + """ + + loudnessMethod = attrib(default=None, validator=optional(instance_of(string_types))) + loudnessRecType = attrib( + default=None, validator=optional(instance_of(string_types)) + ) + loudnessCorrectionType = attrib( + default=None, validator=optional(instance_of(string_types)) + ) + integratedLoudness = attrib(default=None, validator=optional(finite_float())) + loudnessRange = attrib(default=None, validator=optional(finite_float())) + maxTruePeak = attrib(default=None, validator=optional(finite_float())) + maxMomentary = attrib(default=None, validator=optional(finite_float())) + maxShortTerm = attrib(default=None, validator=optional(finite_float())) + dialogueLoudness = attrib(default=None, validator=optional(finite_float())) + + +@attrs(slots=True) +class AudioObjectInteraction(object): + """ADM audioObjectInteraction element + + Attributes: + onOffInteract (bool) + gainInteract (Optional[bool]) + positionInteract (Optional[bool]) + gainInteractionRange (Optional[InteractionRange]) + positionInteractionRange (Optional[PositionInteractionRange]) + """ + + onOffInteract = attrib(validator=instance_of(bool), default=False) + gainInteract = attrib(validator=optional(instance_of(bool)), default=None) + positionInteract = attrib(validator=optional(instance_of(bool)), default=None) + + gainInteractionRange = attrib( + validator=optional(instance_of(InteractionRange)), default=None + ) + + positionInteractionRange = attrib( + validator=optional(instance_of(PositionInteractionRange)), default=None + ) + + +@attrs(slots=True) +class AlternativeValueSet(object): + """ADM alternativeValueSet + + used in audioObjects and referenced in audioProgramme and audioContents + + Attributes: + id (Optional[str]): ADM ID attribute + gain (Optional[float]) + mute (Optional[bool]) + positionOffset (Optional[PositionOffset]) + audioObjectInteraction (Optional[AudioObjectInteraction]) + """ + + id = attrib(default=None) + + gain = attrib(validator=optional(finite_float()), default=None) + mute = attrib(validator=optional(instance_of(bool)), default=None) + positionOffset = attrib( + validator=optional(instance_of(PositionOffset)), default=None + ) + audioObjectInteraction = attrib( + validator=optional(instance_of(AudioObjectInteraction)), default=None + ) + + @attrs(slots=True) class ADMElement(object): + """base class for top-level ADM elements + + Attributes: + id (str): ADM ID attribute (e.g. audioProgrammeID) + is_common_definition (bool): was this read from the common definitions file? + """ + id = attrib(default=None) is_common_definition = attrib(default=False, validator=instance_of(bool)) @@ -44,12 +148,27 @@ class ADMElement(object): def element_type(self): return type(self).__name__ - def validate(self): + def validate(self, adm=None): validate(self) @attrs(slots=True) class AudioProgramme(ADMElement): + """ADM audioProgramme + + Attributes: + audioProgrammeName (str) + audioProgrammeLanguage (Optional[str]) + start (Optional[fractions.Fraction]) + end (Optional[fractions.Fraction]) + maxDuckingDepth (Optional[float]) + audioContents (list[AudioContent]): audioContent elements referenced + via audioContentIDRef + referenceScreen (Optional[Union[CartesianScreen, PolarScreen]]) + loudnessMetadata (list[LoudnessMetadata]) + alternativeValueSets (list[AlternativeValueSet]): referenced alternativeValueSets + """ + audioProgrammeName = attrib(default=None, validator=instance_of(string_types)) audioProgrammeLanguage = attrib(default=None) start = attrib(default=None) @@ -61,6 +180,13 @@ class AudioProgramme(ADMElement): referenceScreen = attrib(validator=optional(instance_of((CartesianScreen, PolarScreen))), default=default_screen) + loudnessMetadata = attrib(default=Factory(list), validator=list_of(LoudnessMetadata)) + + alternativeValueSets = attrib( + validator=list_of(AlternativeValueSet), default=Factory(list) + ) + + alternativeValueSetIDRef = attrib(default=None) def lazy_lookup_references(self, adm): if self.audioContentIDRef is not None: @@ -70,14 +196,31 @@ def lazy_lookup_references(self, adm): @attrs(slots=True) class AudioContent(ADMElement): + """ADM audioContent + + Attributes: + audioContentName (str) + audioContentLanguage (Optional[str]) + loudnessMetadata (list[LoudnessMetadata]) + dialogue (Optional[int]) + audioObjects (list[AudioObject]) + alternativeValueSets (list[AlternativeValueSet]): referenced alternativeValueSets + """ + audioContentName = attrib(default=None, validator=instance_of(string_types)) audioContentLanguage = attrib(default=None) - loudnessMetadata = attrib(default=None) + loudnessMetadata = attrib(default=Factory(list), validator=list_of(LoudnessMetadata)) dialogue = attrib(default=None) audioObjects = attrib(default=Factory(list), repr=False) audioObjectIDRef = attrib(default=None) + alternativeValueSets = attrib( + validator=list_of(AlternativeValueSet), default=Factory(list) + ) + + alternativeValueSetIDRef = attrib(default=None) + def lazy_lookup_references(self, adm): if self.audioObjectIDRef is not None: self.audioObjects = _lookup_elements(adm, self.audioObjectIDRef) @@ -86,6 +229,27 @@ def lazy_lookup_references(self, adm): @attrs(slots=True) class AudioObject(ADMElement): + """ADM audioObject + + Attributes: + audioObjectName (str) + start (Optional[fractions.Fraction]) + duration (Optional[fractions.Fraction]) + importance (Optional[int]) + interact (Optional[bool]) + disableDucking (Optional[bool]) + dialogue (Optional[int]) + audioPackFormats (list[AudioPackFormat]) + audioTrackUIDs (list[AudioTrackUID]) + audioObjects (list[AudioObject]) + audioComplementaryObjects (list[AudioObject]) + gain (float) + mute (bool) + positionOffset (Optional[PositionOffset]) + alternativeValueSets (list[AlternativeValueSet]) + audioObjectInteraction (Optional[AudioObjectInteraction]) + """ + audioObjectName = attrib(default=None, validator=instance_of(string_types)) start = attrib(validator=optional(instance_of(Fraction)), default=None) duration = attrib(validator=optional(instance_of(Fraction)), default=None) @@ -98,6 +262,20 @@ class AudioObject(ADMElement): audioObjects = attrib(default=Factory(list), repr=False) audioComplementaryObjects = attrib(default=Factory(list), repr=False) + gain = attrib(validator=finite_float(), default=1.0) + mute = attrib(validator=instance_of(bool), default=False) + positionOffset = attrib( + validator=optional(instance_of(PositionOffset)), default=None + ) + + alternativeValueSets = attrib( + validator=list_of(AlternativeValueSet), default=Factory(list) + ) + + audioObjectInteraction = attrib( + validator=optional(instance_of(AudioObjectInteraction)), default=None + ) + audioPackFormatIDRef = attrib(default=None) audioTrackUIDRef = attrib(default=None) audioObjectIDRef = attrib(default=None) @@ -121,6 +299,27 @@ def lazy_lookup_references(self, adm): @attrs(slots=True) class AudioPackFormat(ADMElement): + """ADM audioPackformat + + Attributes: + audioPackFormatName (str) + type (TypeDefinition): ``typeDefintion`` and/or ``typeLabel`` + absoluteDistance (Optional[float]) + audioChannelFormats (list[AudioChannelFormat]) + audioPackFormats (list[AudioPackFormat]) + importance (Optional[int]) + + encodePackFormats (list[AudioPackFormat]): Only for type==Matrix. + Encode and decode pack references are a single binary many-many + relationship; we only store one side. + inputPackFormat (Optional[AudioPackFormat]): Only for type==Matrix. + outputPackFormat (Optional[AudioPackFormat]): Only for type==Matrix. + + normalization (Optional[str]): Only for type==HOA. + nfcRefDist (Optional[float]): Only for type==HOA. + screenRef (Optional[bool]): Only for type==HOA. + """ + audioPackFormatName = attrib(default=None, validator=instance_of(string_types)) type = attrib(default=None, validator=instance_of(TypeDefinition)) absoluteDistance = attrib(default=None) @@ -137,7 +336,7 @@ class AudioPackFormat(ADMElement): # attributes for type==HOA normalization = attrib(default=None, validator=optional(instance_of(str))) - nfcRefDist = attrib(default=None, validator=optional(instance_of(float))) + nfcRefDist = attrib(default=None, validator=optional(finite_float())) screenRef = attrib(default=None, validator=optional(instance_of(bool))) audioChannelFormatIDRef = attrib(default=None) @@ -181,12 +380,28 @@ def add_encodePackFormat(decode_pack, new_encode_pack): @attrs(slots=True) class Frequency(object): - lowPass = attrib(default=None, validator=optional(instance_of(float))) - highPass = attrib(default=None, validator=optional(instance_of(float))) + """ADM frequency element + + Attributes: + lowPass (Optional[float]) + highPass (Optional[float]) + """ + + lowPass = attrib(default=None, validator=optional(finite_float())) + highPass = attrib(default=None, validator=optional(finite_float())) @attrs(slots=True) class AudioChannelFormat(ADMElement): + """ADM audioChannelFormat + + Attributes: + audioChannelFormatName (str) + type (TypeDefinition): ``typeDefintion`` and/or ``typeLabel`` + audioBlockFormats (list[AudioBlockFormat]) + frequency (Frequency) + """ + audioChannelFormatName = attrib(default=None, validator=instance_of(string_types)) type = attrib(default=None, validator=instance_of(TypeDefinition)) audioBlockFormats = attrib(default=Factory(list)) @@ -202,14 +417,23 @@ def _validate_audioBlockFormats(self, attr, value): block_type = block_formats.by_type_definition[self.type] list_of(block_type)(self, attr, value) - def validate(self): - super(AudioChannelFormat, self).validate() + def validate(self, adm=None): + super(AudioChannelFormat, self).validate(adm=adm) for block in self.audioBlockFormats: - block.validate() + block.validate(adm=adm, audioChannelFormat=self) @attrs(slots=True) class AudioStreamFormat(ADMElement): + """ADM audioStreamFormat + + Attributes: + audioStreamFormatName (str) + format (FormatDefinition): ``formatDefintion`` and/or ``formatLabel`` + audioChannelFormat (Optional[AudioStreamFormat]) + audioPackFormat (Optional[AudioPackFormat]) + """ + audioStreamFormatName = attrib(default=None, validator=instance_of(string_types)) format = attrib(default=None, validator=instance_of(FormatDefinition)) @@ -234,8 +458,8 @@ def lazy_lookup_references(self, adm): _link_track_stream_format(track_format, self) self.audioTrackFormatIDRef = None - def validate(self): - super(AudioStreamFormat, self).validate() + def validate(self, adm=None): + super(AudioStreamFormat, self).validate(adm=adm) if self.audioPackFormat is not None and self.audioChannelFormat is not None: raise AdmError("audioStreamFormat {self.id} has a reference to both an " "audioPackFormat and an audioChannelFormat".format(self=self)) @@ -247,6 +471,14 @@ def validate(self): @attrs(slots=True) class AudioTrackFormat(ADMElement): + """ADM audioTrackFormat + + Attributes: + audioTrackFormatName (str) + format (FormatDefinition): ``formatDefintion`` and/or ``formatLabel`` + audioStreamFormat (Optional[AudioStreamFormat]) + """ + audioTrackFormatName = attrib(default=None, validator=instance_of(string_types)) format = attrib(default=None, validator=instance_of(FormatDefinition)) audioStreamFormat = attrib(default=None, validator=optional(instance_of(AudioStreamFormat))) @@ -259,8 +491,8 @@ def lazy_lookup_references(self, adm): _link_track_stream_format(self, stream_format) self.audioStreamFormatIDRef = None - def validate(self): - super(AudioTrackFormat, self).validate() + def validate(self, adm=None): + super(AudioTrackFormat, self).validate(adm=None) if self.audioStreamFormat is None: raise AdmError("audioTrackFormat {self.id} is not linked " "to an audioStreamFormat".format(self=self)) @@ -268,21 +500,38 @@ def validate(self): @attrs(slots=True) class AudioTrackUID(ADMElement): - trackIndex = attrib(default=None) + """ADM audioTrackUID + + Attributes: + trackIndex (Optional[int]): 1-based track index from CHNA chunk + sampleRate (Optional[int]) + bitDepth (Optional[int]) + audioTrackFormat (Optional[AudioTrackFormat]) + audioChannelFormat (Optional[AudioChannelFormat]) + audioPackFormat (Optional[AudioPackFormat]) + """ + + trackIndex = attrib(default=None, validator=optional(and_(instance_of(int), gt(0)))) sampleRate = attrib(default=None) bitDepth = attrib(default=None) audioTrackFormat = attrib(default=None, repr=False, validator=optional(instance_of(AudioTrackFormat))) + audioChannelFormat = attrib(default=None, repr=False, + validator=optional(instance_of(AudioChannelFormat))) audioPackFormat = attrib(default=None, repr=False, validator=optional(instance_of(AudioPackFormat))) audioTrackFormatIDRef = attrib(default=None) + audioChannelFormatIDRef = attrib(default=None) audioPackFormatIDRef = attrib(default=None) def lazy_lookup_references(self, adm): if self.audioTrackFormatIDRef is not None: self.audioTrackFormat = adm.lookup_element(self.audioTrackFormatIDRef) self.audioTrackFormatIDRef = None + if self.audioChannelFormatIDRef is not None: + self.audioChannelFormat = adm.lookup_element(self.audioChannelFormatIDRef) + self.audioChannelFormatIDRef = None if self.audioPackFormatIDRef is not None: self.audioPackFormat = adm.lookup_element(self.audioPackFormatIDRef) self.audioPackFormatIDRef = None diff --git a/ear/fileio/adm/elements/version.py b/ear/fileio/adm/elements/version.py new file mode 100644 index 00000000..dda35067 --- /dev/null +++ b/ear/fileio/adm/elements/version.py @@ -0,0 +1,75 @@ +from attr import attrib, attrs +from attr.validators import instance_of, optional +from typing import Union + + +@attrs(slots=True, frozen=True) +class BS2076Version: + """representation of a BS.2076 version number""" + + version = attrib(default=1, validator=instance_of(int)) + + def __str__(self): + return f"ITU-R_BS.2076-{self.version}" + + +@attrs(slots=True, frozen=True) +class UnknownVersion: + """representation of a non-BS.2076 version string""" + + version = attrib(validator=instance_of(str)) + + def __str__(self): + return self.version + + +@attrs(slots=True, frozen=True) +class NoVersion: + """A version could have been provided, but wasn't.""" + + +Version = Union[BS2076Version, UnknownVersion, NoVersion, None] +"""ADM version specifier + +there are 4 possible cases: + +`` -> `BS2076Version(2)` +`` -> `UnknownVersion("foo")` +`` -> `NoVersion()` +CHNA only mode (no AXML or no audioFormatExtended) -> `None` +""" + + +version_validator = optional(instance_of((BS2076Version, UnknownVersion, NoVersion))) + + +def version_at_least(a: Union[Version, int], b: Union[Version, int]) -> bool: + """is a at least b? + + integers will be converted to BS2076Version objects; only returns true if a + and b are both BS2076Version. + """ + if isinstance(a, int): + a = BS2076Version(a) + if isinstance(b, int): + b = BS2076Version(b) + + if isinstance(a, BS2076Version) and isinstance(b, BS2076Version): + return a.version >= b.version + else: + return False + + +def parse_version(version: Union[str, None]) -> Version: + import re + + if version is None: + return NoVersion() + + bs2076_re = r"ITU-R_BS\.2076-([0-9]+)" + match = re.fullmatch(bs2076_re, version) + + if match is not None: + return BS2076Version(int(match.group(1))) + else: + return UnknownVersion(version) diff --git a/ear/fileio/adm/exceptions.py b/ear/fileio/adm/exceptions.py index f087be8d..7aef13b4 100644 --- a/ear/fileio/adm/exceptions.py +++ b/ear/fileio/adm/exceptions.py @@ -20,7 +20,7 @@ class AdmFormatRefError(AdmError): reasons = attrib() def __str__(self): - fmt = "{message}. Possile reasons:\n{reasons}" if self.reasons else "{message}" + fmt = "{message}. Possible reasons:\n{reasons}" if self.reasons else "{message}" return fmt.format( message=self.message, diff --git a/ear/fileio/adm/generate_ids.py b/ear/fileio/adm/generate_ids.py index 4f844e74..2cacf2da 100644 --- a/ear/fileio/adm/generate_ids.py +++ b/ear/fileio/adm/generate_ids.py @@ -10,8 +10,25 @@ def _stream_track_formats(adm, stream_format): yield track_format +def _stream_type_definition(stream_format): + """get the TypeDefinition from the channel or pack format linked to a stream format""" + if stream_format.audioChannelFormat is not None: + return stream_format.audioChannelFormat.type + elif stream_format.audioPackFormat is not None: + return stream_format.audioPackFormat.type + else: + assert False, ( + "can not generate IDs for an audioStreamFormat not linked" + "to an audioChannelFormat or an audioPackFormat; run validation first" + ) + + def generate_ids(adm): - """regenerate ids for all elements in adm""" + """regenerate ids for all elements in adm + + Parameters: + adm (ADM): ADM structure to modify + """ # clear track format ids so that we can check these have all been allocated for element in non_common(adm.audioTrackFormats): element.id = None @@ -25,6 +42,9 @@ def generate_ids(adm): for id, element in enumerate(adm.audioObjects, 0x1001): element.id = "AO_{id:04X}".format(id=id) + for avs_id, avs in enumerate(element.alternativeValueSets, 0x1): + avs.id = "AVS_{id:04X}_{avs_id:04X}".format(id=id, avs_id=avs_id) + for id, element in enumerate(non_common(adm.audioPackFormats), 0x1001): element.id = "AP_{type.value:04X}{id:04X}".format(id=id, type=element.type) @@ -35,10 +55,14 @@ def generate_ids(adm): block.id = "AB_{type.value:04X}{id:04X}_{block_id:08X}".format(id=id, type=element.type, block_id=block_id) for id, element in enumerate(non_common(adm.audioStreamFormats), 0x1001): - element.id = "AS_{format.value:04X}{id:04X}".format(id=id, format=element.format) + type_id = _stream_type_definition(element).value + + element.id = "AS_{type_id:04X}{id:04X}".format(id=id, type_id=type_id) for track_id, element in enumerate(_stream_track_formats(adm, element), 0x1): - element.id = "AT_{format.value:04X}{id:04X}_{track_id:02X}".format(id=id, format=element.format, track_id=track_id) + element.id = "AT_{type_id:04X}{id:04X}_{track_id:02X}".format( + id=id, type_id=type_id, track_id=track_id + ) for id, element in enumerate(adm.audioTrackUIDs, 0x1): element.id = "ATU_{id:08X}".format(id=id) diff --git a/ear/fileio/adm/test/test_adm_files/defaults_v1.xml b/ear/fileio/adm/test/test_adm_files/defaults_v1.xml new file mode 100644 index 00000000..3ae038f3 --- /dev/null +++ b/ear/fileio/adm/test/test_adm_files/defaults_v1.xml @@ -0,0 +1,38 @@ + + + + + + ACO_1001 + + + AO_1001 + + + AP_00031001 + ATU_00000001 + + + + 0.00000 + 0.00000 + + + + AC_00031001 + + + AT_00031001_01 + AC_00031001 + + + AS_00031001 + + + AT_00031001_01 + AP_00031001 + + + + + diff --git a/ear/fileio/adm/test/test_adm_files/defaults_v2.xml b/ear/fileio/adm/test/test_adm_files/defaults_v2.xml new file mode 100644 index 00000000..3a67d7ad --- /dev/null +++ b/ear/fileio/adm/test/test_adm_files/defaults_v2.xml @@ -0,0 +1,31 @@ + + + + + + ACO_1001 + + + AO_1001 + + + AP_00031001 + ATU_00000001 + + + + 0.00000 + 0.00000 + + + + AC_00031001 + + + AC_00031001 + AP_00031001 + + + + + diff --git a/ear/fileio/adm/test/test_builder.py b/ear/fileio/adm/test/test_builder.py new file mode 100644 index 00000000..efcd90c2 --- /dev/null +++ b/ear/fileio/adm/test/test_builder.py @@ -0,0 +1,136 @@ +import pytest +from ..builder import ADMBuilder +from ..elements import ( + AudioBlockFormatHoa, + AudioBlockFormatObjects, + ObjectPolarPosition, + TypeDefinition, +) + + +@pytest.mark.parametrize("use_wrapper", [False, True]) +def test_hoa(use_wrapper): + builder = ADMBuilder() + + programme = builder.create_programme(audioProgrammeName="programme") + content = builder.create_content(audioContentName="content") + + normalization = "SN3D" + orders = [0, 1, 1, 1] + degrees = [0, -1, 0, 1] + + blocks = [ + [AudioBlockFormatHoa(order=order, degree=degree, normalization=normalization)] + for order, degree in zip(orders, degrees) + ] + track_indices = list(range(len(blocks))) + + if use_wrapper: + item = builder.create_item_multichannel( + type=TypeDefinition.HOA, + track_indices=track_indices, + name="myitem", + block_formats=blocks, + ) + else: + item = builder.create_item_hoa( + track_indices=track_indices, + name="myitem", + orders=orders, + degrees=degrees, + normalization=normalization, + ) + + for i in range(len(blocks)): + track_index = track_indices[i] + channel_blocks = blocks[i] + track_format = item.track_formats[i] + stream_format = item.stream_formats[i] + channel_format = item.channel_formats[i] + track_uid = item.track_uids[i] + + assert track_uid.trackIndex == track_index + 1 + assert track_uid.audioPackFormat is item.pack_format + assert track_uid.audioTrackFormat is track_format + + assert track_format.audioStreamFormat is stream_format + + assert stream_format.audioChannelFormat is channel_format + + assert channel_format.audioBlockFormats == channel_blocks + assert channel_format.audioChannelFormatName == f"myitem_{i+1}" + + assert item.pack_format.audioChannelFormats == item.channel_formats + + assert item.audio_object.audioPackFormats == [item.pack_format] + assert item.audio_object.audioTrackUIDs == item.track_uids + + assert programme.audioContents == [content] + assert content.audioObjects == [item.audio_object] + + +def test_mono(): + builder = ADMBuilder() + + programme = builder.create_programme(audioProgrammeName="programme") + content = builder.create_content(audioContentName="content") + + block_formats = [ + AudioBlockFormatObjects( + position=ObjectPolarPosition(azimuth=0.0, elevation=0.0, distance=1.0), + ), + ] + item = builder.create_item_objects(0, "MyObject 1", block_formats=block_formats) + + assert item.track_uid.trackIndex == 1 + assert item.track_uid.audioPackFormat is item.pack_format + assert item.track_uid.audioTrackFormat is item.track_format + + assert item.track_format.audioStreamFormat is item.stream_format + + assert item.stream_format.audioChannelFormat is item.channel_format + + assert item.channel_format.audioBlockFormats == block_formats + assert item.channel_format.audioChannelFormatName == "MyObject 1" + + assert item.pack_format.audioChannelFormats == [item.channel_format] + + assert item.audio_object.audioPackFormats == [item.pack_format] + assert item.audio_object.audioTrackUIDs == [item.track_uid] + + assert programme.audioContents == [content] + assert content.audioObjects == [item.audio_object] + + +def test_v2(): + builder = ADMBuilder.for_version(2) + assert builder.use_track_uid_to_channel_format_ref + + programme = builder.create_programme(audioProgrammeName="programme") + content = builder.create_content(audioContentName="content") + + block_formats = [ + AudioBlockFormatObjects( + position=ObjectPolarPosition(azimuth=0.0, elevation=0.0, distance=1.0), + ), + ] + item = builder.create_item_objects(0, "MyObject 1", block_formats=block_formats) + + assert item.track_uid.trackIndex == 1 + assert item.track_uid.audioPackFormat is item.pack_format + assert item.track_uid.audioTrackFormat is None + assert item.track_uid.audioChannelFormat is item.channel_format + + assert item.track_format is None + assert item.stream_format is None + + assert item.channel_format.audioBlockFormats == block_formats + assert item.channel_format.audioChannelFormatName == "MyObject 1" + + assert item.pack_format.audioChannelFormats == [item.channel_format] + + assert item.audio_object.audioPackFormats == [item.pack_format] + assert item.audio_object.audioTrackUIDs == [item.track_uid] + + assert programme.audioContents == [content] + assert content.audioObjects == [item.audio_object] diff --git a/ear/fileio/adm/test/test_chna.py b/ear/fileio/adm/test/test_chna.py index 6f177a9e..921064f9 100644 --- a/ear/fileio/adm/test/test_chna.py +++ b/ear/fileio/adm/test/test_chna.py @@ -1,75 +1,266 @@ import pytest -from ..chna import load_chna_chunk, populate_chna_chunk +from ...bw64.chunks import AudioID, ChnaChunk from ..adm import ADM -from ..elements import AudioTrackUID +from ..chna import load_chna_chunk, populate_chna_chunk, validate_trackIndex from ..common_definitions import load_common_definitions -from ...bw64.chunks import ChnaChunk, AudioID +from ..elements import AudioTrackUID -def test_load(): +@pytest.fixture +def adm(): adm = ADM() load_common_definitions(adm) - chna = ChnaChunk() - - # normal use - chna.audioIDs = [AudioID(1, "ATU_00000001", "AT_00010001_01", "AP_00010002")] - load_chna_chunk(adm, chna) - assert adm.audioTrackUIDs[0].trackIndex == 1 - assert adm.audioTrackUIDs[0].id == "ATU_00000001" - assert adm.audioTrackUIDs[0].audioTrackFormat is adm["AT_00010001_01"] - assert adm.audioTrackUIDs[0].audioPackFormat is adm["AP_00010002"] - - # missing pack ref - chna.audioIDs = [AudioID(2, "ATU_00000002", "AT_00010002_01", None)] - load_chna_chunk(adm, chna) - assert adm.audioTrackUIDs[1].audioPackFormat is None - - # inconsistent pack ref - chna.audioIDs = [AudioID(1, "ATU_00000001", "AT_00010002_01", "AP_00010002")] - with pytest.raises(Exception) as excinfo: + return adm + + +@pytest.fixture +def chna(): + return ChnaChunk() + + +class TestLoad: + def test_track_ref(self, adm, chna): + chna.audioIDs = [AudioID(1, "ATU_00000001", "AT_00010001_01", "AP_00010002")] load_chna_chunk(adm, chna) - assert str(excinfo.value) == ("Error in track UID ATU_00000001: audioTrackFormatIDRef in CHNA, " - "'AT_00010002_01' does not match value in AXML, 'AT_00010001_01'.") + assert adm.audioTrackUIDs[0].trackIndex == 1 + assert adm.audioTrackUIDs[0].id == "ATU_00000001" + assert adm.audioTrackUIDs[0].audioTrackFormat is adm["AT_00010001_01"] + assert adm.audioTrackUIDs[0].audioPackFormat is adm["AP_00010002"] - # inconsistent track ref - chna.audioIDs = [AudioID(1, "ATU_00000001", "AT_00010001_01", "AP_00010003")] - with pytest.raises(Exception) as excinfo: + def test_missing_pack_ref(self, adm, chna): + chna.audioIDs = [AudioID(2, "ATU_00000002", "AT_00010002_01", None)] load_chna_chunk(adm, chna) - assert str(excinfo.value) == ("Error in track UID ATU_00000001: audioPackFormatIDRef in CHNA, " - "'AP_00010003' does not match value in AXML, 'AP_00010002'.") + assert adm.audioTrackUIDs[0].audioPackFormat is None - # zero track uid - chna.audioIDs = [AudioID(1, "ATU_00000000", "AT_00010001_01", "AP_00010002")] - expected = ("audioTrackUID element or CHNA row found with UID " - "ATU_00000000, which is reserved for silent tracks.") - with pytest.raises(Exception, match=expected): + def test_inconsistent_pack_ref(self, adm, chna): + adm.addAudioTrackUID( + AudioTrackUID( + id="ATU_00000001", + trackIndex=1, + audioTrackFormat=adm["AT_00010001_01"], + audioPackFormat=adm["AP_00010002"], + ) + ) + chna.audioIDs = [AudioID(1, "ATU_00000001", "AT_00010001_01", "AP_00010003")] + expected = ( + "Error in track UID ATU_00000001: audioPackFormatIDRef in CHNA, " + "'AP_00010003' does not match value in AXML, 'AP_00010002'." + ) + with pytest.raises(Exception, match=expected): + load_chna_chunk(adm, chna) + + def test_inconsistent_track_ref(self, adm, chna): + adm.addAudioTrackUID( + AudioTrackUID( + id="ATU_00000001", + trackIndex=1, + audioTrackFormat=adm["AT_00010001_01"], + audioPackFormat=adm["AP_00010002"], + ) + ) + chna.audioIDs = [AudioID(1, "ATU_00000001", "AT_00010002_01", "AP_00010002")] + expected = ( + "Error in track UID ATU_00000001: CHNA entry references " + "'AT_00010002_01' but AXML references 'AT_00010001_01'" + ) + with pytest.raises(Exception, match=expected): + load_chna_chunk(adm, chna) + + def test_zero_track_uid(self, adm, chna): + chna.audioIDs = [AudioID(1, "ATU_00000000", "AT_00010001_01", "AP_00010002")] + expected = ( + "audioTrackUID element or CHNA row found with UID " + "ATU_00000000, which is reserved for silent tracks." + ) + with pytest.raises(Exception, match=expected): + load_chna_chunk(adm, chna) + + def test_channel_ref(self, adm, chna): + chna.audioIDs = [AudioID(1, "ATU_00000001", "AC_00010001", "AP_00010002")] load_chna_chunk(adm, chna) + assert adm.audioTrackUIDs[0].trackIndex == 1 + assert adm.audioTrackUIDs[0].id == "ATU_00000001" + assert adm.audioTrackUIDs[0].audioChannelFormat is adm["AC_00010001"] + assert adm.audioTrackUIDs[0].audioPackFormat is adm["AP_00010002"] + def test_inconsistent_channel_ref(self, adm, chna): + adm.addAudioTrackUID( + AudioTrackUID( + id="ATU_00000001", + trackIndex=1, + audioChannelFormat=adm["AC_00010001"], + audioPackFormat=adm["AP_00010002"], + ) + ) + chna.audioIDs = [AudioID(1, "ATU_00000001", "AC_00010002", "AP_00010002")] + expected = ( + "Error in track UID ATU_00000001: CHNA entry references " + "'AC_00010002' but AXML references 'AC_00010001'" + ) + with pytest.raises(Exception, match=expected): + load_chna_chunk(adm, chna) -def test_populate(): - adm = ADM() - load_common_definitions(adm) - chna = ChnaChunk() - - # normal use - adm.addAudioTrackUID(AudioTrackUID( - id="ATU_00000001", - trackIndex=1, - audioTrackFormat=adm["AT_00010001_01"], - audioPackFormat=adm["AP_00010002"])) - - # missing pack format - adm.addAudioTrackUID(AudioTrackUID( - id="ATU_00000002", - trackIndex=2, - audioTrackFormat=adm["AT_00010002_01"])) - - populate_chna_chunk(chna, adm) - assert chna.audioIDs == [AudioID(audioTrackUID="ATU_00000001", - trackIndex=1, - audioTrackFormatIDRef="AT_00010001_01", - audioPackFormatIDRef="AP_00010002"), - AudioID(audioTrackUID="ATU_00000002", - trackIndex=2, - audioTrackFormatIDRef="AT_00010002_01", - audioPackFormatIDRef=None)] + def test_adm_channel_chna_track(self, adm, chna): + adm.addAudioTrackUID( + AudioTrackUID( + id="ATU_00000001", + trackIndex=1, + audioChannelFormat=adm["AC_00010001"], + audioPackFormat=adm["AP_00010002"], + ) + ) + chna.audioIDs = [AudioID(1, "ATU_00000001", "AT_00010001_01", "AP_00010002")] + expected = ( + "Error in track UID ATU_00000001: CHNA entry references " + "'AT_00010001_01' but AXML references 'AC_00010001'" + ) + with pytest.raises(Exception, match=expected): + load_chna_chunk(adm, chna) + + def test_adm_track_chna_channel(self, adm, chna): + adm.addAudioTrackUID( + AudioTrackUID( + id="ATU_00000001", + trackIndex=1, + audioTrackFormat=adm["AT_00010001_01"], + audioPackFormat=adm["AP_00010002"], + ) + ) + chna.audioIDs = [AudioID(1, "ATU_00000001", "AC_00010001", "AP_00010002")] + expected = ( + "Error in track UID ATU_00000001: CHNA entry references " + "'AC_00010001' but AXML references 'AT_00010001_01'" + ) + with pytest.raises(Exception, match=expected): + load_chna_chunk(adm, chna) + + def test_adm_track_and_channel(self, adm, chna): + adm.addAudioTrackUID( + AudioTrackUID( + id="ATU_00000001", + trackIndex=1, + audioTrackFormat=adm["AT_00010001_01"], + audioChannelFormat=adm["AC_00010001"], + audioPackFormat=adm["AP_00010002"], + ) + ) + chna.audioIDs = [AudioID(1, "ATU_00000001", "AC_00010001", "AP_00010002")] + expected = ( + "audioTrackUID ATU_00000001 is linked to both an audioTrackFormat " + "and a audioChannelFormat" + ) + with pytest.raises(Exception, match=expected): + load_chna_chunk(adm, chna) + + +class TestPopulate: + def test_track_format(self, adm, chna): + adm.addAudioTrackUID( + AudioTrackUID( + id="ATU_00000001", + trackIndex=1, + audioTrackFormat=adm["AT_00010001_01"], + audioPackFormat=adm["AP_00010002"], + ) + ) + + populate_chna_chunk(chna, adm) + assert chna.audioIDs == [ + AudioID( + audioTrackUID="ATU_00000001", + trackIndex=1, + audioTrackFormatIDRef="AT_00010001_01", + audioPackFormatIDRef="AP_00010002", + ) + ] + + def test_missing_pack(self, adm, chna): + adm.addAudioTrackUID( + AudioTrackUID( + id="ATU_00000002", trackIndex=2, audioTrackFormat=adm["AT_00010002_01"] + ) + ) + + populate_chna_chunk(chna, adm) + assert chna.audioIDs == [ + AudioID( + audioTrackUID="ATU_00000002", + trackIndex=2, + audioTrackFormatIDRef="AT_00010002_01", + audioPackFormatIDRef=None, + ) + ] + + def test_channel_format(self, adm, chna): + adm.addAudioTrackUID( + AudioTrackUID( + id="ATU_00000003", + trackIndex=3, + audioChannelFormat=adm["AC_00010001"], + audioPackFormat=adm["AP_00010002"], + ) + ) + + populate_chna_chunk(chna, adm) + assert chna.audioIDs == [ + AudioID( + audioTrackUID="ATU_00000003", + trackIndex=3, + audioTrackFormatIDRef="AC_00010001", + audioPackFormatIDRef="AP_00010002", + ) + ] + + def test_missing_track_index(self, adm, chna): + adm.addAudioTrackUID( + AudioTrackUID( + id="ATU_00000001", + audioTrackFormat=adm["AT_00010001_01"], + audioPackFormat=adm["AP_00010002"], + ) + ) + + expected = "Track UID ATU_00000001 has no track number." + with pytest.raises(Exception, match=expected): + populate_chna_chunk(chna, adm) + + def test_missing_track_channel(self, adm, chna): + adm.addAudioTrackUID(AudioTrackUID(id="ATU_00000001", trackIndex=1)) + + expected = "Track UID ATU_00000001 has no track or channel format." + with pytest.raises(Exception, match=expected): + populate_chna_chunk(chna, adm) + + def test_both_track_channel(self, adm, chna): + adm.addAudioTrackUID( + AudioTrackUID( + id="ATU_00000001", + trackIndex=1, + audioTrackFormat=adm["AT_00010001_01"], + audioChannelFormat=adm["AC_00010001"], + audioPackFormat=adm["AP_00010002"], + ) + ) + + expected = "Track UID ATU_00000001 has both track and channel formats." + with pytest.raises(Exception, match=expected): + populate_chna_chunk(chna, adm) + + +def test_validate_trackIndex(adm): + atu = AudioTrackUID(id="ATU_00000001", trackIndex=1) + adm.addAudioTrackUID(atu) + + # no error + validate_trackIndex(adm, 1) + + # plural + expected = r"audioTrackUID ATU_00000001 has track index 1 \(1-based\) in a file with 0 tracks" + with pytest.raises(Exception, match=expected): + validate_trackIndex(adm, 0) + + # singular + atu.trackIndex = 2 + expected = r"audioTrackUID ATU_00000001 has track index 2 \(1-based\) in a file with 1 track" + with pytest.raises(Exception, match=expected): + validate_trackIndex(adm, 1) diff --git a/ear/fileio/adm/test/test_generate_ids.py b/ear/fileio/adm/test/test_generate_ids.py new file mode 100644 index 00000000..ffb07095 --- /dev/null +++ b/ear/fileio/adm/test/test_generate_ids.py @@ -0,0 +1,43 @@ +from ..builder import ADMBuilder +from ..elements import AlternativeValueSet, AudioBlockFormatObjects, ObjectPolarPosition +from ..generate_ids import generate_ids + + +def test_generate_ids(): + builder = ADMBuilder() + + programme = builder.create_programme(audioProgrammeName="programme") + content = builder.create_content(audioContentName="content") + + item = builder.create_item_objects( + 1, + "object", + parent=content, + block_formats=[ + AudioBlockFormatObjects(position=ObjectPolarPosition(0.0, 0.0, 1.0)), + ], + ) + + item.audio_object.alternativeValueSets = [AlternativeValueSet()] + + generate_ids(builder.adm) + + assert builder.adm.audioProgrammes[0].id == "APR_1001" + + assert builder.adm.audioContents[0].id == "ACO_1001" + + ao = builder.adm.audioObjects[0] + assert ao.id == "AO_1001" + assert ao.alternativeValueSets[0].id == "AVS_1001_0001" + + assert builder.adm.audioPackFormats[0].id == "AP_00031001" + + acf = builder.adm.audioChannelFormats[0] + assert acf.id == "AC_00031001" + assert acf.audioBlockFormats[0].id == "AB_00031001_00000001" + + assert builder.adm.audioStreamFormats[0].id == "AS_00031001" + + assert builder.adm.audioTrackFormats[0].id == "AT_00031001_01" + + assert builder.adm.audioTrackUIDs[0].id == "ATU_00000001" diff --git a/ear/fileio/adm/test/test_time_format.py b/ear/fileio/adm/test/test_time_format.py new file mode 100644 index 00000000..4fe34318 --- /dev/null +++ b/ear/fileio/adm/test/test_time_format.py @@ -0,0 +1,153 @@ +from ..time_format import parse_time, unparse_time, FractionalTime +from fractions import Fraction +import pytest + + +def time_equal(a, b): + if isinstance(a, FractionalTime) is not isinstance(b, FractionalTime): + return False + if isinstance(a, FractionalTime): + return a == b and a.format_denominator == b.format_denominator + else: + return a == b + + +@pytest.mark.parametrize( + "time_str,match", + [ + ("1.0", "Cannot parse time"), + ("0:0:0.", "Cannot parse time"), + ("0:0:0", "Cannot parse time"), + ("00:00:00.2S1", "numerator must be less than denominator"), + ("00:00:00.0S0", "numerator must be less than denominator"), + ("00:00:00.-1S0", "Cannot parse time"), + ], +) +def test_parse_invalid(time_str, match): + with pytest.raises(ValueError, match=match): + parse_time(time_str) + + +@pytest.mark.parametrize( + "time_str,expected", + [ + # decimal -> Fraction + ("00:00:00.5", Fraction(1, 2)), + # fraction -> FractionalTime + ("00:00:00.1S2", FractionalTime(1, 2)), + # fraction not normalised + ("00:00:00.2S6", FractionalTime(2, 6)), + # test all places + ("01:02:03.4", Fraction("3723.4")), + ("01:02:03.2S5", FractionalTime("3723.4")), + # decimals in right position + ("00:00:00.000000001", Fraction("1e-9")), + ], +) +def test_parse_time(time_str, expected): + parsed = parse_time(time_str) + assert time_equal(parsed, expected) + + +@pytest.mark.parametrize( + "time,allow_fractional,expected", + [ + # Fraction representable as decimal is always decimal + (Fraction(1, 2), False, "00:00:00.5"), + (Fraction(1, 2), True, "00:00:00.5"), + # FractionalTime representable as decimal is decimal without warnings + # when fractional is disabled + (FractionalTime(2, 4), False, "00:00:00.5"), + # FractionalTime representable as decimal is fractional when enabled + (FractionalTime(2, 4), True, "00:00:00.2S4"), + # FractionalTime not representable as decimal is fractional without warning when enabled + (FractionalTime(1, 3), True, "00:00:00.1S3"), + # Fraction not representable as decimal is fractional without warning when enabled + (Fraction(1, 3), True, "00:00:00.1S3"), + # test all places + (Fraction("3723.4"), False, "01:02:03.4"), + (FractionalTime("3723.4"), True, "01:02:03.2S5"), + # decimals in right position + (Fraction("1"), False, "00:00:01.0"), + (Fraction("1e-9"), False, "00:00:00.000000001"), + ], +) +def test_unparse_time(time, allow_fractional, expected): + assert unparse_time(time, allow_fractional) == expected + + +def test_inaccuracy_warning(): + with pytest.warns( + UserWarning, + match="^loss of accuracy when converting fractional time 1/3 to decimal$", + ): + unparse_time(Fraction(1, 3)) + + with pytest.warns( + UserWarning, + match="^loss of accuracy when converting fractional time 1/3 to decimal$", + ): + unparse_time(FractionalTime(1, 3)) + + +class TestFractionalTime: + def test_construct_from_fraction(self): + f = Fraction(1, 2) + ft = FractionalTime(f) + + assert isinstance(ft, FractionalTime) + assert ft == f + assert f == ft + assert ft.format_numerator == 1 + assert ft.format_denominator == 2 + + def test_construct_from_two_ints(self): + f = Fraction(1, 2) + ft = FractionalTime(2, 4) + + assert isinstance(ft, FractionalTime) + assert ft == f + assert f == ft + assert ft.format_numerator == 2 + assert ft.format_denominator == 4 + + def test_construct_error(self): + with pytest.raises(ValueError): + FractionalTime(2, Fraction(4)) + + def test_from_fraction(self): + f = Fraction(1, 2) + ft = FractionalTime.from_fraction(f, 4) + + assert isinstance(ft, FractionalTime) + assert ft == f + assert ft.format_numerator == 2 + assert ft.format_denominator == 4 + + def test_fractional_numerator(self): + ft = FractionalTime(Fraction(2, 1), 2) + + assert isinstance(ft, FractionalTime) + assert ft == Fraction(1, 1) + assert ft.format_numerator == 2 + assert ft.format_denominator == 2 + + def test_equality(self): + # see FractionalTime docs + assert FractionalTime(1, 2) == FractionalTime(2, 4) + + def test_bad_denominator(self): + with pytest.raises(ValueError): + # would be 1/4, which can't be x/2 + FractionalTime(Fraction(1, 2), 2) + + with pytest.raises(ValueError): + FractionalTime.from_fraction(Fraction(1, 4), 2) + + def test_str(self): + assert str(FractionalTime(2, 6)) == "2/6" + assert str(FractionalTime(1)) == "1" + assert str(FractionalTime(2, 2)) == "2/2" + + def test_repr(self): + assert repr(FractionalTime(2, 6)) == "FractionalTime(2, 6)" diff --git a/ear/fileio/adm/test/test_timing_fixes.py b/ear/fileio/adm/test/test_timing_fixes.py new file mode 100644 index 00000000..df4140ce --- /dev/null +++ b/ear/fileio/adm/test/test_timing_fixes.py @@ -0,0 +1,239 @@ +from ..builder import ADMBuilder +from ..elements import AudioBlockFormatObjects +from .. import timing_fixes +from ..generate_ids import generate_ids +from fractions import Fraction +import pytest + + +def make_abfo(**kwargs): + return AudioBlockFormatObjects( + position=dict(azimuth=0.0, elevation=0.0, distance=1.0), **kwargs + ) + + +@pytest.fixture +def two_blocks(): + builder = ADMBuilder() + builder.create_item_objects( + track_index=1, + name="MyObject 1", + block_formats=[ + make_abfo(rtime=Fraction(0), duration=Fraction(1)), + make_abfo(rtime=Fraction(1), duration=Fraction(1)), + ], + ) + generate_ids(builder.adm) + + return builder + + +@pytest.fixture +def one_block(): + builder = ADMBuilder() + builder.create_item_objects( + track_index=1, + name="MyObject 1", + block_formats=[make_abfo(rtime=None, duration=None)], + ) + generate_ids(builder.adm) + + return builder + + +def test_fix_blockFormat_durations_expansion(two_blocks): + block_formats = two_blocks.adm.audioChannelFormats[0].audioBlockFormats + block_formats[1].rtime = Fraction(2) + + with pytest.warns( + UserWarning, + match="expanded duration of block format {bf.id} to match next " + "rtime; was: 1, now: 2".format(bf=block_formats[0]), + ): + timing_fixes.check_blockFormat_durations(two_blocks.adm, fix=True) + + assert block_formats[0].duration == Fraction(2) + + +def test_fix_blockFormat_durations_contraction(two_blocks): + block_formats = two_blocks.adm.audioChannelFormats[0].audioBlockFormats + block_formats[0].duration = Fraction(2) + + with pytest.warns( + UserWarning, + match="contracted duration of block format {bf.id} to match next " + "rtime; was: 2, now: 1".format(bf=block_formats[0]), + ): + timing_fixes.check_blockFormat_durations(two_blocks.adm, fix=True) + + assert block_formats[0].duration == Fraction(1) + + +def test_check_blockFormat_durations(two_blocks): + block_formats = two_blocks.adm.audioChannelFormats[0].audioBlockFormats + block_formats[0].duration = Fraction(2) + + with pytest.warns( + UserWarning, + match="duration of block format {bf.id} does not match rtime of next block".format( + bf=block_formats[0] + ), + ): + timing_fixes.check_blockFormat_durations(two_blocks.adm, fix=False) + + assert block_formats[0].duration == Fraction(2) + + +def test_fix_blockFormat_durations_interpolationLength(two_blocks): + block_formats = two_blocks.adm.audioChannelFormats[0].audioBlockFormats + block_formats[0].duration = Fraction(2) + block_formats[0].jumpPosition.flag = True + block_formats[0].jumpPosition.interpolationLength = Fraction(2) + + with pytest.warns( + UserWarning, + match="contracted duration of block format {bf.id} to match next " + "rtime; was: 2, now: 1".format(bf=block_formats[0]), + ): + timing_fixes.check_blockFormat_durations(two_blocks.adm, fix=True) + + assert block_formats[0].duration == Fraction(1) + assert block_formats[0].jumpPosition.interpolationLength == Fraction(1) + + +def test_fix_blockFormat_durations_correct_interpolationLength(two_blocks): + block_formats = two_blocks.adm.audioChannelFormats[0].audioBlockFormats + block_formats[0].duration = Fraction(2) + block_formats[0].jumpPosition.flag = True + block_formats[0].jumpPosition.interpolationLength = Fraction("1/2") + + with pytest.warns( + UserWarning, + match="contracted duration of block format {bf.id} to match next " + "rtime; was: 2, now: 1".format(bf=block_formats[0]), + ): + timing_fixes.check_blockFormat_durations(two_blocks.adm, fix=True) + + assert block_formats[0].duration == Fraction(1) + assert block_formats[0].jumpPosition.interpolationLength == Fraction("1/2") + + +def test_fix_blockFormat_interpolationLengths(two_blocks): + block_formats = two_blocks.adm.audioChannelFormats[0].audioBlockFormats + block_formats[0].jumpPosition.flag = True + block_formats[0].jumpPosition.interpolationLength = Fraction("2") + + with pytest.warns( + UserWarning, + match="contracted interpolationLength of block format {bf.id} to " + "match duration; was: 2, now: 1".format(bf=block_formats[0]), + ): + timing_fixes.check_blockFormat_interpolationLengths(two_blocks.adm, fix=True) + + assert block_formats[0].jumpPosition.interpolationLength == Fraction(1) + + +def test_fix_blockFormat_interpolationLengths_no_change(two_blocks): + block_formats = two_blocks.adm.audioChannelFormats[0].audioBlockFormats + block_formats[0].jumpPosition.flag = True + block_formats[0].jumpPosition.interpolationLength = Fraction("1/2") + + timing_fixes.check_blockFormat_interpolationLengths(two_blocks.adm, fix=True) + + assert block_formats[0].jumpPosition.interpolationLength == Fraction("1/2") + + +def test_check_blockFormat_interpolationLengths(two_blocks): + block_formats = two_blocks.adm.audioChannelFormats[0].audioBlockFormats + block_formats[0].jumpPosition.flag = True + block_formats[0].jumpPosition.interpolationLength = Fraction("2") + + with pytest.warns( + UserWarning, + match="interpolationLength of block format {bf.id} is greater than duration".format( + bf=block_formats[0] + ), + ): + timing_fixes.check_blockFormat_interpolationLengths(two_blocks.adm, fix=False) + + assert block_formats[0].jumpPosition.interpolationLength == Fraction("2") + + +def test_fix_blockFormat_times_for_audioObjects_no_change(two_blocks): + timing_fixes.check_blockFormat_times_for_audioObjects(two_blocks.adm, fix=True) + + +@pytest.mark.parametrize("fix", [False, True]) +def test_fix_blockFormat_times_for_audioObjects_advance_end(two_blocks, fix): + block_formats = two_blocks.adm.audioChannelFormats[0].audioBlockFormats + two_blocks.adm.audioObjects[0].start = Fraction("2") + two_blocks.adm.audioObjects[0].duration = Fraction("1.5") + + msg_fmt = ( + "advancing end of {bf.id} by 1/2 to match end time of {ao.id}" + if fix + else "end of {bf.id} is after end time of {ao.id}" + ) + msg = msg_fmt.format(bf=block_formats[1], ao=two_blocks.adm.audioObjects[0]) + with pytest.warns(UserWarning, match=msg): + timing_fixes.check_blockFormat_times_for_audioObjects(two_blocks.adm, fix=fix) + + assert block_formats[1].duration == Fraction("1/2" if fix else "1.0") + + +def test_fix_blockFormat_times_for_audioObjects_advance_end_before_start(two_blocks): + block_formats = two_blocks.adm.audioChannelFormats[0].audioBlockFormats + two_blocks.adm.audioObjects[0].start = Fraction(0) + two_blocks.adm.audioObjects[0].duration = Fraction(1) + + msg = ( + "tried to advance end of {bf.id} by 1 to match end time of {ao.id}, " + "but this would be before the block start" + ).format(bf=block_formats[1], ao=two_blocks.adm.audioObjects[0]) + with pytest.raises(ValueError, match=msg): + timing_fixes.check_blockFormat_times_for_audioObjects(two_blocks.adm, fix=True) + + +def test_fix_blockFormat_times_for_audioObjects_advance_end_interpolationLength( + two_blocks, +): + block_formats = two_blocks.adm.audioChannelFormats[0].audioBlockFormats + two_blocks.adm.audioObjects[0].start = Fraction(0) + two_blocks.adm.audioObjects[0].duration = Fraction("1.25") + block_formats[1].jumpPosition.flag = True + block_formats[1].jumpPosition.interpolationLength = Fraction("1/2") + + with pytest.warns(UserWarning) as warnings: + timing_fixes.check_blockFormat_times_for_audioObjects(two_blocks.adm, fix=True) + msg = "advancing end of {bf.id} by 3/4 to match end time of {ao.id}".format( + bf=block_formats[1], ao=two_blocks.adm.audioObjects[0] + ) + assert str(warnings[0].message) == msg + msg = ( + "while advancing end of {bf.id} to match end time of {ao.id}, had " + "to reduce the interpolationLength too" + ).format(bf=block_formats[1], ao=two_blocks.adm.audioObjects[0]) + assert str(warnings[1].message) == msg + + assert block_formats[1].duration == Fraction("1/4") + assert block_formats[1].jumpPosition.interpolationLength == Fraction("1/4") + + +@pytest.mark.parametrize("fix", [False, True]) +def test_fix_blockFormat_times_for_audioObjects_only_interp(one_block, fix): + [block_format] = one_block.adm.audioChannelFormats[0].audioBlockFormats + one_block.adm.audioObjects[0].start = Fraction(0) + one_block.adm.audioObjects[0].duration = Fraction(1) + block_format.jumpPosition.flag = True + block_format.jumpPosition.interpolationLength = Fraction(2) + + msg_fmt = ( + "reduced interpolationLength of {bf.id} to match duration of {ao.id}" + if fix + else "interpolationLength of {bf.id} is longer than duration of {ao.id}" + ) + msg = msg_fmt.format(bf=block_format, ao=one_block.adm.audioObjects[0]) + with pytest.warns(UserWarning, match=msg): + timing_fixes.check_blockFormat_times_for_audioObjects(one_block.adm, fix=fix) + + assert block_format.jumpPosition.interpolationLength == Fraction(1 if fix else 2) diff --git a/ear/fileio/adm/test/test_version.py b/ear/fileio/adm/test/test_version.py new file mode 100644 index 00000000..857e2f3a --- /dev/null +++ b/ear/fileio/adm/test/test_version.py @@ -0,0 +1,30 @@ +from ..elements.version import ( + BS2076Version, + UnknownVersion, + parse_version, + version_at_least, +) + + +def test_parse_version(): + assert parse_version("ITU-R_BS.2076-2") == BS2076Version(2) + assert parse_version("ITU-R_BS.2076-1a") == UnknownVersion("ITU-R_BS.2076-1a") + + +def test_str(): + assert str(BS2076Version(2)) == "ITU-R_BS.2076-2" + assert str(UnknownVersion("ITU-R_BS.2076-1a")) == "ITU-R_BS.2076-1a" + + +def test_version_at_least(): + def identity(x): + return x + + for t1 in identity, BS2076Version: + for t2 in identity, BS2076Version: + assert version_at_least(t1(2), t2(2)) + assert not version_at_least(t1(1), t2(2)) + + for t2 in identity, BS2076Version: + assert not version_at_least(None, t2(2)) + assert not version_at_least(UnknownVersion("foo"), t2(2)) diff --git a/ear/fileio/adm/test/test_xml.py b/ear/fileio/adm/test/test_xml.py index d4109310..330a5fd0 100644 --- a/ear/fileio/adm/test/test_xml.py +++ b/ear/fileio/adm/test/test_xml.py @@ -2,12 +2,34 @@ from lxml.builder import ElementMaker from fractions import Fraction import pytest +import py.path import re from copy import deepcopy from ..xml import parse_string, adm_to_xml, ParseError -from ..exceptions import AdmError -from ..elements import AudioBlockFormatBinaural, CartesianZone, PolarZone +from ..exceptions import AdmError, AdmIDError +from ..elements import ( + AudioBlockFormatBinaural, + AudioBlockFormatObjects, + AudioObjectInteraction, + CartesianZone, + CartesianPositionOffset, + ObjectPolarPosition, + PolarZone, + PolarPositionOffset, + CartesianPositionInteractionRange, + PolarPositionInteractionRange, + InteractionRange, +) +from ..elements.version import BS2076Version, NoVersion from ....common import CartesianPosition, PolarPosition, CartesianScreen, PolarScreen +from .test_time_format import time_equal +from ..time_format import FractionalTime +from ..builder import ADMBuilder +from ..generate_ids import generate_ids + + +pytestmark = pytest.mark.filterwarnings("ignore:use of gainUnit .*") + ns = "urn:ebu:metadata-schema:ebuCore_2015" nsmap = dict(adm=ns) @@ -18,8 +40,11 @@ class BaseADM(object): """Base ADM to start tests from, with utilities for interacting with it.""" def __init__(self, fname): - import pkg_resources - with pkg_resources.resource_stream(__name__, fname) as xml_file: + import importlib_resources + + path = importlib_resources.files("ear.fileio.adm.test") / fname + + with path.open() as xml_file: self.xml = lxml.etree.parse(xml_file) self.adm = parse_string(lxml.etree.tostring(self.xml)) @@ -98,10 +123,20 @@ def f(xml): assert elements for element in elements: for attr in attrs: - del element.attrib[attr] + if attr in element.attrib: + del element.attrib[attr] return f +def set_version(version): + afe_path = "//adm:audioFormatExtended" + + if isinstance(version, int): + version = f"ITU-R_BS.2076-{version}" + + return set_attrs(afe_path, version=version) + + def get_acf(adm): """Get the first non-common-definition channel format.""" for cf in adm.audioChannelFormats: @@ -112,11 +147,116 @@ def get_acf(adm): bf_path = "//adm:audioBlockFormat" +def test_version(base): + assert isinstance(base.adm.version, NoVersion) + + adm = base.adm_after_mods(set_version("ITU-R_BS.2076-2")) + assert adm.version == BS2076Version(2) + + # new versions are not supported -- historically BS.2076 has not been + # forwards-compatible, so it's best to error out early. + + # for example: + # - addition of audioObject gain/mute/positionOffset + # - addition of gainUnit + # - change of default azimuthRange for divergence + + # all of these will cause silent errors (i.e. unintended behaviour, not an + # error message) if a document containing them is interpreted by software + # that doesn't know about them, so let's not do that + expected = "ADM version 'ITU-R_BS.2076-3' is not supported" + with pytest.raises(NotImplementedError, match=expected): + base.adm_after_mods(set_version("ITU-R_BS.2076-3")) + + # unknown + expected = "ADM version 'ITU-R_BS.2076-2a' is not supported" + with pytest.raises(NotImplementedError, match=expected): + base.adm_after_mods(set_version("ITU-R_BS.2076-2a")) + + +def test_loudness(base): + # test a couple of different method strings to make sure that multiple + # elements are handled correctly + test_methods = ["ITU-R BS.1770", "proprietary"] + + children = [ + E.loudnessMetadata( + E.integratedLoudness("-24.0"), + E.loudnessRange("12.5"), + E.maxTruePeak("-5.2"), + E.maxMomentary("-9.9"), + E.maxShortTerm("-18.3"), + E.dialogueLoudness("-10.2"), + loudnessMethod=loudnessMethod, + loudnessRecType="EBU R128", + loudnessCorrectionType="file", + ) + for loudnessMethod in test_methods + ] + + def check_loudness(loudnessMetadatas): + assert len(loudnessMetadatas) == len(test_methods) + for loudnessMetadata, method in zip(loudnessMetadatas, test_methods): + assert loudnessMetadata.loudnessMethod == method + assert loudnessMetadata.loudnessRecType == "EBU R128" + assert loudnessMetadata.loudnessCorrectionType == "file" + assert loudnessMetadata.integratedLoudness == -24.0 + assert loudnessMetadata.loudnessRange == 12.5 + assert loudnessMetadata.maxTruePeak == -5.2 + assert loudnessMetadata.maxMomentary == -9.9 + assert loudnessMetadata.dialogueLoudness == -10.2 + + # need to copy as lxml doesn't like elements appearing in more than one + # place + adm = base.adm_after_mods( + add_children("//adm:audioContent", *deepcopy(children)), + add_children("//adm:audioProgramme", *deepcopy(children)), + ) + check_loudness(adm.audioProgrammes[0].loudnessMetadata) + check_loudness(adm.audioContents[0].loudnessMetadata) + + def test_gain(base): + # linear assert base.bf_after_mods(add_children(bf_path, E.gain("0"))).gain == 0.0 assert base.bf_after_mods(add_children(bf_path, E.gain("0.5"))).gain == 0.5 + assert ( + base.bf_after_mods(set_version(2), add_children(bf_path, E.gain("0.5", gainUnit="linear"))).gain + == 0.5 + ) + + # db + with pytest.warns(UserWarning, match="gainUnit"): + assert ( + base.bf_after_mods(set_version(2), add_children(bf_path, E.gain("20", gainUnit="dB"))).gain + == 10.0 + ) + expected = "gainUnit must be linear or dB, not 'DB'" + with pytest.raises(ParseError, match=expected): + base.bf_after_mods(set_version(2), add_children(bf_path, E.gain("20", gainUnit="DB"))) + + # default assert base.bf_after_mods().gain == 1.0 + expected = "gainUnit is a BS.2076-2 feature" + with pytest.raises(ParseError, match=expected): + base.bf_after_mods(add_children(bf_path, E.gain("20", gainUnit="dB"))) + + # various infinity representations + for neg_inf_rep in "-inf", "-INF", "-infinity", "-INFINITY", "-InFiNiTy": + assert ( + base.bf_after_mods( + set_version(2), + add_children(bf_path, E.gain(neg_inf_rep, gainUnit="dB")), + ).gain + == 0.0 + ) + + with pytest.raises(ParseError, match="'gain' must be finite, but inf is not"): + base.bf_after_mods( + set_version(2), add_children(bf_path, E.gain("inf", gainUnit="dB")) + ) + def test_extent(base): assert base.bf_after_mods().width == 0.0 @@ -310,8 +450,9 @@ def test_zone(base): def test_directspeakers(base): - def with_children(*children): + def with_children(*children, version=2): return base.bf_after_mods( + set_version(version), set_attrs("//adm:audioChannelFormat", typeDefinition="DirectSpeakers", typeLabel="001"), remove_children("//adm:position"), add_children(bf_path, @@ -339,6 +480,12 @@ def with_children(*children): E.position("15", coordinate="elevation")) assert block_format.position.distance == 1.0 + # gain + block_format = with_children(E.position("0", coordinate="azimuth"), + E.position("0", coordinate="elevation"), + E.gain("0.5")) + assert block_format.gain == 0.5 + # test min and max block_format = with_children(E.position("-29", coordinate="azimuth"), E.position("-28", coordinate="azimuth", bound="max"), @@ -397,6 +544,54 @@ def with_children(*children): *map(E.speakerLabel, labels)) assert block_format.speakerLabel == labels + # default gain and importance + block_format = with_children( + E.position("0", coordinate="azimuth"), E.position("0", coordinate="elevation") + ) + assert block_format.gain == 1.0 + assert block_format.importance == 10 + + # specify gain and importance + block_format = with_children( + E.position("0", coordinate="azimuth"), + E.position("0", coordinate="elevation"), + E.gain("0.5"), + E.importance("5"), + ) + assert block_format.gain == 0.5 + assert block_format.importance == 5 + + # dB gain + block_format = with_children( + E.position("0", coordinate="azimuth"), + E.position("0", coordinate="elevation"), + E.gain("-20", gainUnit="dB"), + ) + assert block_format.gain == pytest.approx(0.1, rel=1e-6) + + # v2 features + with pytest.raises( + ParseError, + match="gain in DirectSpeakers audioBlockFormat is a BS.2076-2 feature", + ): + with_children( + E.position("0", coordinate="azimuth"), + E.position("0", coordinate="elevation"), + E.gain("0.5"), + version=1, + ) + + with pytest.raises( + ParseError, + match="importance in DirectSpeakers audioBlockFormat is a BS.2076-2 feature", + ): + with_children( + E.position("0", coordinate="azimuth"), + E.position("0", coordinate="elevation"), + E.importance("5"), + version=1, + ) + def test_frequency(base): def cf_with_children(*children): @@ -429,42 +624,95 @@ def cf_with_children(*children): def test_binaural(base): - block_format = base.bf_after_mods( - set_attrs("//adm:audioChannelFormat", typeDefinition="Binaural", typeLabel="005"), - remove_children("//adm:position")) + to_binaural = ( + set_attrs( + "//adm:audioChannelFormat", typeDefinition="Binaural", typeLabel="005" + ), + remove_children("//adm:position"), + ) + block_format = base.bf_after_mods(*to_binaural) assert isinstance(block_format, AudioBlockFormatBinaural) + assert block_format.gain == 1.0 + assert block_format.importance == 10 + + block_format = base.bf_after_mods( + *to_binaural, + set_version(2), + add_children(bf_path, E.gain("0.5"), E.importance("5")), + ) + assert block_format.gain == 0.5 + assert block_format.importance == 5 + + with pytest.raises( + ParseError, match="gain in Binaural audioBlockFormat is a BS.2076-2 feature" + ): + base.bf_after_mods(*to_binaural, add_children(bf_path, E.gain("0.5"))) + with pytest.raises( + ParseError, + match="importance in Binaural audioBlockFormat is a BS.2076-2 feature", + ): + base.bf_after_mods(*to_binaural, add_children(bf_path, E.importance("5"))) def test_hoa(base): - def with_children(*children): + def hoa_bf(*mods): return base.bf_after_mods( set_attrs("//adm:audioChannelFormat", typeDefinition="HOA", typeLabel="004"), remove_children("//adm:position"), - add_children(bf_path, *children)) + *mods, + ) # normal usage - block_format = with_children(E.order("1"), E.degree("-1")) + block_format = hoa_bf(add_children(bf_path, E.order("1"), E.degree("-1"))) assert block_format.equation is None assert block_format.order == 1 assert block_format.degree == -1 assert block_format.normalization is None assert block_format.nfcRefDist is None assert block_format.screenRef is None + assert block_format.gain == 1.0 + assert block_format.importance == 10 # explicit defaults - block_format = with_children(E.normalization("SN3D"), E.nfcRefDist("0.0"), E.screenRef("0")) + block_format = hoa_bf( + add_children( + bf_path, E.normalization("SN3D"), E.nfcRefDist("0.0"), E.screenRef("0") + ) + ) assert block_format.normalization == "SN3D" assert block_format.nfcRefDist == 0.0 assert block_format.screenRef is False # specify everything - block_format = with_children(E.equation("eqn"), E.normalization("N3D"), E.nfcRefDist("0.5"), E.screenRef("1")) + block_format = hoa_bf( + add_children( + bf_path, + E.equation("eqn"), + E.normalization("N3D"), + E.nfcRefDist("0.5"), + E.screenRef("1"), + ) + ) assert block_format.equation == "eqn" assert block_format.normalization == "N3D" assert block_format.nfcRefDist == 0.5 assert block_format.screenRef is True + # v2 attributes + assert hoa_bf(set_version(2), add_children(bf_path, E.gain("0.5"))).gain == 0.5 + assert ( + hoa_bf(set_version(2), add_children(bf_path, E.importance("5"))).importance == 5 + ) + with pytest.raises( + ParseError, match="gain in HOA audioBlockFormat is a BS.2076-2 feature" + ): + assert hoa_bf(add_children(bf_path, E.gain("0.5"))) + with pytest.raises( + ParseError, match="importance in HOA audioBlockFormat is a BS.2076-2 feature" + ): + assert hoa_bf(add_children(bf_path, E.importance("5"))) + def test_hoa_pack(base): def with_children(*children): @@ -541,6 +789,81 @@ def test_matrix_params(base_mat): assert [c.phaseVar for c in bf.matrix] == [None, "phase", None] assert [c.delayVar for c in bf.matrix] == [None, None, "delay"] + mat_bf_path = "//*[@audioChannelFormatID='AC_00021003']//adm:audioBlockFormat" + + adm = base_mat.adm_after_mods( + set_version(2), + add_children(mat_bf_path, E.gain("0.5"), E.importance("5")), + ) + [bf] = adm.lookup_element("AC_00021003").audioBlockFormats + assert bf.gain == 0.5 + assert bf.importance == 5 + + with pytest.raises( + ParseError, match="gain in Matrix audioBlockFormat is a BS.2076-2 feature" + ): + base_mat.adm_after_mods(add_children(mat_bf_path, E.gain("0.5"))) + with pytest.raises( + ParseError, match="importance in Matrix audioBlockFormat is a BS.2076-2 feature" + ): + base_mat.adm_after_mods(add_children(mat_bf_path, E.importance("5"))) + + +def test_matrix_gain_db(base_mat): + adm = base_mat.adm_after_mods( + set_version(2), + del_attrs("//adm:coefficient", "gain", "gainUnit"), + set_attrs( + "//*[@audioChannelFormatID='AC_00021003']//adm:coefficient[1]", + gain="20.0", + gainUnit="dB", + ), + set_attrs( + "//*[@audioChannelFormatID='AC_00021003']//adm:coefficient[2]", + gain="2.0", + gainUnit="linear", + ), + ) + + acf = adm.lookup_element("AC_00021003") + [bf] = acf.audioBlockFormats + assert [c.gain for c in bf.matrix] == [10.0, 2.0, None] + + expected = "gainUnit must not be specified without gain" + with pytest.raises(ParseError, match=expected): + base_mat.adm_after_mods( + set_version(2), + del_attrs("//adm:coefficient", "gain", "gainUnit"), + set_attrs( + "//*[@audioChannelFormatID='AC_00021003']//adm:coefficient[1]", + gainUnit="dB", + ), + ) + + expected = "gainUnit must be linear or dB, not 'DB'" + with pytest.raises(ParseError, match=expected): + base_mat.adm_after_mods( + set_version(2), + del_attrs("//adm:coefficient", "gain", "gainUnit"), + set_attrs( + "//*[@audioChannelFormatID='AC_00021003']//adm:coefficient[1]", + gain="1.0", + gainUnit="DB", + ), + ) + + expected = "gainUnit is a BS.2076-2 feature" + with pytest.raises(ParseError, match=expected): + base_mat.adm_after_mods( + set_version(1), + del_attrs("//adm:coefficient", "gain", "gainUnit"), + set_attrs( + "//*[@audioChannelFormatID='AC_00021003']//adm:coefficient[1]", + gain="20.0", + gainUnit="dB", + ), + ) + def test_matrix_outputChannelIDRef(base_mat): adm = base_mat.adm_after_mods( @@ -663,6 +986,510 @@ def test_silent_tracks(base): assert silent_atu is None +def test_audioObject_time(base): + [ao] = base.adm.audioObjects + assert time_equal(ao.start, Fraction(0, 1)) + assert time_equal(ao.duration, Fraction(12, 1)) + + adm = base.adm_after_mods( + set_version(2), + ) + [ao] = adm.audioObjects + assert time_equal(ao.start, Fraction(0, 1)) + assert time_equal(ao.duration, Fraction(12, 1)) + + adm = base.adm_after_mods( + set_version(2), + set_attrs( + "//adm:audioObject", + start="00:00:00.0S48000", + duration="00:00:12.0S48000", + ), + ) + [ao] = adm.audioObjects + assert time_equal(ao.start, FractionalTime(0, 48000)) + assert time_equal(ao.duration, FractionalTime(12 * 48000, 48000)) + + +def test_audioObject_gain(base): + [ao] = base.adm.audioObjects + assert ao.gain == 1.0 + + adm = base.adm_after_mods( + set_version(2), + add_children("//adm:audioObject", E.gain("0.5")), + ) + [ao] = adm.audioObjects + assert ao.gain == 0.5 + + expected = "gain in audioObject is a BS.2076-2 feature" + with pytest.raises(ParseError, match=expected): + base.adm_after_mods( + add_children("//adm:audioObject", E.gain("0.5")), + ) + + +def test_audioObject_mute(base): + [ao] = base.adm.audioObjects + assert ao.mute is False + + adm = base.adm_after_mods( + set_version(2), + add_children("//adm:audioObject", E.mute("1")), + ) + [ao] = adm.audioObjects + assert ao.mute is True + + expected = "mute in audioObject is a BS.2076-2 feature" + with pytest.raises(ParseError, match=expected): + base.adm_after_mods( + add_children("//adm:audioObject", E.mute("0")), + ) + + +def test_audioObject_positionOffset(base): + [ao] = base.adm.audioObjects + assert ao.positionOffset is None + + # full polar + adm = base.adm_after_mods( + set_version(2), + add_children( + "//adm:audioObject", + E.positionOffset("10", coordinate="azimuth"), + E.positionOffset("20", coordinate="elevation"), + E.positionOffset("3", coordinate="distance"), + ), + ) + [ao] = adm.audioObjects + assert ao.positionOffset == PolarPositionOffset( + azimuth=10.0, elevation=20.0, distance=3.0 + ) + + # partial polar + adm = base.adm_after_mods( + set_version(2), + add_children("//adm:audioObject", E.positionOffset("20", coordinate="azimuth")), + ) + [ao] = adm.audioObjects + assert ao.positionOffset.azimuth == 20.0 + + # cartesian + adm = base.adm_after_mods( + set_version(2), + add_children( + "//adm:audioObject", + E.positionOffset("0.1", coordinate="X"), + E.positionOffset("0.2", coordinate="Y"), + E.positionOffset("0.3", coordinate="Z"), + ), + ) + [ao] = adm.audioObjects + assert ao.positionOffset == CartesianPositionOffset(X=0.1, Y=0.2, Z=0.3) + + expected = "positionOffset in audioObject is a BS.2076-2 feature" + with pytest.raises(ParseError, match=expected): + base.adm_after_mods( + add_children( + "//adm:audioObject", E.positionOffset("20", coordinate="azimuth") + ), + ) + + expected = "Found positionOffset coordinates {X,azimuth}, but expected {azimuth,elevation,distance} or {X,Y,Z}." + with pytest.raises(ParseError, match=expected): + base.adm_after_mods( + set_version(2), + add_children( + "//adm:audioObject", + E.positionOffset("20", coordinate="azimuth"), + E.positionOffset("0.5", coordinate="X"), + ), + ) + + expected = "duplicate azimuth coordinates specified" + with pytest.raises(ParseError, match=expected): + base.adm_after_mods( + set_version(2), + add_children( + "//adm:audioObject", + E.positionOffset("20", coordinate="azimuth"), + E.positionOffset("20", coordinate="azimuth"), + ), + ) + + expected = "missing coordinate attr" + with pytest.raises(ParseError, match=expected): + base.adm_after_mods( + set_version(2), + add_children( + "//adm:audioObject", + E.positionOffset("20"), + ), + ) + + +def test_audioObject_alternativeValueSet_positionOffset(base): + adm = base.adm_after_mods( + set_version(2), + add_children( + "//adm:audioObject", + # empty + E.alternativeValueSet( + alternativeValueSetID="AVS_1001_0001", + ), + # full + E.alternativeValueSet( + E.gain("0.5"), + E.mute("1"), + E.positionOffset("10.0", coordinate="azimuth"), + E.positionOffset("20.0", coordinate="elevation"), + E.positionOffset("0.25", coordinate="distance"), + alternativeValueSetID="AVS_1001_0002", + ), + ), + ) + [ao] = adm.audioObjects + [avs_default, avs_full] = ao.alternativeValueSets + + assert avs_default.id == "AVS_1001_0001" + assert avs_default.gain is None + assert avs_default.mute is None + assert avs_default.positionOffset is None + + assert avs_full.id == "AVS_1001_0002" + assert avs_full.gain == 0.5 + assert avs_full.mute is True + assert avs_full.positionOffset == PolarPositionOffset( + azimuth=10.0, elevation=20.0, distance=0.25 + ) + + expected = "alternativeValueSet in audioObject is a BS.2076-2 feature" + with pytest.raises(ParseError, match=expected): + base.adm_after_mods( + add_children( + "//adm:audioObject", + E.alternativeValueSet( + alternativeValueSetID="AVS_1001_0001", + ), + ) + ) + + +def test_audioObject_alternativeValueSet_interact(base): + adm = base.adm_after_mods( + set_version(2), + add_children( + "//adm:audioObject", + # no interact + E.alternativeValueSet( + alternativeValueSetID="AVS_1001_0001", + ), + # interaction specified + E.alternativeValueSet( + E.audioObjectInteraction( + onOffInteract="1", + ), + alternativeValueSetID="AVS_1001_0002", + ), + ), + ) + [ao] = adm.audioObjects + [avs_no_interact, avs_interact] = ao.alternativeValueSets + + assert avs_no_interact.id == "AVS_1001_0001" + assert avs_no_interact.audioObjectInteraction is None + + assert avs_interact.id == "AVS_1001_0002" + assert avs_interact.audioObjectInteraction == AudioObjectInteraction( + onOffInteract=True, + ) + + +def test_audioObject_alternativeValueSet_references(base): + adm = base.adm_after_mods( + set_version(2), + add_children( + "//adm:audioObject", + E.alternativeValueSet( + alternativeValueSetID="AVS_1001_0001", + ), + ), + add_children( + "//adm:audioProgramme", + E.alternativeValueSetIDRef("AVS_1001_0001"), + ), + add_children( + "//adm:audioContent", + E.alternativeValueSetIDRef("AVS_1001_0001"), + ), + ) + + [ao] = adm.audioObjects + [avs] = ao.alternativeValueSets + + [ap] = adm.audioProgrammes + [ap_avs_ref] = ap.alternativeValueSets + assert ap_avs_ref is avs + + [ac] = adm.audioContents + [ac_avs_ref] = ac.alternativeValueSets + assert ac_avs_ref is avs + + +def test_audioObject_alternativeValueSet_only_dash2(base): + expected = "alternativeValueSetIDRef in audioProgramme is a BS.2076-2 feature" + with pytest.raises(ParseError, match=expected): + base.adm_after_mods( + add_children( + "//adm:audioProgramme", E.alternativeValueSetIDRef("AVS_1001_0001") + ) + ) + + +def test_audioObject_alternativeValueSet_duplicate(base): + expected = "duplicate objects with id=AVS_1001_0001" + with pytest.raises(AdmIDError, match=expected): + base.adm_after_mods( + set_version(2), + add_children( + "//adm:audioObject", + E.alternativeValueSet( + alternativeValueSetID="AVS_1001_0001", + ), + E.alternativeValueSet( + alternativeValueSetID="AVS_1001_0001", + ), + ), + ) + + +def test_audioObject_alternativeValueSet_unknown(base): + expected = "unknown alternativeValueSet AVS_1001_0002" + with pytest.raises(KeyError, match=expected): + base.adm_after_mods( + set_version(2), + add_children( + "//adm:audioObject", + E.alternativeValueSet( + alternativeValueSetID="AVS_1001_0001", + ), + ), + add_children( + "//adm:audioContent", + E.alternativeValueSetIDRef("AVS_1001_0002"), + ), + ) + + +def test_audioObject_audioObjectInteraction_none(base): + [ao] = base.adm.audioObjects + assert ao.audioObjectInteraction is None + + +def test_audioObject_audioObjectInteraction_minimal(base): + adm = base.adm_after_mods( + add_children( + "//adm:audioObject", + E.audioObjectInteraction( + onOffInteract="1", + ), + ), + ) + [ao] = adm.audioObjects + aoi = ao.audioObjectInteraction + assert aoi is not None + assert aoi.onOffInteract is True + assert aoi.gainInteract is None + assert aoi.positionInteract is None + assert aoi.gainInteractionRange is None + assert aoi.positionInteractionRange is None + + +def test_audioObject_audioObjectInteraction_flags(base): + adm = base.adm_after_mods( + add_children( + "//adm:audioObject", + E.audioObjectInteraction( + onOffInteract="0", + gainInteract="1", + positionInteract="1", + ), + ), + ) + [ao] = adm.audioObjects + aoi = ao.audioObjectInteraction + assert aoi is not None + assert aoi.onOffInteract is False + assert aoi.gainInteract is True + assert aoi.positionInteract is True + assert aoi.gainInteractionRange is None + assert aoi.positionInteractionRange is None + + +def test_audioObject_audioObjectInteraction_gains(base): + adm = base.adm_after_mods( + set_version(2), + add_children( + "//adm:audioObject", + E.audioObjectInteraction( + E.gainInteractionRange("0.5", bound="min"), + E.gainInteractionRange("20.0", bound="max", gainUnit="dB"), + onOffInteract="0", + ), + ), + ) + [ao] = adm.audioObjects + aoi = ao.audioObjectInteraction + assert aoi is not None + assert aoi.gainInteractionRange is not None + assert aoi.gainInteractionRange.min == 0.5 + assert aoi.gainInteractionRange.max == 10.0 + assert aoi.positionInteractionRange is None + + +def test_audioObject_audioObjectInteraction_gains_partial(base): + adm = base.adm_after_mods( + add_children( + "//adm:audioObject", + E.audioObjectInteraction( + E.gainInteractionRange("0.5", bound="min"), + onOffInteract="0", + ), + ), + ) + [ao] = adm.audioObjects + aoi = ao.audioObjectInteraction + assert aoi is not None + assert aoi.gainInteractionRange is not None + assert aoi.gainInteractionRange.min == 0.5 + assert aoi.gainInteractionRange.max is None + assert aoi.positionInteractionRange is None + + +def test_audioObject_audioObjectInteraction_polar_position_full(base): + adm = base.adm_after_mods( + add_children( + "//adm:audioObject", + E.audioObjectInteraction( + E.positionInteractionRange("-20.0", coordinate="azimuth", bound="min"), + E.positionInteractionRange("20.0", coordinate="azimuth", bound="max"), + E.positionInteractionRange( + "-30.0", coordinate="elevation", bound="min" + ), + E.positionInteractionRange("30.0", coordinate="elevation", bound="max"), + E.positionInteractionRange("0.5", coordinate="distance", bound="min"), + E.positionInteractionRange("1.5", coordinate="distance", bound="max"), + onOffInteract="0", + ), + ), + ) + [ao] = adm.audioObjects + aoi = ao.audioObjectInteraction + assert aoi is not None + + pos = aoi.positionInteractionRange + assert pos == PolarPositionInteractionRange( + azimuth=InteractionRange(min=-20.0, max=20.0), + elevation=InteractionRange(min=-30.0, max=30.0), + distance=InteractionRange(min=0.5, max=1.5), + ) + + +def test_audioObject_audioObjectInteraction_polar_position_partial(base): + adm = base.adm_after_mods( + add_children( + "//adm:audioObject", + E.audioObjectInteraction( + E.positionInteractionRange("-20.0", coordinate="azimuth", bound="min"), + E.positionInteractionRange("20.0", coordinate="azimuth", bound="max"), + E.positionInteractionRange( + "-30.0", coordinate="elevation", bound="min" + ), + onOffInteract="0", + ), + ), + ) + [ao] = adm.audioObjects + aoi = ao.audioObjectInteraction + assert aoi is not None + + pos = aoi.positionInteractionRange + assert pos == PolarPositionInteractionRange( + azimuth=InteractionRange(min=-20.0, max=20.0), + elevation=InteractionRange(min=-30.0), + ) + + +def test_audioObject_audioObjectInteraction_cartesian_position_full(base): + adm = base.adm_after_mods( + add_children( + "//adm:audioObject", + E.audioObjectInteraction( + E.positionInteractionRange("-0.1", coordinate="X", bound="min"), + E.positionInteractionRange("0.1", coordinate="X", bound="max"), + E.positionInteractionRange("-0.2", coordinate="Y", bound="min"), + E.positionInteractionRange("0.2", coordinate="Y", bound="max"), + E.positionInteractionRange("-0.3", coordinate="Z", bound="min"), + E.positionInteractionRange("0.3", coordinate="Z", bound="max"), + onOffInteract="0", + ), + ), + ) + [ao] = adm.audioObjects + aoi = ao.audioObjectInteraction + assert aoi is not None + + pos = aoi.positionInteractionRange + assert pos == CartesianPositionInteractionRange( + X=InteractionRange(min=-0.1, max=0.1), + Y=InteractionRange(min=-0.2, max=0.2), + Z=InteractionRange(min=-0.3, max=0.3), + ) + + +def test_audioObject_audioObjectInteraction_missing(base): + with pytest.raises(ParseError, match="missing items: onOffInteract"): + base.adm_after_mods( + add_children( + "//adm:audioObject", + E.audioObjectInteraction(), + ), + ) + + +def test_audioObject_audioObjectInteraction_gainUnit_v2(base): + with pytest.raises(ParseError, match="gainUnit is a BS.2076-2 feature"): + base.adm_after_mods( + set_version(1), + add_children( + "//adm:audioObject", + E.audioObjectInteraction( + E.gainInteractionRange("20.0", bound="max", gainUnit="dB"), + ), + ), + ) + + +def test_audioObject_audioObjectInteraction_position_error(base): + expected = ( + "found positionInteractionRange elements with coordinates {X,azimuth}, " + "but expected {azimuth,elevation,distance}, {X,Y,Z}, or some subset" + ) + with pytest.raises(ParseError, match=expected): + base.adm_after_mods( + add_children( + "//adm:audioObject", + E.audioObjectInteraction( + E.positionInteractionRange( + "-20.0", coordinate="azimuth", bound="min" + ), + E.positionInteractionRange("0.0", coordinate="X", bound="min"), + onOffInteract="0", + ), + ), + ) + + def test_track_stream_ref(base): """Check that the track->stream ref is established by a reference in either direction.""" with pytest.warns(UserWarning, match=("audioTrackFormat AT_00011001_01 has no audioStreamFormatIDRef; " @@ -690,6 +1517,39 @@ def test_track_stream_ref(base): formatDefinition="PCM", formatLabel="0001"))) +def test_atu_acf_ref(base): + mods = ( + remove_children("//adm:audioTrackUID/adm:audioTrackFormatIDRef"), + add_children("//adm:audioTrackUID", E.audioChannelFormatIDRef("AC_00031001")), + ) + adm = base.adm_after_mods( + set_version(2), + *mods, + ) + + assert adm["ATU_00000001"].audioChannelFormat is adm["AC_00031001"] + assert adm["ATU_00000001"].audioTrackFormat is None + + expected = "audioChannelFormatIDRef in audioTrackUID is a BS.2076-2 feature" + with pytest.raises(ParseError, match=expected): + adm = base.adm_after_mods(*mods) + + +def test_set_default_rtimes(base): + """Check that the audioBlockFormats with a duration but no rtime are given an rtime, + and a warning is issued. + """ + with pytest.warns( + UserWarning, + match=( + "added missing rtime to AB_00031001_00000001; BS.2127-0 states " + "that rtime and duration should both be present or absent" + ), + ): + bf = base.bf_after_mods(del_attrs(bf_path, "rtime")) + assert bf.rtime == Fraction(0) + + def as_dict(inst): """Turn an adm element into a dict to be used for comparison. @@ -716,6 +1576,9 @@ def as_dict(inst): def check_round_trip(adm): + """check that turning adm into xml, then parsing, results in the original adm""" + import attr + xml = adm_to_xml(adm) xml_str = lxml.etree.tostring(xml, pretty_print=True) parsed_adm = parse_string(xml_str) @@ -728,6 +1591,8 @@ def check_round_trip(adm): assert as_dict(element_orig) == as_dict(element_parsed) + assert attr.asdict(parsed_adm) == attr.asdict(adm) + def test_round_trip_base(base): check_round_trip(base.adm) @@ -735,3 +1600,50 @@ def test_round_trip_base(base): def test_round_trip_matrix(base_mat): check_round_trip(base_mat.adm) + + +@pytest.mark.parametrize("version", [1, 2]) +def test_defaults(version): + """test that a basic adm structure results in the expected xml + + this is mostly intended to catch default values accidentally being added to the xml + + if this is failing for a good reason, delete the defaults_v1 and v2 files, + and re-run the test suite to regenerate them + """ + builder = ADMBuilder.for_version(version) + + builder.create_programme(audioProgrammeName="programme") + builder.create_content(audioContentName="content") + + block_formats = [ + AudioBlockFormatObjects( + position=ObjectPolarPosition(azimuth=0.0, elevation=0.0, distance=1.0), + ), + ] + builder.create_item_objects(0, "MyObject 1", block_formats=block_formats) + + generate_ids(builder.adm) + xml = adm_to_xml(builder.adm) + xml_str = lxml.etree.tostring(xml, encoding=str, pretty_print=True) + + this_dir = py.path.local(__file__).dirpath() + defaults_file = this_dir / "test_adm_files" / f"defaults_v{version}.xml" + + if defaults_file.check(): + with open(defaults_file) as f: + assert f.read() == xml_str + else: + with open(defaults_file, "w") as f: + f.write(xml_str) + pytest.skip("generated defaults file") + + +def test_ignore_outside_audioFormatExtended(base): + """check that ADM-defined objects are only found in audioFormatExtended""" + [apr] = base.xml.xpath("//adm:audioProgramme", namespaces=nsmap) + adm = base.adm_after_mods( + add_children("//adm:format", E.notAudioFormatExtended(deepcopy(apr))) + ) + + assert len(adm.audioProgrammes) == 1 diff --git a/ear/fileio/adm/time_format.py b/ear/fileio/adm/time_format.py index 6ab2ec2d..9e8017ad 100644 --- a/ear/fileio/adm/time_format.py +++ b/ear/fileio/adm/time_format.py @@ -1,41 +1,209 @@ +from decimal import Decimal from fractions import Fraction import re +import warnings -_TIME_RE = re.compile(r""" - (?P\d{1,2}) # one or two hour digits - : # : - (?P\d{1,2}) # one or two minute digits - : # : - (?P # second decimal consisting of: - \d{1,2} # two digits - (?: # then optionally - \. # a dot - \d* # and any number of digits - ) +class FractionalTime(Fraction): + """represents an ADM fractional time as a non-normalised fraction + + This is stored as a normalised Fraction, with an extra attribute + (_format_multiplier) used to derive the numerator and denominator when + it is formatted as an ADM time. + + This is done to keep compatibility with existing code (which expects + Fractions), and because Fraction can not store non-normalised fractions (or + at least, its methods assume that the stored numerator and denominator are + normalised). + + If the constructor is used, the given denominator is stored (and must be an + integer), so e.g. FractionalTime(2, 4) may be formatted as ``00:00:00.2S4``, + while Fraction would normalise this to 1/2. + + Note that two FractionalTime instances which represent the same number but + have different denominators (e.g. 1/2 and 2/4) are considered equal, even + though they may be formatted differently. This again is to ensure + compatibility with code that expects Fractions. + + See :func:`parse_time` and :func:`unparse_time` for how this is used with + ADM XML. + """ + + __slots__ = "_format_multiplier" + + def __new__(cls, numerator=0, denominator=None): + self = super(FractionalTime, cls).__new__(cls, numerator, denominator) + + if denominator is None: + self._format_multiplier = 1 + else: + if not isinstance(denominator, int): + raise ValueError("denominator must be an integer") + + self._format_multiplier, remainder = divmod(denominator, self.denominator) + # must be able to be represented with a denominator of denominator + if remainder != 0: + raise ValueError( + "FractionalTime must be a fraction with the given denominator" + ) + + return self + + @property + def format_numerator(self) -> int: + """numerator of the non-normalised fraction this represents""" + return self.numerator * self._format_multiplier + + @property + def format_denominator(self) -> int: + """denominator of the non-normalised fraction this represents""" + return self.denominator * self._format_multiplier + + @classmethod + def from_fraction( + cls, fraction: Fraction, format_denominator: int + ) -> "FractionalTime": + """construct with a value equal to an existing fraction, but use the + given denominator when formatting""" + return cls(fraction * format_denominator, format_denominator) + + def __repr__(self): + return f"{self.__class__.__name__}({self.format_numerator}, {self.format_denominator})" + + def __str__(self): + if self.format_denominator == 1: + return str(self.format_numerator) + else: + return f"{self.format_numerator}/{self.format_denominator}" + + +_TIME_RE = re.compile( + r""" + (?P\d{1,2}) # one or two hour digits + : # : + (?P\d{1,2}) # one or two minute digits + : # : + (?P # second decimal consisting of: + (?P\d{1,2}) # two digits + \. # a period + (?P\d+) # and at least one digit (decimal or numerator) + (?: # and optionally a fractional part containing: + S # S + (?P\d+) # and one or more digits (denominator) + )? ) - \Z # end -""", re.VERBOSE) + \Z # end +""", + re.VERBOSE, +) + +def parse_time(time_string: str) -> Fraction: + """parse an ADM-format time -def parse_time(time_string): + for decimal times the return value will be a Fraction in seconds. for + fractional/sample times, the return value will be a :class:`FractionalTime` + """ match = _TIME_RE.match(time_string) if match is None: raise ValueError("Cannot parse time: {!r}".format(time_string)) hour = int(match.group("hour")) minute = int(match.group("minute")) - second = Fraction(match.group("second")) - return ((hour * 60) + minute) * 60 + second + if match.group("den") is not None: # fractional + numerator = int(match.group("num")) + denominator = int(match.group("den")) + if not numerator < denominator: + raise ValueError( + f"in time {time_string!r}: numerator must be less than denominator" + ) + second = int(match.group("whole_s")) + Fraction(numerator, denominator) + time_frac = ((hour * 60) + minute) * 60 + second -def unparse_time(time): - minutes, seconds = divmod(time, 60) + return FractionalTime.from_fraction(time_frac, denominator) + else: # decimal + second = Fraction(match.group("second")) + return ((hour * 60) + minute) * 60 + second + + +def parse_time_v1(time_string: str) -> Fraction: + """parse ADM-format times, allowing only decimal times""" + f = parse_time(time_string) + if isinstance(f, FractionalTime): + raise ValueError( + f"in time {time_string!r}: fractional times are not supported before BS.2076-2" + ) + return f + + +def _unparse_whole_part(seconds: int) -> str: + minutes, seconds = divmod(seconds, 60) hours, minutes = divmod(minutes, 60) + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + + +def _unparse_fractional(time: Fraction) -> str: + seconds, fraction = divmod(time, 1) + + if isinstance(time, FractionalTime): + numerator = int(fraction * time.format_denominator) + denominator = time.format_denominator + else: + numerator, denominator = fraction.numerator, fraction.denominator + + whole_part = _unparse_whole_part(int(seconds)) + return f"{whole_part}.{numerator}S{denominator}" + + +def _unparse_decimal(time: Decimal) -> str: + seconds, decimal = divmod(time, 1) + + sign, digits, exponent = decimal.as_tuple() + assert sign == 0 + if digits == (0,): + decimal_digits = (0,) + else: + # calc number of zeros to add to front + num_zeros = -exponent - len(digits) + assert num_zeros >= 0 + decimal_digits = (0,) * num_zeros + digits + decimal_digits_str = "".join(str(d) for d in decimal_digits) + + whole_part = _unparse_whole_part(int(seconds)) + return f"{whole_part}.{decimal_digits_str}" + + +def unparse_time(time: Fraction, allow_fractional=False) -> str: + """format a time for use in an ADM document + + Parameters: + time: time in seconds + allow_fractional: allow use of the BS.2086-2 fractional/sample time format + + Returns: + string like ``01:02:03.4`` (decimal format) or ``01:02:03.2S5`` (fractional format) + + if allow_fractional is true, then the fractional format is used for + FractionalTime instances, or Fraction instances which cannot be exactly + converted to decimal + + otherwise, decimal format is always used, and a warning is issued if it is + not possible to convert the time exactly to decimal + """ + if allow_fractional and isinstance(time, FractionalTime): + return _unparse_fractional(time) + else: + decimal = Decimal(time.numerator) / Decimal(time.denominator) - # XXX: conversion using float here is less than ideal, but there doesn't - # seem to be a better (easy) way; this should be accurate enough to - # maintain ns resolution, but should be revised - return "{hours:02d}:{minutes:02d}:{seconds:08.5f}".format( - hours=hours, minutes=minutes, seconds=float(seconds)) + if decimal == time: + return _unparse_decimal(decimal) + else: + if allow_fractional: + return _unparse_fractional(time) + else: + warnings.warn( + f"loss of accuracy when converting fractional time {time} to decimal" + ) + return _unparse_decimal(decimal) diff --git a/ear/fileio/adm/timing_fixes.py b/ear/fileio/adm/timing_fixes.py new file mode 100644 index 00000000..9e26ab93 --- /dev/null +++ b/ear/fileio/adm/timing_fixes.py @@ -0,0 +1,215 @@ +import warnings +from ...core.select_items.select_items import ObjectChannelMatcher +from .adm import ADM +from .elements import AudioBlockFormatObjects, AudioBlockFormat + + +def _has_interpolationLength(blockFormat): + """Does blockFormat have a defined interpolationLength?""" + assert isinstance(blockFormat, AudioBlockFormat), "wrong type" + return ( + isinstance(blockFormat, AudioBlockFormatObjects) + and blockFormat.jumpPosition.flag + and blockFormat.jumpPosition.interpolationLength is not None + ) + + +def _check_blockFormat_duration(bf_a, bf_b, fix=False): + old_duration = bf_a.duration + new_duration = bf_b.rtime - bf_a.rtime + + if old_duration != new_duration: + if fix: + warnings.warn( + "{direction} duration of block format {id} to match next " + "rtime; was: {old}, now: {new}".format( + direction=( + "expanded" if new_duration > old_duration else "contracted" + ), + id=bf_a.id, + old=old_duration, + new=new_duration, + ) + ) + bf_a.duration = new_duration + + # if contracting this block makes the interpolation end after + # the block, fix it without any more noise + if ( + _has_interpolationLength(bf_a) + and old_duration >= bf_a.jumpPosition.interpolationLength + and new_duration < bf_a.jumpPosition.interpolationLength + ): + bf_a.jumpPosition.interpolationLength = new_duration + else: + warnings.warn( + "duration of block format {id} does not match rtime of next block".format( + id=bf_a.id, + ) + ) + + +def check_blockFormat_durations(adm, fix=False): + """If fix, modify the duration of audioBlockFormats to ensure that the end + of one audioBlockFormat always matches the start of the next. + """ + for channelFormat in adm.audioChannelFormats: + blockFormats = channelFormat.audioBlockFormats + + for bf_a, bf_b in zip(blockFormats[:-1], blockFormats[1:]): + if ( + bf_a.rtime is None + or bf_a.duration is None + or bf_b.rtime is None + or bf_b.duration is None + ): + continue + + _check_blockFormat_duration(bf_a, bf_b, fix=fix) + + +def check_blockFormat_interpolationLengths(adm, fix=False): + """If fix, modify the interpolationLength of audioBlockFormats to ensure + that they are not greater than the durations. + """ + for channelFormat in adm.audioChannelFormats: + for blockFormat in channelFormat.audioBlockFormats: + if ( + blockFormat.rtime is None + or blockFormat.duration is None + or not _has_interpolationLength(blockFormat) + ): + continue + + if blockFormat.jumpPosition.interpolationLength > blockFormat.duration: + if fix: + warnings.warn( + "contracted interpolationLength of block format {id} to match " + "duration; was: {old}, now: {new}".format( + id=blockFormat.id, + old=blockFormat.jumpPosition.interpolationLength, + new=blockFormat.duration, + ) + ) + blockFormat.jumpPosition.interpolationLength = blockFormat.duration + else: + warnings.warn( + "interpolationLength of block format {id} is greater than duration".format( + id=blockFormat.id, + ) + ) + + +def _clamp_blockFormat_times(blockFormat, audioObject, fix=False): + """Modify the duration and interpolationLengths of blockFormat to ensure + that it is within audioObject, which must have a start and duration. + """ + if blockFormat.rtime is None and blockFormat.duration is None: + _clamp_blockFormat_interpolationLength(blockFormat, audioObject, fix=fix) + elif blockFormat.rtime is not None and blockFormat.duration is not None: + _clamp_blockFormat_end(blockFormat, audioObject, fix=fix) + else: + assert False, "not validated" + + +def _clamp_blockFormat_interpolationLength(blockFormat, audioObject, fix=False): + """Modify the interpolationLength of blockFormat to fit within audioObject, + in the case where audioObject has a start and duration, but blockFormat + doesn't. + """ + if ( + _has_interpolationLength(blockFormat) + and blockFormat.jumpPosition.interpolationLength > audioObject.duration + ): + if fix: + warnings.warn( + "reduced interpolationLength of {bf_id} to match duration of {obj_id}".format( + bf_id=blockFormat.id, obj_id=audioObject.id + ) + ) + blockFormat.jumpPosition.interpolationLength = audioObject.duration + else: + warnings.warn( + "interpolationLength of {bf_id} is longer than duration of {obj_id}".format( + bf_id=blockFormat.id, obj_id=audioObject.id + ) + ) + + +def _clamp_blockFormat_end(blockFormat, audioObject, fix=False): + """Modify the duration and interpolationLength of blockFormat to fit within + audioObject, in the case where both blockFormat and audioObject have + rtime/start/duration. + """ + block_end = blockFormat.rtime + blockFormat.duration + if block_end > audioObject.duration: + shift = block_end - audioObject.duration + + fmt_args = dict(bf_id=blockFormat.id, obj_id=audioObject.id, shift=shift) + + if fix: + if shift >= blockFormat.duration: + raise ValueError( + "tried to advance end of {bf_id} by {shift} to match end time of " + "{obj_id}, but this would be before the block start".format( + **fmt_args + ) + ) + else: + warnings.warn( + "advancing end of {bf_id} by {shift} to match end time of {obj_id}".format( + **fmt_args + ) + ) + + blockFormat.duration -= shift + if ( + _has_interpolationLength(blockFormat) + and blockFormat.jumpPosition.interpolationLength + > blockFormat.duration + ): + warnings.warn( + "while advancing end of {bf_id} to match end time of {obj_id}, had " + "to reduce the interpolationLength too".format(**fmt_args) + ) + blockFormat.jumpPosition.interpolationLength = blockFormat.duration + else: + warnings.warn( + "end of {bf_id} is after end time of {obj_id}".format(**fmt_args) + ) + + +def check_blockFormat_times_for_audioObjects(adm: ADM, fix=False): + """If fix, modify the rtimes, durations and interpolationLengths of + audioBlockFormats to ensure that they are within their parent audioObjects. + """ + matcher = ObjectChannelMatcher(adm) + + for audioObject in adm.audioObjects: + if audioObject.duration is None: + continue + + for channelFormat in matcher.get_channel_formats_for_object(audioObject): + for blockFormat in channelFormat.audioBlockFormats: + _clamp_blockFormat_times(blockFormat, audioObject, fix=fix) + + +def check_blockFormat_timings(adm: ADM, fix=False): + """If fix, fix various audioBlockFormat timing issues, otherwise just issue + warnings. + + Parameters: + adm: ADM document to modify + """ + check_blockFormat_durations(adm, fix=fix) + check_blockFormat_interpolationLengths(adm, fix=fix) + check_blockFormat_times_for_audioObjects(adm, fix=fix) + + +def fix_blockFormat_timings(adm: ADM): + """Fix various audioBlockFormat timing issues, issuing warnings. + + Parameters: + adm: ADM document to modify + """ + check_blockFormat_timings(adm, fix=True) diff --git a/ear/fileio/adm/xml.py b/ear/fileio/adm/xml.py index 2405a4bf..b6e16192 100644 --- a/ear/fileio/adm/xml.py +++ b/ear/fileio/adm/xml.py @@ -1,3 +1,4 @@ +import math import sys import warnings from fractions import Fraction @@ -10,14 +11,45 @@ from .adm import ADM from .elements import ( - AudioBlockFormatObjects, AudioBlockFormatDirectSpeakers, AudioBlockFormatBinaural, AudioBlockFormatHoa, AudioBlockFormatMatrix, - ChannelLock, BoundCoordinate, JumpPosition, ObjectDivergence, CartesianZone, PolarZone, ScreenEdgeLock, MatrixCoefficient) -from .elements import ( - AudioProgramme, AudioContent, AudioObject, AudioChannelFormat, AudioPackFormat, AudioStreamFormat, AudioTrackFormat, AudioTrackUID, - FormatDefinition, TypeDefinition, Frequency) -from .elements.geom import (DirectSpeakerPolarPosition, DirectSpeakerCartesianPosition, - ObjectPolarPosition, ObjectCartesianPosition) -from .time_format import parse_time, unparse_time + AlternativeValueSet, + AudioBlockFormatBinaural, + AudioBlockFormatDirectSpeakers, + AudioBlockFormatHoa, + AudioBlockFormatMatrix, + AudioBlockFormatObjects, + AudioChannelFormat, + AudioContent, + AudioObject, + AudioObjectInteraction, + AudioPackFormat, + AudioProgramme, + AudioStreamFormat, + AudioTrackFormat, + AudioTrackUID, + BoundCoordinate, + CartesianPositionInteractionRange, + CartesianPositionOffset, + CartesianZone, + ChannelLock, + DirectSpeakerCartesianPosition, + DirectSpeakerPolarPosition, + FormatDefinition, + Frequency, + InteractionRange, + JumpPosition, + LoudnessMetadata, + MatrixCoefficient, + ObjectCartesianPosition, + ObjectDivergence, + ObjectPolarPosition, + PolarPositionInteractionRange, + PolarPositionOffset, + PolarZone, + ScreenEdgeLock, + TypeDefinition, +) +from .elements.version import parse_version, BS2076Version, NoVersion, Version +from .time_format import parse_time, parse_time_v1, unparse_time from ...common import PolarPosition, CartesianPosition, CartesianScreen, PolarScreen @@ -56,9 +88,9 @@ def text(element): return text if text is not None else "" -@attrs(cmp=False) +@attrs(eq=False) class ParseError(Exception): - """Represents an error that occured while processing a piece of xml.""" + """Represents an error that occurred while processing a piece of xml.""" exception = attrib() element = attrib() attr_name = attrib(default=None) @@ -116,9 +148,13 @@ def load_bool(data): FloatType = TypeConvert(float, "{:.5f}".format) # noqa: P103 SecondsType = TypeConvert(Fraction, lambda t: "{:07.5f}".format(float(t))) BoolType = TypeConvert(load_bool, "{:d}".format) # noqa: P103 -TimeType = TypeConvert(parse_time, unparse_time) +TimeTypeV1 = TypeConvert( + parse_time_v1, lambda t: unparse_time(t, allow_fractional=False) +) +TimeType = TypeConvert(parse_time, lambda t: unparse_time(t, allow_fractional=True)) RefType = TypeConvert(loads=None, dumps=lambda data: data.id) +VersionType = TypeConvert(loads=parse_version, dumps=str) TrackUIDRefType = TypeConvert( loads=lambda s: None if s == "ATU_00000000" else s, @@ -157,13 +193,13 @@ def to_xml(self, element, obj): @attrs class Element(object): """An xml sub-element; see sub-classes.""" - adm_name = attrib() @attrs class ListElement(Element): """An xml sub-element whose text contents is added to a list in the constructor kwargs, either directly or via some conversion.""" + adm_name = attrib() arg_name = attrib() attr_name = attrib(default=Factory(lambda self: self.arg_name, takes_self=True)) type = attrib(default=StringType) @@ -196,6 +232,7 @@ def to_xml(self, element, obj): class AttrElement(Element): """An xml sub-element whose text contents is converted to a constructor kwarg, either directly or via some conversion.""" + adm_name = attrib() arg_name = attrib() attr_name = attrib(default=Factory(lambda self: self.arg_name, takes_self=True)) type = attrib(default=StringType) @@ -257,6 +294,7 @@ def to_xml(self, element, obj): @attrs class CustomElement(Element): """An xml sub-element that is handled by some handler function.""" + adm_name = attrib() handler = attrib() to_xml = attrib(default=None) arg_name = attrib(default=None) @@ -266,6 +304,21 @@ def get_handlers(self): return [("element", qname, self.handler) for qname in qnames(self.adm_name)] +@attrs +class GenericElement(Element): + """Some XML relative to a parent element (e.g. sub-elements or attributes) + which are handled by some handler function which is passed the parent + element. + """ + handler = attrib() + to_xml = attrib(default=None) + arg_name = attrib(default=None) + required = attrib(default=False) + + def get_handlers(self): + return [("generic", None, self.handler)] + + class ElementParser(object): """Parser for an xml element type, that defers to the given properties to handle the attributes and sub-elements.""" @@ -280,6 +333,7 @@ def __init__(self, cls, adm_name, properties, validate=None): self.required_args = set() self.arg_to_name = {} self.validate = validate + self.generic_handlers = [] for prop in properties: for handler_type, adm_name, handler in prop.get_handlers(): @@ -290,6 +344,8 @@ def __init__(self, cls, adm_name, properties, validate=None): elif handler_type == "text": assert self.text_handler is None, "more than one text handler" self.text_handler = handler + elif handler_type == "generic": + self.generic_handlers.append(handler) else: assert False # pragma: no cover @@ -318,6 +374,14 @@ def null_handler(kwargs, x): if self.text_handler is not None: self.text_handler(kwargs, text(element)) + for handler in self.generic_handlers: + try: + handler(kwargs, element) + except ParseError: + raise + except Exception as e: + reraise(ParseError, ParseError(e, element), sys.exc_info()[2]) + if not (viewkeys(kwargs) >= self.required_args): missing_args = self.required_args - viewkeys(kwargs) missing_items = (self.arg_to_name[arg_name] for arg_name in missing_args) @@ -359,6 +423,25 @@ def to_xml(parent, obj): required=False, ) + def as_list_handler(self, arg_name): + """Get a CustomElement for use in another ElementParser which turns + multiple sub-elements into a list called arg_name, and the reverse. + """ + def handle(kwargs, el): + kwargs.setdefault(arg_name, []).append(self.parse(el)) + + def to_xml(parent, obj): + for attr in getattr(obj, arg_name): + self.to_xml(parent, attr) + + return CustomElement( + adm_name=self.adm_name, + handler=handle, + to_xml=to_xml, + arg_name=arg_name, + required=False, + ) + # helpers for common element types @@ -431,20 +514,107 @@ def to_xml(self, element, obj): format_handler = TypeAttribute(FormatDefinition, "formatDefinition", "formatLabel", "format") -# properties common to all block formats -block_format_props = [ - Attribute(adm_name="audioBlockFormatID", arg_name="id", required=True), - Attribute(adm_name="rtime", arg_name="rtime", type=TimeType), - Attribute(adm_name="duration", arg_name="duration", type=TimeType), -] +def make_no_element_before_v2(parent_name, el_name, not_default): + """use where el_name is not supported in parent_name before v2""" + error_msg = f"{el_name} in {parent_name} is a BS.2076-2 feature" + + def handle(kwargs, el): + raise ValueError(error_msg) + + def to_xml(element, obj): + if not_default(obj): + raise ValueError(error_msg) + + return CustomElement(el_name, handle, to_xml=to_xml) + + +# gain + + +def parse_gain(gain_str, gainUnit): + gain_num = FloatType.loads(gain_str) + + if gainUnit == "linear": + return gain_num + elif gainUnit == "dB": + warnings.warn("use of gainUnit may not be compatible with older software") + return math.pow(10, gain_num / 20.0) + else: + raise ValueError(f"gainUnit must be linear or dB, not {gainUnit!r}") -# typeDefinition == "Objects" -def parse_block_format_objects(element): - props = block_format_objects_handler.parse(element) - props["position"] = parse_objects_position(element) - return AudioBlockFormatObjects(**props) +def handle_gain_element_v2(kwargs, el): + if "gain" in kwargs: + raise ValueError("multiple gain elements found") + + gainUnit = el.attrib.get("gainUnit", "linear") + + kwargs["gain"] = parse_gain(text(el), gainUnit) + + +def handle_gain_element_v1(kwargs, el): + if "gain" in kwargs: + raise ValueError("multiple gain elements found") + + if "gainUnit" in el.attrib: + raise ValueError("gainUnit is a BS.2076-2 feature") + + kwargs["gain"] = FloatType.loads(text(el)) + + +def gain_to_xml(element, obj): + if obj.gain != 1.0: + new_el = element.makeelement(QName(default_ns, "gain")) + new_el.text = FloatType.dumps(obj.gain) + element.append(new_el) + + +def optional_gain_to_xml(element, obj): + if obj.gain is not None: + new_el = element.makeelement(QName(default_ns, "gain")) + new_el.text = FloatType.dumps(obj.gain) + element.append(new_el) + + +# use where gain is kept in a sub-element with a gainUnit attribute +gain_element_v1 = CustomElement("gain", handle_gain_element_v1, to_xml=gain_to_xml) +gain_element_v2 = CustomElement("gain", handle_gain_element_v2, to_xml=gain_to_xml) + + +def handle_gain_attribute_v1(kwargs, el): + if "gainUnit" in el.attrib: + raise ValueError("gainUnit is a BS.2076-2 feature") + + if "gain" in el.attrib: + kwargs["gain"] = FloatType.loads(el.attrib["gain"]) + + +def handle_gain_attribute_v2(kwargs, el): + if "gain" in el.attrib: + gain = parse_gain( + el.attrib["gain"], el.attrib.get("gainUnit", "linear") + ) + kwargs["gain"] = gain + elif "gainUnit" in el.attrib: + raise ValueError("gainUnit must not be specified without gain") + + +def gain_attribute_to_xml(element, obj): + if obj.gain is not None: + element.attrib["gain"] = FloatType.dumps(obj.gain) + + +# use where gain is kept in an attribute with an optional gainUnit attribute +gain_attribute_v1 = GenericElement( + handler=handle_gain_attribute_v1, to_xml=gain_attribute_to_xml +) +gain_attribute_v2 = GenericElement( + handler=handle_gain_attribute_v2, to_xml=gain_attribute_to_xml +) + + +# typeDefinition == "Objects" def parse_objects_position(element): @@ -497,9 +667,8 @@ def parse_objects_position(element): .format(found=','.join(coordinates))) -def block_format_objects_to_xml(parent, obj): - element = block_format_objects_handler.to_xml(parent, obj) - object_position_to_xml(element, obj) +def handle_objects_position(kwargs, el): + kwargs["position"] = parse_objects_position(el) def object_position_to_xml(parent, obj): @@ -643,22 +812,6 @@ def zones_to_xml(parent, obj): ]) -block_format_objects_handler = ElementParser(dict, "audioBlockFormat", block_format_props + [ - CustomElement("channelLock", handle_channel_lock, to_xml=channel_lock_to_xml), - CustomElement("jumpPosition", handle_jump_position, to_xml=jump_position_to_xml), - CustomElement("objectDivergence", handle_divergence, to_xml=divergence_to_xml), - AttrElement(adm_name="width", arg_name="width", type=FloatType, default=0.0), - AttrElement(adm_name="height", arg_name="height", type=FloatType, default=0.0), - AttrElement(adm_name="depth", arg_name="depth", type=FloatType, default=0.0), - AttrElement(adm_name="gain", arg_name="gain", type=FloatType, default=1.0), - AttrElement(adm_name="diffuse", arg_name="diffuse", type=FloatType, default=0.0), - AttrElement(adm_name="cartesian", arg_name="cartesian", type=BoolType, default=False), - AttrElement(adm_name="screenRef", arg_name="screenRef", type=BoolType, default=False), - AttrElement(adm_name="importance", arg_name="importance", type=IntType, default=10), - zone_exclusion_handler.as_handler("zoneExclusion", default=[]), -]) - - # typeDefinition == "DirectSpeakers" @@ -710,6 +863,10 @@ def parse_speaker_position(element): .format(found=','.join(coordinates))) +def handle_speaker_position(kwargs, el): + kwargs["position"] = parse_speaker_position(el) + + def speaker_position_to_xml(parent, obj): pos = obj.position @@ -741,109 +898,7 @@ def dump_bound(coordinate, bound, screen_edge_lock=None): assert False, "unexpected type" # pragma: no cover -block_format_direct_speakers_handler = ElementParser(dict, "audioBlockFormat", block_format_props + [ - ListElement(adm_name="speakerLabel", arg_name="speakerLabel"), -]) - - -block_format_binaural_handler = ElementParser(AudioBlockFormatBinaural, "audioBlockFormat", block_format_props) - - -def parse_block_format_direct_speakers(element): - props = block_format_direct_speakers_handler.parse(element) - props["position"] = parse_speaker_position(element) - return AudioBlockFormatDirectSpeakers(**props) - - -def block_format_direct_speakers_to_xml(parent, obj): - element = block_format_direct_speakers_handler.to_xml(parent, obj) - speaker_position_to_xml(element, obj) - - -block_format_HOA_handler = ElementParser(AudioBlockFormatHoa, "audioBlockFormat", block_format_props + [ - AttrElement(adm_name="equation", arg_name="equation", type=StringType), - AttrElement(adm_name="order", arg_name="order", type=IntType), - AttrElement(adm_name="degree", arg_name="degree", type=IntType), - AttrElement(adm_name="normalization", arg_name="normalization", type=StringType), - AttrElement(adm_name="nfcRefDist", arg_name="nfcRefDist", type=FloatType), - AttrElement(adm_name="screenRef", arg_name="screenRef", type=BoolType), -]) - - -matrix_coefficient_handler = ElementParser(MatrixCoefficient, "coefficient", [ - HandleText(arg_name="inputChannelFormatIDRef", attr_name="inputChannelFormat", type=RefType), - Attribute(adm_name="gain", arg_name="gain", type=FloatType), - Attribute(adm_name="phase", arg_name="phase", type=FloatType), - Attribute(adm_name="delay", arg_name="delay", type=FloatType), - Attribute(adm_name="gainVar", arg_name="gainVar", type=StringType), - Attribute(adm_name="phaseVar", arg_name="phaseVar", type=StringType), - Attribute(adm_name="delayVar", arg_name="delayVar", type=StringType), -]) - - -def handle_matrix(kwargs, el): - if "matrix" in kwargs: - raise ValueError("multiple matrix elements found") - - kwargs["matrix"] = [matrix_coefficient_handler.parse(child) - for child in xpath(el, "{ns}coefficient")] - - -def matrix_to_xml(parent, obj): - el = parent.makeelement(QName(default_ns, "matrix")) - - for coefficient in obj.matrix: - matrix_coefficient_handler.to_xml(el, coefficient) - - parent.append(el) - - -block_format_matrix_handler = ElementParser(AudioBlockFormatMatrix, "audioBlockFormat", block_format_props + [ - AttrElement(adm_name="outputChannelFormatIDRef", - arg_name="outputChannelFormatIDRef", - attr_name="outputChannelFormat", type=RefType), - AttrElement(adm_name="outputChannelIDRef", - arg_name="outputChannelFormatIDRef", - attr_name="outputChannelFormat", type=RefType, parse_only=True), - CustomElement("matrix", handle_matrix, to_xml=matrix_to_xml), -]) - - -block_format_handlers = { - TypeDefinition.Objects: parse_block_format_objects, - TypeDefinition.DirectSpeakers: parse_block_format_direct_speakers, - TypeDefinition.Binaural: block_format_binaural_handler.parse, - TypeDefinition.HOA: block_format_HOA_handler.parse, - TypeDefinition.Matrix: block_format_matrix_handler.parse, -} - - -def handle_block_format(kwargs, el): - type = kwargs["type"] - try: - handler = block_format_handlers[type] - except KeyError: - raise ValueError("Do not know how to parse block format of type {type.name}".format(type=type)) - block_format = handler(el) - kwargs.setdefault("audioBlockFormats", []).append(block_format) - - -block_format_to_xml_handlers = { - TypeDefinition.Objects: block_format_objects_to_xml, - TypeDefinition.DirectSpeakers: block_format_direct_speakers_to_xml, - TypeDefinition.Binaural: block_format_binaural_handler.to_xml, - TypeDefinition.HOA: block_format_HOA_handler.to_xml, - TypeDefinition.Matrix: block_format_matrix_handler.to_xml, -} - - -def block_format_to_xml(parent, obj): - try: - handler = block_format_to_xml_handlers[obj.type] - except KeyError: - raise ValueError("Do not know how to generate block format of type {type.name}".format(type=obj.type)) - for bf in obj.audioBlockFormats: - handler(parent, bf) +# frequency def handle_frequency(kwargs, el): @@ -875,60 +930,7 @@ def frequency_to_xml(parent, obj): parent.append(element) -channel_format_handler = ElementParser(AudioChannelFormat, "audioChannelFormat", [ - Attribute(adm_name="audioChannelFormatID", arg_name="id", required=True), - Attribute(adm_name="audioChannelFormatName", arg_name="audioChannelFormatName", required=True), - type_handler, - CustomElement("audioBlockFormat", handle_block_format, arg_name="audioBlockFormats", to_xml=block_format_to_xml, required=True), - CustomElement("frequency", handle_frequency, to_xml=frequency_to_xml), -]) - -pack_format_handler = ElementParser(AudioPackFormat, "audioPackFormat", [ - Attribute(adm_name="audioPackFormatID", arg_name="id", required=True), - Attribute(adm_name="audioPackFormatName", arg_name="audioPackFormatName", required=True), - type_handler, - Attribute(adm_name="importance", arg_name="importance", type=IntType), - RefList("audioChannelFormat"), - RefList("audioPackFormat"), - AttrElement(adm_name="absoluteDistance", arg_name="absoluteDistance", type=FloatType), - RefList("encodePackFormat"), - RefList("decodePackFormat", parse_only=True), - RefElement("inputPackFormat"), - RefElement("outputPackFormat"), - AttrElement(adm_name="normalization", arg_name="normalization", type=StringType), - AttrElement(adm_name="nfcRefDist", arg_name="nfcRefDist", type=FloatType), - AttrElement(adm_name="screenRef", arg_name="screenRef", type=BoolType), -]) - - -def _check_stream_track_ref(kwargs): - if "audioTrackFormatIDRef" not in kwargs: - warnings.warn("audioStreamFormat {id} has no audioTrackFormatIDRef; " - "this may be incompatible with some software".format(id=kwargs["id"])) - - -stream_format_handler = ElementParser(AudioStreamFormat, "audioStreamFormat", [ - Attribute(adm_name="audioStreamFormatID", arg_name="id", required=True), - Attribute(adm_name="audioStreamFormatName", arg_name="audioStreamFormatName", required=True), - format_handler, - RefList("audioTrackFormat"), - RefElement("audioChannelFormat"), - RefElement("audioPackFormat"), -], _check_stream_track_ref) - - -def _check_track_stream_ref(kwargs): - if "audioStreamFormatIDRef" not in kwargs: - warnings.warn("audioTrackFormat {id} has no audioStreamFormatIDRef; " - "this may be incompatible with some software".format(id=kwargs["id"])) - - -track_format_handler = ElementParser(AudioTrackFormat, "audioTrackFormat", [ - Attribute(adm_name="audioTrackFormatID", arg_name="id", required=True), - Attribute(adm_name="audioTrackFormatName", arg_name="audioTrackFormatName", required=True), - format_handler, - RefElement("audioStreamFormat"), -], _check_track_stream_ref) +# screen default_screen = PolarScreen(aspectRatio=1.78, @@ -1027,75 +1029,1027 @@ def make_screen(aspectRatio, centrePosition, width, screen_type): ]) -def make_audio_programme(referenceScreen=None, **kwargs): - if referenceScreen is None: - referenceScreen = default_screen - return AudioProgramme(referenceScreen=referenceScreen, **kwargs) +# position offset -programme_handler = ElementParser(make_audio_programme, "audioProgramme", [ - Attribute(adm_name="audioProgrammeID", arg_name="id", required=True), - Attribute(adm_name="audioProgrammeName", arg_name="audioProgrammeName", required=True), - Attribute(adm_name="audioProgrammeLanguage", arg_name="audioProgrammeLanguage"), - Attribute(adm_name="start", arg_name="start", type=TimeType), - Attribute(adm_name="end", arg_name="end", type=TimeType), - Attribute(adm_name="maxDuckingDepth", arg_name="maxDuckingDepth", type=FloatType), - RefList("audioContent"), - screen_handler.as_handler("referenceScreen", default=default_screen), -]) +def handle_position_offset(kwargs, element): + pos_kwargs = {} -content_handler = ElementParser(AudioContent, "audioContent", [ - Attribute(adm_name="audioContentID", arg_name="id", required=True), - Attribute(adm_name="audioContentName", arg_name="audioContentName", required=True), - Attribute(adm_name="audioContentLanguage", arg_name="audioContentLanguage"), - AttrElement(adm_name="dialogue", arg_name="dialogue", type=IntType), - RefList("audioObject"), -]) + for sub_element in xpath(element, "{ns}positionOffset"): + try: + coordinate = sub_element.attrib["coordinate"] + except KeyError: + raise ValueError("missing coordinate attr") + if coordinate in pos_kwargs: + raise ValueError(f"duplicate {coordinate} coordinates specified") -object_handler = ElementParser(AudioObject, "audioObject", [ - Attribute(adm_name="audioObjectID", arg_name="id", required=True), - Attribute(adm_name="audioObjectName", arg_name="audioObjectName", required=True), - Attribute(adm_name="start", arg_name="start", type=TimeType), - Attribute(adm_name="duration", arg_name="duration", type=TimeType), - Attribute(adm_name="dialogue", arg_name="dialogue", type=IntType), - Attribute(adm_name="importance", arg_name="importance", type=IntType), - Attribute(adm_name="interact", arg_name="interact", type=BoolType), - Attribute(adm_name="disableDucking", arg_name="disableDucking", type=BoolType), - RefList("audioPackFormat"), - RefList("audioObject"), - RefList("audioComplementaryObject"), - ListElement(adm_name="audioTrackUIDRef", arg_name="audioTrackUIDRef", attr_name="audioTrackUIDs", type=TrackUIDRefType), -]) + pos_kwargs[coordinate] = FloatType.loads(text(sub_element)) -track_uid_handler = ElementParser(AudioTrackUID, "audioTrackUID", [ - Attribute(adm_name="UID", arg_name="id", required=True), - Attribute(adm_name="sampleRate", arg_name="sampleRate", type=IntType), - Attribute(adm_name="bitDepth", arg_name="bitDepth", type=IntType), - RefElement("audioTrackFormat"), - RefElement("audioPackFormat"), -]) + if not pos_kwargs: + return + + coordinates = set(pos_kwargs.keys()) + + if coordinates <= {"azimuth", "elevation", "distance"}: + kwargs["positionOffset"] = PolarPositionOffset(**pos_kwargs) + elif coordinates <= {"X", "Y", "Z"}: + kwargs["positionOffset"] = CartesianPositionOffset(**pos_kwargs) + else: + found = ",".join(sorted(coordinates)) + raise ValueError( + f"Found positionOffset coordinates {{{found}}}, but expected " + "{azimuth,elevation,distance} or {X,Y,Z}." + ) + + +def position_offset_to_xml(parent, obj): + pos = obj.positionOffset + + if pos is None: + return + + def dump_coordinate(coordinate, value): + if value != 0.0: + element = parent.makeelement( + QName(default_ns, "positionOffset"), coordinate=coordinate + ) + element.text = FloatType.dumps(value) + + parent.append(element) + + if isinstance(pos, PolarPositionOffset): + dump_coordinate("azimuth", pos.azimuth) + dump_coordinate("elevation", pos.elevation) + dump_coordinate("distance", pos.distance) + elif isinstance(pos, CartesianPositionOffset): + dump_coordinate("X", pos.X) + dump_coordinate("Y", pos.Y) + dump_coordinate("Z", pos.Z) + else: + assert False, "unexpected type" # pragma: no cover + + +position_offset_handler = GenericElement( + handler=handle_position_offset, to_xml=position_offset_to_xml +) + +# main elements + + +@attrs +class AudioStreamFormatWrapper(object): + """Wrapper around an audioStreamFormat which adds audioTrackFormat references.""" + + wrapped = attrib() + audioTrackFormats = attrib(default=Factory(list)) + + def __getattr__(self, name): + return getattr(self.wrapped, name) + + @classmethod + def wrapped_audioStreamFormats(cls, adm): + from collections import OrderedDict + + stream_formats = OrderedDict( + (id(stream_format), cls(stream_format)) + for stream_format in adm.audioStreamFormats + ) + + for track_format in adm.audioTrackFormats: + if track_format.audioStreamFormat is not None: + stream_format = track_format.audioStreamFormat + stream_formats[id(stream_format)].audioTrackFormats.append(track_format) + + return list(stream_formats.values()) + + +@attrs +class MainElement: + """information required to handle a particular type of main element, used + in MainElementHandler + """ + + # xml name of this element + name = attrib() + # ElementParser for this element + handler = attrib() + # given an ADM object and some sub-object, add the sub-object to the ADM + add_func = attrib() + # get a list of sub-objects given an ADM object + get_func = attrib() + + +class MainElementHandler: + """a collection of handlers for the main ADM elements + + this exists to allow some control over how ADM elements are handled + (specifically in different versions), without having to modify the whole + hierarchy of handlers to get at one at the bottom of the stack + + this can be done either by adding parameters/attributes (e.g. with + version), or by sub-classing and overriding one of the make_ functions + """ + + def __init__(self, version): + self.version = version + + self.loudness_handler = self.make_loudness_handler() + + self.block_format_props = self.make_block_format_props() + + self.main_elements = self.get_main_elements() + + def by_version(self, **kwargs): + """select a handler by version; call like by_version(v1=handler_v1...)""" + return kwargs[f"v{self.version.version}"] + + # misc. sub-elements + + def make_loudness_handler(self): + return ElementParser( + LoudnessMetadata, + "loudnessMetadata", + [ + Attribute( + adm_name="loudnessMethod", + arg_name="loudnessMethod", + type=StringType, + ), + Attribute( + adm_name="loudnessRecType", + arg_name="loudnessRecType", + type=StringType, + ), + Attribute( + adm_name="loudnessCorrectionType", + arg_name="loudnessCorrectionType", + type=StringType, + ), + AttrElement( + adm_name="integratedLoudness", + arg_name="integratedLoudness", + type=FloatType, + ), + AttrElement( + adm_name="loudnessRange", arg_name="loudnessRange", type=FloatType + ), + AttrElement( + adm_name="maxTruePeak", arg_name="maxTruePeak", type=FloatType + ), + AttrElement( + adm_name="maxMomentary", arg_name="maxMomentary", type=FloatType + ), + AttrElement( + adm_name="maxShortTerm", arg_name="maxShortTerm", type=FloatType + ), + AttrElement( + adm_name="dialogueLoudness", + arg_name="dialogueLoudness", + type=FloatType, + ), + ], + ) + + def make_gain_element(self): + return self.by_version( + v1=gain_element_v1, + v2=gain_element_v2, + ) + + def make_gain_element_v2(self, element_name): + """use where gain is kept in a sub-element with a gainUnit attribute, but only in v2 + + TODO: element_name is only needed to make the error message make more + sense; include more of the hierarchy in error messages and remove this + """ + return self.by_version( + v1=make_no_element_before_v2( + element_name, "gain", lambda obj: obj.gain != 1.0 + ), + v2=gain_element_v2, + ) + + def make_gain_attribute(self): + return self.by_version( + v1=gain_attribute_v1, + v2=gain_attribute_v2, + ) + + def make_mute_element_v2(self, element_name): + return self.by_version( + v1=make_no_element_before_v2(element_name, "mute", lambda obj: obj.mute), + v2=AttrElement( + adm_name="mute", + arg_name="mute", + type=BoolType, + default=False, + ), + ) + + def make_default_importance_element_v2(self, element_name): + """for elements which have importance in a sub-element only in V2, with a default value""" + return self.by_version( + v1=make_no_element_before_v2( + element_name, "importance", lambda obj: obj.importance != 10 + ), + v2=AttrElement( + adm_name="importance", + arg_name="importance", + type=IntType, + default=10, + ), + ) + + def make_time_type(self): + return self.by_version( + v1=TimeTypeV1, + v2=TimeType, + ) + + def make_v2_RefElement(self, parent_name, name, **kwargs): + """a RefElement which is only present in v2""" + return self.by_version( + v1=make_no_element_before_v2( + parent_name, name + "IDRef", lambda obj: getattr(obj, name) is not None + ), + v2=RefElement(name), + ) + def make_gainInteractionRange_handler(self): + def parse_gain_el_v1(el): + if "gainUnit" in el.attrib: + raise ValueError("gainUnit is a BS.2076-2 feature") + return FloatType.loads(text(el)) -def parse_adm_elements(adm, element, common_definitions=False): - element_types = [ - ("//{ns}audioProgramme", programme_handler.parse, adm.addAudioProgramme), - ("//{ns}audioContent", content_handler.parse, adm.addAudioContent), - ("//{ns}audioObject", object_handler.parse, adm.addAudioObject), - ("//{ns}audioChannelFormat", channel_format_handler.parse, adm.addAudioChannelFormat), - ("//{ns}audioPackFormat", pack_format_handler.parse, adm.addAudioPackFormat), - ("//{ns}audioStreamFormat", stream_format_handler.parse, adm.addAudioStreamFormat), - ("//{ns}audioTrackFormat", track_format_handler.parse, adm.addAudioTrackFormat), - ("//{ns}audioTrackUID", track_uid_handler.parse, adm.addAudioTrackUID), - ] + def parse_gain_el_v2(el): + gainUnit = el.attrib.get("gainUnit", "linear") + + return parse_gain(text(el), gainUnit) + + parse_gain_el = self.by_version( + v1=parse_gain_el_v1, + v2=parse_gain_el_v2, + ) + + def handle_gainInteractionRange(kwargs, element): + gains = {} + for sub_element in xpath(element, "{ns}gainInteractionRange"): + try: + bound = sub_element.attrib["bound"] + except KeyError: + raise ValueError("missing bound attr") + + if bound not in ("min", "max"): + raise ValueError(f"bound {bound!r} is not 'min' or 'max'") + + if bound in gains: + raise ValueError("bound {bound!r} specified multiple times") + + gains[bound] = parse_gain_el(sub_element) + + if gains: + kwargs["gainInteractionRange"] = InteractionRange(**gains) + + def gainInteractionRange_to_xml(element, obj): + if obj.gainInteractionRange is not None: + for bound, value in ( + ("min", obj.gainInteractionRange.min), + ("max", obj.gainInteractionRange.max), + ): + if value is not None: + new_el = element.makeelement( + QName(default_ns, "gainInteractionRange") + ) + new_el.text = FloatType.dumps(value) + new_el.attrib["bound"] = bound + element.append(new_el) + + return GenericElement( + handler=handle_gainInteractionRange, + to_xml=gainInteractionRange_to_xml, + ) - for path, parse_func, add_func in element_types: - for sub_element in xpath(element, path): - adm_element = parse_func(sub_element) + def make_positionInteractionRange_handler(self): + def handle_positionInteractionRange(kwargs, element): + coordinates = {} - if common_definitions: - adm_element.is_common_definition = True + for sub_element in xpath(element, "{ns}positionInteractionRange"): + try: + bound = sub_element.attrib["bound"] + except KeyError: + raise ValueError("missing bound attr") - add_func(adm_element) + if bound not in ("min", "max"): + raise ValueError(f"bound {bound!r} is not 'min' or 'max'") + + try: + coordinate = sub_element.attrib["coordinate"] + except KeyError: + raise ValueError("missing coordinate attr") + + coordinate_args = coordinates.setdefault(coordinate, {}) + + if bound in coordinate_args: + raise ValueError( + f"positionInteractionRange with duplicate coordinate {coordinate!r} and bound {bound!r}" + ) + + coordinate_args[bound] = FloatType.loads(text(sub_element)) + + if coordinates.keys() <= {"azimuth", "elevation", "distance"}: + CoordType = PolarPositionInteractionRange + elif coordinates.keys() <= {"X", "Y", "Z"}: + CoordType = CartesianPositionInteractionRange + else: + found = ",".join(sorted(coordinates)) + raise ValueError( + f"found positionInteractionRange elements with coordinates {{{found}}}, " + f"but expected {{azimuth,elevation,distance}}, {{X,Y,Z}}, or some subset" + ) + + if coordinates: + kwargs["positionInteractionRange"] = CoordType( + **{ + coord: InteractionRange(**bounds) + for coord, bounds in coordinates.items() + } + ) + + def positionInteractionRange_to_xml(element, obj): + pos = obj.positionInteractionRange + if pos is None: + return + elif isinstance(pos, PolarPositionInteractionRange): + coords = ( + ("azimuth", pos.azimuth), + ("elevation", pos.elevation), + ("distance", pos.distance), + ) + elif isinstance(pos, CartesianPositionInteractionRange): + coords = ( + ("X", pos.X), + ("Y", pos.Y), + ("Z", pos.Z), + ) + else: + raise ValueError( + "positionInteractionRange is not PolarPositionInteractionRange " + "or CartesianPositionInteractionRange" + ) + + for coord, bounds in coords: + for bound, value in ("min", bounds.min), ("max", bounds.max): + if value is not None: + new_el = element.makeelement( + QName(default_ns, "positionInteractionRange") + ) + new_el.text = FloatType.dumps(value) + new_el.attrib["bound"] = bound + new_el.attrib["coordinate"] = coord + element.append(new_el) + + return GenericElement( + handler=handle_positionInteractionRange, + to_xml=positionInteractionRange_to_xml, + ) + + def make_audioObjectInteraction_handler(self): + return ElementParser( + AudioObjectInteraction, + "audioObjectInteraction", + [ + Attribute( + adm_name="onOffInteract", + arg_name="onOffInteract", + type=BoolType, + required=True, + ), + Attribute( + adm_name="gainInteract", arg_name="gainInteract", type=BoolType + ), + Attribute( + adm_name="positionInteract", + arg_name="positionInteract", + type=BoolType, + ), + self.make_gainInteractionRange_handler(), + self.make_positionInteractionRange_handler(), + ], + ) + + def make_alternativeValueSet_handler(self): + return ElementParser( + AlternativeValueSet, + "alternativeValueSet", + [ + Attribute( + adm_name="alternativeValueSetID", arg_name="id", required=True + ), + CustomElement( + "gain", handle_gain_element_v2, to_xml=optional_gain_to_xml + ), + AttrElement(adm_name="mute", arg_name="mute", type=BoolType), + position_offset_handler, + self.make_audioObjectInteraction_handler().as_handler( + "audioObjectInteraction" + ), + ], + ) + + # main elements + + def make_programme_handler(self): + def make_audio_programme(referenceScreen=None, **kwargs): + if referenceScreen is None: + referenceScreen = default_screen + return AudioProgramme(referenceScreen=referenceScreen, **kwargs) + + return ElementParser( + make_audio_programme, + "audioProgramme", + [ + Attribute(adm_name="audioProgrammeID", arg_name="id", required=True), + Attribute( + adm_name="audioProgrammeName", + arg_name="audioProgrammeName", + required=True, + ), + Attribute( + adm_name="audioProgrammeLanguage", arg_name="audioProgrammeLanguage" + ), + Attribute( + adm_name="start", arg_name="start", type=self.make_time_type() + ), + Attribute(adm_name="end", arg_name="end", type=self.make_time_type()), + Attribute( + adm_name="maxDuckingDepth", + arg_name="maxDuckingDepth", + type=FloatType, + ), + RefList("audioContent"), + screen_handler.as_handler("referenceScreen", default=default_screen), + self.loudness_handler.as_list_handler("loudnessMetadata"), + self.by_version( + v1=make_no_element_before_v2( + "audioProgramme", + "alternativeValueSetIDRef", + lambda obj: obj.alternativeValueSets, + ), + v2=RefList("alternativeValueSet"), + ), + ], + ) + + def make_content_handler(self): + return ElementParser( + AudioContent, + "audioContent", + [ + Attribute(adm_name="audioContentID", arg_name="id", required=True), + Attribute( + adm_name="audioContentName", + arg_name="audioContentName", + required=True, + ), + Attribute( + adm_name="audioContentLanguage", arg_name="audioContentLanguage" + ), + AttrElement(adm_name="dialogue", arg_name="dialogue", type=IntType), + RefList("audioObject"), + self.loudness_handler.as_list_handler("loudnessMetadata"), + self.by_version( + v1=make_no_element_before_v2( + "audioContent", + "alternativeValueSetIDRef", + lambda obj: obj.alternativeValueSets, + ), + v2=RefList("alternativeValueSet"), + ), + ], + ) + + def make_object_handler(self): + return ElementParser( + AudioObject, + "audioObject", + [ + Attribute(adm_name="audioObjectID", arg_name="id", required=True), + Attribute( + adm_name="audioObjectName", + arg_name="audioObjectName", + required=True, + ), + Attribute( + adm_name="start", arg_name="start", type=self.make_time_type() + ), + Attribute( + adm_name="duration", arg_name="duration", type=self.make_time_type() + ), + Attribute(adm_name="dialogue", arg_name="dialogue", type=IntType), + Attribute(adm_name="importance", arg_name="importance", type=IntType), + Attribute(adm_name="interact", arg_name="interact", type=BoolType), + Attribute( + adm_name="disableDucking", arg_name="disableDucking", type=BoolType + ), + RefList("audioPackFormat"), + RefList("audioObject"), + RefList("audioComplementaryObject"), + ListElement( + adm_name="audioTrackUIDRef", + arg_name="audioTrackUIDRef", + attr_name="audioTrackUIDs", + type=TrackUIDRefType, + ), + self.make_gain_element_v2("audioObject"), + self.make_mute_element_v2("audioObject"), + self.by_version( + v1=make_no_element_before_v2( + "audioObject", + "positionOffset", + lambda obj: obj.positionOffset is not None, + ), + v2=position_offset_handler, + ), + self.by_version( + v1=make_no_element_before_v2( + "audioObject", + "alternativeValueSet", + lambda obj: obj.alternativeValueSets, + ), + v2=self.make_alternativeValueSet_handler().as_list_handler( + "alternativeValueSets" + ), + ), + self.make_audioObjectInteraction_handler().as_handler( + "audioObjectInteraction" + ), + ], + ) + + def make_channel_format_handler(self): + return ElementParser( + AudioChannelFormat, + "audioChannelFormat", + [ + Attribute( + adm_name="audioChannelFormatID", arg_name="id", required=True + ), + Attribute( + adm_name="audioChannelFormatName", + arg_name="audioChannelFormatName", + required=True, + ), + type_handler, + self.make_block_format_handler(), + CustomElement("frequency", handle_frequency, to_xml=frequency_to_xml), + ], + ) + + def make_pack_format_handler(self): + return ElementParser( + AudioPackFormat, + "audioPackFormat", + [ + Attribute(adm_name="audioPackFormatID", arg_name="id", required=True), + Attribute( + adm_name="audioPackFormatName", + arg_name="audioPackFormatName", + required=True, + ), + type_handler, + Attribute(adm_name="importance", arg_name="importance", type=IntType), + RefList("audioChannelFormat"), + RefList("audioPackFormat"), + AttrElement( + adm_name="absoluteDistance", + arg_name="absoluteDistance", + type=FloatType, + ), + RefList("encodePackFormat"), + RefList("decodePackFormat", parse_only=True), + RefElement("inputPackFormat"), + RefElement("outputPackFormat"), + AttrElement( + adm_name="normalization", arg_name="normalization", type=StringType + ), + AttrElement( + adm_name="nfcRefDist", arg_name="nfcRefDist", type=FloatType + ), + AttrElement(adm_name="screenRef", arg_name="screenRef", type=BoolType), + ], + ) + + def make_stream_format_handler(self): + def _check_stream_track_ref(kwargs): + if "audioTrackFormatIDRef" not in kwargs: + warnings.warn( + "audioStreamFormat {id} has no audioTrackFormatIDRef; " + "this may be incompatible with some software".format( + id=kwargs["id"] + ) + ) + + return ElementParser( + AudioStreamFormat, + "audioStreamFormat", + [ + Attribute(adm_name="audioStreamFormatID", arg_name="id", required=True), + Attribute( + adm_name="audioStreamFormatName", + arg_name="audioStreamFormatName", + required=True, + ), + format_handler, + RefList("audioTrackFormat"), + RefElement("audioChannelFormat"), + RefElement("audioPackFormat"), + ], + _check_stream_track_ref, + ) + + def make_track_format_handler(self): + def _check_track_stream_ref(kwargs): + if "audioStreamFormatIDRef" not in kwargs: + warnings.warn( + "audioTrackFormat {id} has no audioStreamFormatIDRef; " + "this may be incompatible with some software".format( + id=kwargs["id"] + ) + ) + + return ElementParser( + AudioTrackFormat, + "audioTrackFormat", + [ + Attribute(adm_name="audioTrackFormatID", arg_name="id", required=True), + Attribute( + adm_name="audioTrackFormatName", + arg_name="audioTrackFormatName", + required=True, + ), + format_handler, + RefElement("audioStreamFormat"), + ], + _check_track_stream_ref, + ) + + def make_track_uid_handler(self): + return ElementParser( + AudioTrackUID, + "audioTrackUID", + [ + Attribute(adm_name="UID", arg_name="id", required=True), + Attribute(adm_name="sampleRate", arg_name="sampleRate", type=IntType), + Attribute(adm_name="bitDepth", arg_name="bitDepth", type=IntType), + RefElement("audioTrackFormat"), + self.make_v2_RefElement("audioTrackUID", "audioChannelFormat"), + RefElement("audioPackFormat"), + ], + ) + + def get_main_elements(self): + return [ + MainElement( + "audioProgramme", + handler=self.make_programme_handler(), + add_func=lambda adm, obj: adm.addAudioProgramme(obj), + get_func=lambda adm: adm.audioProgrammes, + ), + MainElement( + "audioContent", + handler=self.make_content_handler(), + add_func=lambda adm, obj: adm.addAudioContent(obj), + get_func=lambda adm: adm.audioContents, + ), + MainElement( + "audioObject", + handler=self.make_object_handler(), + add_func=lambda adm, obj: adm.addAudioObject(obj), + get_func=lambda adm: adm.audioObjects, + ), + MainElement( + "audioChannelFormat", + handler=self.make_channel_format_handler(), + add_func=lambda adm, obj: adm.addAudioChannelFormat(obj), + get_func=lambda adm: adm.audioChannelFormats, + ), + MainElement( + "audioPackFormat", + handler=self.make_pack_format_handler(), + add_func=lambda adm, obj: adm.addAudioPackFormat(obj), + get_func=lambda adm: adm.audioPackFormats, + ), + MainElement( + "audioStreamFormat", + handler=self.make_stream_format_handler(), + add_func=lambda adm, obj: adm.addAudioStreamFormat(obj), + get_func=lambda adm: AudioStreamFormatWrapper.wrapped_audioStreamFormats( + adm + ), + ), + MainElement( + "audioTrackFormat", + handler=self.make_track_format_handler(), + add_func=lambda adm, obj: adm.addAudioTrackFormat(obj), + get_func=lambda adm: adm.audioTrackFormats, + ), + MainElement( + "audioTrackUID", + handler=self.make_track_uid_handler(), + add_func=lambda adm, obj: adm.addAudioTrackUID(obj), + get_func=lambda adm: adm.audioTrackUIDs, + ), + ] + + # block formats + + def make_block_format_props(self): + return [ + Attribute(adm_name="audioBlockFormatID", arg_name="id", required=True), + Attribute(adm_name="rtime", arg_name="rtime", type=self.make_time_type()), + Attribute( + adm_name="duration", arg_name="duration", type=self.make_time_type() + ), + ] + + def make_block_format_objects_handler(self): + return ElementParser( + AudioBlockFormatObjects, + "audioBlockFormat", + self.block_format_props + + [ + GenericElement( + handler=handle_objects_position, to_xml=object_position_to_xml + ), + CustomElement( + "channelLock", handle_channel_lock, to_xml=channel_lock_to_xml + ), + CustomElement( + "jumpPosition", handle_jump_position, to_xml=jump_position_to_xml + ), + CustomElement( + "objectDivergence", handle_divergence, to_xml=divergence_to_xml + ), + AttrElement( + adm_name="width", arg_name="width", type=FloatType, default=0.0 + ), + AttrElement( + adm_name="height", arg_name="height", type=FloatType, default=0.0 + ), + AttrElement( + adm_name="depth", arg_name="depth", type=FloatType, default=0.0 + ), + AttrElement( + adm_name="diffuse", arg_name="diffuse", type=FloatType, default=0.0 + ), + AttrElement( + adm_name="cartesian", + arg_name="cartesian", + type=BoolType, + default=False, + ), + AttrElement( + adm_name="screenRef", + arg_name="screenRef", + type=BoolType, + default=False, + ), + zone_exclusion_handler.as_handler("zoneExclusion", default=[]), + self.make_gain_element(), + AttrElement( + adm_name="importance", + arg_name="importance", + type=IntType, + default=10, + ), + ], + ) + + def make_block_format_direct_speakers_handler(self): + return ElementParser( + AudioBlockFormatDirectSpeakers, + "audioBlockFormat", + self.block_format_props + + [ + ListElement(adm_name="speakerLabel", arg_name="speakerLabel"), + GenericElement( + handler=handle_speaker_position, to_xml=speaker_position_to_xml + ), + self.make_gain_element_v2("DirectSpeakers audioBlockFormat"), + self.make_default_importance_element_v2( + "DirectSpeakers audioBlockFormat" + ), + ], + ) + + def make_block_format_binaural_handler(self): + return ElementParser( + AudioBlockFormatBinaural, + "audioBlockFormat", + self.block_format_props + + [ + self.make_gain_element_v2("Binaural audioBlockFormat"), + self.make_default_importance_element_v2("Binaural audioBlockFormat"), + ], + ) + + def make_block_format_HOA_handler(self): + return ElementParser( + AudioBlockFormatHoa, + "audioBlockFormat", + self.block_format_props + + [ + AttrElement(adm_name="equation", arg_name="equation", type=StringType), + AttrElement(adm_name="order", arg_name="order", type=IntType), + AttrElement(adm_name="degree", arg_name="degree", type=IntType), + AttrElement( + adm_name="normalization", arg_name="normalization", type=StringType + ), + AttrElement( + adm_name="nfcRefDist", arg_name="nfcRefDist", type=FloatType + ), + AttrElement(adm_name="screenRef", arg_name="screenRef", type=BoolType), + self.make_gain_element_v2("HOA audioBlockFormat"), + self.make_default_importance_element_v2("HOA audioBlockFormat"), + ], + ) + + def make_matrix_coefficient_handler(self): + return ElementParser( + MatrixCoefficient, + "coefficient", + [ + HandleText( + arg_name="inputChannelFormatIDRef", + attr_name="inputChannelFormat", + type=RefType, + ), + self.make_gain_attribute(), + Attribute(adm_name="phase", arg_name="phase", type=FloatType), + Attribute(adm_name="delay", arg_name="delay", type=FloatType), + Attribute(adm_name="gainVar", arg_name="gainVar", type=StringType), + Attribute(adm_name="phaseVar", arg_name="phaseVar", type=StringType), + Attribute(adm_name="delayVar", arg_name="delayVar", type=StringType), + ], + ) + + def make_block_format_matrix_handler(self): + matrix_coefficient_handler = self.make_matrix_coefficient_handler() + + def handle_matrix(kwargs, el): + if "matrix" in kwargs: + raise ValueError("multiple matrix elements found") + + kwargs["matrix"] = [ + matrix_coefficient_handler.parse(child) + for child in xpath(el, "{ns}coefficient") + ] + + def matrix_to_xml(parent, obj): + el = parent.makeelement(QName(default_ns, "matrix")) + + for coefficient in obj.matrix: + matrix_coefficient_handler.to_xml(el, coefficient) + + parent.append(el) + + return ElementParser( + AudioBlockFormatMatrix, + "audioBlockFormat", + self.block_format_props + + [ + AttrElement( + adm_name="outputChannelFormatIDRef", + arg_name="outputChannelFormatIDRef", + attr_name="outputChannelFormat", + type=RefType, + ), + AttrElement( + adm_name="outputChannelIDRef", + arg_name="outputChannelFormatIDRef", + attr_name="outputChannelFormat", + type=RefType, + parse_only=True, + ), + CustomElement("matrix", handle_matrix, to_xml=matrix_to_xml), + self.make_gain_element_v2("Matrix audioBlockFormat"), + self.make_default_importance_element_v2("Matrix audioBlockFormat"), + ], + ) + + def make_block_format_handlers(self): + return { + TypeDefinition.Objects: self.make_block_format_objects_handler(), + TypeDefinition.DirectSpeakers: self.make_block_format_direct_speakers_handler(), + TypeDefinition.Binaural: self.make_block_format_binaural_handler(), + TypeDefinition.HOA: self.make_block_format_HOA_handler(), + TypeDefinition.Matrix: self.make_block_format_matrix_handler(), + } + + def make_block_format_handler(self): + handlers = self.make_block_format_handlers() + + def handle(kwargs, el): + type = kwargs["type"] + try: + handler = handlers[type] + except KeyError: + raise ValueError( + "Do not know how to parse block format of type {type.name}".format( + type=type + ) + ) + block_format = handler.parse(el) + kwargs.setdefault("audioBlockFormats", []).append(block_format) + + def to_xml(parent, obj): + try: + handler = handlers[obj.type] + except KeyError: + raise ValueError( + "Do not know how to generate block format of type {type.name}".format( + type=obj.type + ) + ) + for bf in obj.audioBlockFormats: + handler.to_xml(parent, bf) + + return CustomElement( + "audioBlockFormat", + handle, + arg_name="audioBlockFormats", + to_xml=to_xml, + required=True, + ) + + # external interface for parsing/generating elements + + def parse_adm_elements(self, adm, element, common_definitions=False): + for element_t in self.main_elements: + parse_func = element_t.handler.parse + + for sub_element in xpath(element, ".//{ns}" + element_t.name): + adm_element = parse_func(sub_element) + + if common_definitions: + adm_element.is_common_definition = True + + element_t.add_func(adm, adm_element) + + def add_elements_to_afx(self, adm: ADM, afx: lxml.etree._Element): + for element_t in self.main_elements: + for element in element_t.get_func(adm): + if not element.is_common_definition: + element_t.handler.to_xml(afx, element) + + +default_main_element_handler_v1 = MainElementHandler(BS2076Version(1)) +default_main_element_handler_v2 = MainElementHandler(BS2076Version(2)) + +# default MainElementHandler for each version +default_main_elements_handlers = { + None: default_main_element_handler_v1, + NoVersion(): default_main_element_handler_v1, + BS2076Version(1): default_main_element_handler_v1, + BS2076Version(2): default_main_element_handler_v2, +} + + +def default_get_main_elements_handler(version: Version) -> MainElementHandler: + """get the default MainElementHandler for a given version, or raise""" + try: + return default_main_elements_handlers[version] + except KeyError: + raise NotImplementedError(f"ADM version '{version!s}' is not supported") + + +def find_audioFormatExtended(element: lxml.etree._Element): + """find the audioFormatExtended tag""" + afe_elements = list(xpath(element, ".//{ns}audioFormatExtended")) + if len(afe_elements) == 0: + raise ValueError("no audioFormatExtended elements found") + elif len(afe_elements) > 1: + raise ValueError("multiple audioFormatExtended elements found") + else: + return afe_elements[0] + + +def parse_audioFormatExtended( + adm: ADM, + element: lxml.etree._Element, + common_definitions=False, + get_main_elemtnts_handler=default_get_main_elements_handler, +): + """find the audioFormatExtended tag, and add information from it (including + sub-elements) to adm + """ + afe_element = find_audioFormatExtended(element) + + if not common_definitions: + adm.version = VersionType.loads(afe_element.attrib.get("version")) + + handler = get_main_elemtnts_handler(adm.version) + handler.parse_adm_elements(adm, afe_element, common_definitions=common_definitions) def _sort_block_formats(channelFormats): @@ -1124,57 +2078,87 @@ def _set_default_rtimes(channelFormats): for bf in channelFormat.audioBlockFormats: if bf.rtime is None and bf.duration is not None: bf.rtime = Fraction(0) - - -def _check_block_format_durations(channelFormats, fix=False): - for channelFormat in channelFormats: - block_formats = channelFormat.audioBlockFormats - - for bf_a, bf_b in zip(block_formats[:-1], block_formats[1:]): - if (bf_a.rtime is None or bf_a.duration is None or - bf_b.rtime is None or bf_b.duration is None): - continue - - old_duration = bf_a.duration - new_duration = bf_b.rtime - bf_a.rtime - - if old_duration != new_duration: - if fix: - warnings.warn("{direction} duration of block format {id}; was: {old}, now: {new}".format( - direction="expanded" if new_duration > old_duration else "contracted", - id=bf_a.id, - old=old_duration, - new=new_duration)) - bf_a.duration = new_duration - else: - warnings.warn( - "(rtime + duration) of block format {id_a} does not equal rtime of block format {id_b}.".format( - id_a=bf_a.id, - id_b=bf_b.id)) - - -def load_axml_doc(adm, element, lookup_references=True, fix_block_format_durations=False): - parse_adm_elements(adm, element) + warnings.warn( + f"added missing rtime to {bf.id}; BS.2127-0 states that " + "rtime and duration should both be present or absent" + ) + + +def load_axml_doc( + adm, + element, + lookup_references=True, + fix_block_format_durations=False, + get_main_elemtnts_handler=default_get_main_elements_handler, +): + """Load some axml into an ADM structure. + + This is a low-level function and doesn't deal with common definitions. + + Parameters: + adm (ADM): ADM structure to add to + element (lxml.etree._Element): parsed ADM XML + lookup_references (bool): should we look up references? + fix_block_format_durations (bool): should we attempt to fix up + inaccuracies in audioBlockFormat durations? + + .. note:: + This is deprecated; use the functions in + :mod:`ear.fileio.adm.timing_fixes` instead. + get_main_elemtnts_handler: function from Version to MainElementHandler, + used to override parsing functionality + """ + parse_audioFormatExtended( + adm, element, get_main_elemtnts_handler=get_main_elemtnts_handler + ) if lookup_references: adm.lazy_lookup_references() _set_default_rtimes(adm.audioChannelFormats) _sort_block_formats(adm.audioChannelFormats) - _check_block_format_durations(adm.audioChannelFormats, fix=fix_block_format_durations) + + if fix_block_format_durations: + from . import timing_fixes + warnings.warn("fix_block_format_durations is deprecated, use " + "the functions in ear.fileio.timing_fixes instead", DeprecationWarning) + timing_fixes.fix_blockFormat_durations(adm) def load_axml_string(adm, axmlstr, **kwargs): + """Wrapper around :func:`load_axml_doc` which parses XML too. + + Parameters: + adm (ADM): ADM structure to add to + axmlstr (str): ADM XML string + kwargs: see :func:`load_axml_doc` + """ element = lxml.etree.fromstring(axmlstr) load_axml_doc(adm, element, **kwargs) def load_axml_file(adm, axmlfile, **kwargs): + """Wrapper around :func:`load_axml_doc` which loads XML from a file. + + Parameters: + adm (ADM): ADM structure to add to + axmlfile (Union[str, File]): ADM XML file name or file object; see + :func:`lxml.etree.parse`. + kwargs: see :func:`load_axml_doc` + """ element = lxml.etree.parse(axmlfile) load_axml_doc(adm, element, **kwargs) def parse_string(axmlstr, **kwargs): + """Parse an ADM XML string, including loading common definitions. + + Parameters: + axmlstr (str): ADM XML string + kwargs: see :func:`load_axml_doc` + Returns: + ADM: ADM structure + """ adm = ADM() from .common_definitions import load_common_definitions load_common_definitions(adm) @@ -1183,6 +2167,15 @@ def parse_string(axmlstr, **kwargs): def parse_file(axmlfile, **kwargs): + """Parse an ADM XML file, including loading common definitions. + + Parameters: + axmlfile (Union[str, File]): ADM XML file name or file object; see + :func:`lxml.etree.parse`. + kwargs: see :func:`load_axml_doc` + Returns: + ADM: ADM structure + """ adm = ADM() from .common_definitions import load_common_definitions load_common_definitions(adm) @@ -1190,51 +2183,25 @@ def parse_file(axmlfile, **kwargs): return adm -@attrs -class AudioStreamFormatWrapper(object): - """Wrapper around an audioStreamFormat which adds audioTrackFormat references.""" +def adm_to_xml( + adm: ADM, get_main_elemtnts_handler=default_get_main_elements_handler +) -> lxml.etree._Element: + """Generate an XML element corresponding to an ADM structure. - wrapped = attrib() - audioTrackFormats = attrib(default=Factory(list)) - - def __getattr__(self, name): - return getattr(self.wrapped, name) - - @classmethod - def wrapped_audioStreamFormats(cls, adm): - from collections import OrderedDict - stream_formats = OrderedDict((id(stream_format), cls(stream_format)) - for stream_format in adm.audioStreamFormats) - - for track_format in adm.audioTrackFormats: - if track_format.audioStreamFormat is not None: - stream_format = track_format.audioStreamFormat - stream_formats[id(stream_format)].audioTrackFormats.append(track_format) - - return list(stream_formats.values()) - - -def adm_to_xml(adm): - audioStreamFormats = AudioStreamFormatWrapper.wrapped_audioStreamFormats(adm) - - element_types = [ - (programme_handler.to_xml, adm.audioProgrammes), - (content_handler.to_xml, adm.audioContents), - (object_handler.to_xml, adm.audioObjects), - (channel_format_handler.to_xml, adm.audioChannelFormats), - (pack_format_handler.to_xml, adm.audioPackFormats), - (stream_format_handler.to_xml, audioStreamFormats), - (track_format_handler.to_xml, adm.audioTrackFormats), - (track_uid_handler.to_xml, adm.audioTrackUIDs), - ] + This skips elements marked with is_common_definition + Parameters: + get_main_elemtnts_handler: function from Version to MainElementHandler, + used to override xml formatting + """ E = ElementMaker(namespace=default_ns, nsmap=default_nsmap) afx = E.audioFormatExtended() - for to_xml, elements in element_types: - for element in elements: - if not element.is_common_definition: - to_xml(afx, element) + if adm.version is not None and not isinstance(adm.version, NoVersion): + afx.attrib["version"] = VersionType.dumps(adm.version) + + handler = get_main_elemtnts_handler(adm.version) + handler.add_elements_to_afx(adm, afx) return E.ebuCoreMain( E.coreMetadata( diff --git a/ear/fileio/bw64/chunks.py b/ear/fileio/bw64/chunks.py index da2d717a..5b77d013 100644 --- a/ear/fileio/bw64/chunks.py +++ b/ear/fileio/bw64/chunks.py @@ -74,18 +74,10 @@ def __init__(self, formatTag=1, channelCount=1, sampleRate=48000, str(self.cbSize) ) - if(self.formatTag == Format.WAVE_FORMAT_EXTENSIBLE): + if(formatTag == Format.WAVE_FORMAT_EXTENSIBLE): if not self.extraData: raise RuntimeError( 'missing extra data for WAVE_FORMAT_EXTENSIBLE') - if self.extraData.subFormat not in list(Format): - raise ValueError( - 'subformat not supported: ' + str(self.formatTag)) - if formatTag != self.extraData.subFormat: - raise ValueError( - 'sanity check failed. \'formatTag\' and' - '\'extraData.subFormat\' do not match.' - ) if(self.channelCount < 1): raise ValueError('channelCount < 1') @@ -122,7 +114,10 @@ def __init__(self, formatTag=1, channelCount=1, sampleRate=48000, @property def formatTag(self): - if(self.extraData): + if ( + self.extraData is not None + and self._formatTag == Format.WAVE_FORMAT_EXTENSIBLE + ): return self.extraData.subFormat else: return self._formatTag @@ -325,12 +320,10 @@ def asByteArray(self): @attrs class ChnaChunk(object): - """ Class representation of the ChannelAllocationChunk + """Class representation of the ChannelAllocationChunk - To simplify the writing of files the ChnaChunk (like every chunk class in - this module) has a asByteArray method. This method returns the correct byte - array representation of the chnaChunk, which can be directly written to a - file. + Attributes: + audioIDs (list of AudioID): CHNA entries """ audioIDs = attrib(validator=instance_of(list), default=Factory(list)) @@ -350,6 +343,7 @@ def appendAudioID(self, newAudioID): self.audioIDs.append(newAudioID) def asByteArray(self): + """Get the binary representation of this chunk data.""" fixed_part = struct.pack(' len(self)): numberOfFrames = len(self) - self.tell() rawData = self._buffer.read( @@ -97,9 +135,24 @@ def read(self, numberOfFrames): return deinterleave(samplesDecoded, self.channels) def tell(self): + """Get the sample number of the next sample returned by read.""" return ((self._buffer.tell() - self._chunks[b'data'].position.data) // self._formatInfo.blockAlignment) + def iter_sample_blocks(self, blockSize): + """Read blocks of samples from the file. + + Parameters: + blockSize(int): number of samples to read at a time + + Yields: + np.ndarray of float: sample blocks of shape (nsamples, nchannels), + where nsamples is <= blockSize, and nchannels is the number of + channels + """ + while self.tell() != len(self): + yield self.read(blockSize) + def __len__(self): """ Returns number of frames """ if (self._ds64): @@ -155,6 +208,10 @@ def _read_chunk_header(self): if len(data) != 8: # EOF return None chunkId, chunkSize = struct.unpack('<4sI', data) + + if CHUNK_ID_RE.fullmatch(chunkId) is None: + raise ValueError(f"found chunk header with invalid ID {chunkId}") + # correct chunkSize for rf64 and bw64 files if self.fileFormat in [b'RF64', b'BW64']: if chunkId == b'data': @@ -175,19 +232,53 @@ def _read_chunks(self): # always skip an even number of bytes self._buffer.seek(chunkSize + (chunkSize & 1), 1) + chunk_end = self._buffer.tell() + if chunk_end > self._file_len: + if (chunkSize & 1) and chunkId == b'data' and chunk_end == self._file_len + 1: + warnings.warn("data chunk is missing padding byte") + else: + raise ValueError( + f"{chunkId} chunk ends after the end of the file: " + f"header says {chunk_end} but file ends at {self._file_len}") + + def _check_chunks(self): + required_chunks = [b'fmt ', b'data'] + + for chunk in required_chunks: + if chunk not in self._chunks: + raise ValueError(f'required chunk "{chunk.decode("ascii")}" not found') + def _read_fmt_chunk(self): last_position = self._buffer.tell() self._buffer.seek(self._chunks[b'fmt '].position.data) - if(self._chunks[b'fmt '].size == 16): - formatInfo = struct.unpack(' 2: + cbSize = struct.unpack(' bytes_left: + raise ValueError('fmt chunk not big enough for cbSize') + + if cbSize == 0: + pass + elif cbSize == 22: + fields += (struct.unpack('=22.2', + 'PyYAML~=6.0', + 'lxml~=4.4', + 'six~=1.11', + 'multipledispatch~=1.0', + # required until 2024-10 when python 3.9 will be the minimum supported version + 'importlib_resources>=5.0', +] + +[project.optional-dependencies] +test = [ + 'pytest~=6.2', + 'pytest-datafiles~=2.0', + 'pytest-cov~=3.0', + 'soundfile~=0.10', +] +dev = [ + 'flake8~=3.5', + 'flake8-print~=3.1', + 'flake8-string-format~=0.2', +] + +[project.scripts] +ear-render = "ear.cmdline.render_file:main" +ear-utils = "ear.cmdline.utils:main" + +[tool.setuptools.packages.find] +include = ["ear", "ear.*"] + +[tool.setuptools.dynamic] +readme = {file = ["README.md", "CHANGELOG.md"], content-type = "text/markdown"} diff --git a/setup.py b/setup.py deleted file mode 100644 index e506aa8e..00000000 --- a/setup.py +++ /dev/null @@ -1,60 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name='ear', - description='EBU ADM Renderer', - version='2.0.0', - - url='https://github.com/ebu/ebu_adm_renderer', - - author='EBU', - author_email='ear-admin@list.ebu.ch', - license='BSD-3-Clause-Clear', - - long_description=open('README.md').read() + '\n' + open('CHANGELOG.md').read(), - long_description_content_type='text/markdown', - - install_requires=[ - 'numpy~=1.14', - 'scipy~=1.0', - 'attrs~=17.4', - 'ruamel.yaml~=0.15', - 'lxml~=4.4', - 'enum34~=1.1', - 'six~=1.11', - 'multipledispatch~=0.5', - ], - - extras_require={ - 'test': [ - 'pytest~=3.5', - 'pytest-datafiles~=2.0', - 'pytest-cov~=2.5', - 'soundfile~=0.10', - ], - 'dev': [ - 'flake8~=3.5', - 'flake8-print~=3.1', - 'flake8-string-format~=0.2', - ], - }, - - packages=find_packages(), - - package_data={ - "ear.test": ["data/*.yaml", "data/*.wav"], - "ear.core": ["data/*.yaml", "data/*.dat"], - "ear.core.test": ["data/psp_pvs/*.npz"], - "ear.core.objectbased.test": ["data/gain_calc_pvs/*"], - "ear.fileio.adm": ["data/*.xml"], - "ear.fileio.adm.test": ["test_adm_files/*.xml"], - "ear.fileio.bw64.test": ["test_wav_files/*.wav"], - }, - - entry_points={ - 'console_scripts': [ - 'ear-render = ear.cmdline.render_file:main', - 'ear-utils = ear.cmdline.utils:main' - ] - }, -) diff --git a/tox.ini b/tox.ini index ac72fb87..dbca8d72 100644 --- a/tox.ini +++ b/tox.ini @@ -4,21 +4,23 @@ # and then run "tox" from this directory. [tox] -envlist = py27, py36, py37 +envlist = py36, py37, py38, py39 skip_missing_interpreters = true [testenv] usedevelop = false changedir = {envdir} commands = - py.test -c {toxinidir}/tox.ini --cov=ear {posargs} + py.test -c {toxinidir}/tox.ini --cov=ear --cov-report term:skip-covered --cov-report html:cov_html {posargs} pip check extras = test [pytest] -python_files = *.py +python_files = ear/*.py testpaths = ear addopts = --doctest-modules --pyargs +markers = + datafiles: load datafiles [coverage:run] omit = */test/*