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)
-[](https://travis-ci.org/ebu/ebu_adm_renderer)
-[](https://codecov.io/gh/ebu/ebu_adm_renderer)
+[](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('=3.8"
+dynamic = ["readme"]
+
+dependencies = [
+ 'numpy~=1.14',
+ 'scipy~=1.0',
+ 'attrs>=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/*