diff --git a/ear/common.py b/ear/common.py index f7ea666..fee877a 100644 --- a/ear/common.py +++ b/ear/common.py @@ -1,5 +1,6 @@ from attr import attrs, attrib from attr.validators import instance_of +from math import isfinite import numpy as np @@ -35,6 +36,20 @@ def f(inst, attr, value): actual=item.__class__, item=item), attr, type, item, ) + + return f + + +def finite_float(): + """Attrs validator that checks for finite floats (i.e. not +-inf or NaN).""" + validate_float = instance_of(float) + + def f(inst, attr, value): + validate_float(inst, attr, value) + + if not isfinite(value): + raise ValueError(f"'{attr.name}' must be finite, but {value} is not") + return f @@ -217,9 +232,10 @@ class CartesianScreen(object): centrePosition (CartesianPosition): screenCentrePosition element widthX (float): screenWidth X attribute """ - aspectRatio = attrib(validator=instance_of(float)) + + 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) @@ -234,9 +250,10 @@ class PolarScreen(object): centrePosition (PolarPosition): screenCentrePosition element widthX (float): screenWidth azimuth attribute """ - aspectRatio = attrib(validator=instance_of(float)) + + 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, diff --git a/ear/core/importance.py b/ear/core/importance.py index df5c4ef..64cf53f 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/metadata_input.py b/ear/core/metadata_input.py index 37bcbf2..b23324a 100644 --- a/ear/core/metadata_input.py +++ b/ear/core/metadata_input.py @@ -2,7 +2,7 @@ from attr.validators import instance_of, optional from fractions import Fraction from typing import Optional -from ..common import list_of, default_screen +from ..common import list_of, default_screen, finite_float from ..fileio.adm.elements import ( AudioProgramme, AudioContent, @@ -76,9 +76,9 @@ class ExtraData(object): 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(instance_of(float)), default=None) + pack_absoluteDistance = attrib(validator=optional(finite_float()), default=None) - object_gain = attrib(validator=instance_of(float), default=1.0) + 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 @@ -200,7 +200,7 @@ class GainTrackSpec(TrackSpec): """ input_track = attrib(validator=instance_of(TrackSpec)) - gain = attrib(validator=instance_of(float)) + gain = attrib(validator=finite_float()) ################################################# @@ -288,11 +288,12 @@ class HOATypeMetadata(TypeMetadata): 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))) @@ -300,11 +301,16 @@ class HOATypeMetadata(TypeMetadata): 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): @@ -322,7 +328,7 @@ class HOARenderingItem(RenderingItem): 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 @@ -330,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/select_items/hoa.py b/ear/core/select_items/hoa.py index f161493..54fffc3 100644 --- a/ear/core/select_items/hoa.py +++ b/ear/core/select_items/hoa.py @@ -35,3 +35,4 @@ def _get_block_format_attr(_audioPackFormat_path, audioChannelFormat, attr): get_rtime = partial(_get_block_format_attr, attr="rtime") get_duration = partial(_get_block_format_attr, attr="duration") 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/select_items.py b/ear/core/select_items/select_items.py index 434d6e4..d4728d2 100644 --- a/ear/core/select_items/select_items.py +++ b/ear/core/select_items/select_items.py @@ -94,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: @@ -734,8 +734,17 @@ def _get_RenderingItems_DirectSpeakers(state): def _get_RenderingItems_HOA(state): """Get a HOARenderingItem given an _ItemSelectionState.""" - from .hoa import (get_nfcRefDist, get_screenRef, get_normalization, - get_order, get_degree, get_rtime, get_duration, get_gain) + 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)) @@ -748,6 +757,7 @@ def _get_RenderingItems_HOA(state): 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), diff --git a/ear/core/select_items/test/test_hoa.py b/ear/core/select_items/test/test_hoa.py index f3070a7..2c77e4a 100644 --- a/ear/core/select_items/test/test_hoa.py +++ b/ear/core/select_items/test/test_hoa.py @@ -160,3 +160,24 @@ def test_hoa_gains(): 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/test/test_importance.py b/ear/core/test/test_importance.py index 3087458..e63b288 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_metadata_input.py b/ear/core/test/test_metadata_input.py new file mode 100644 index 0000000..81ba96c --- /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/fileio/adm/elements/block_formats.py b/ear/fileio/adm/elements/block_formats.py index ca0282d..de1400d 100644 --- a/ear/fileio/adm/elements/block_formats.py +++ b/ear/fileio/adm/elements/block_formats.py @@ -1,7 +1,7 @@ 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 @@ -15,12 +15,14 @@ class AudioBlockFormat(object): 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=instance_of(float), default=1.0) + gain = attrib(validator=finite_float(), default=1.0) + importance = attrib(default=10, validator=instance_of(int)) def lazy_lookup_references(self, adm): pass @@ -55,11 +57,11 @@ class MatrixCoefficient(object): 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) @@ -111,7 +113,7 @@ class ChannelLock(object): maxDistance (Optional[float]) """ - maxDistance = attrib(default=None, validator=optional(instance_of(float))) + maxDistance = attrib(default=None, validator=optional(finite_float())) @attrs(slots=True) @@ -124,9 +126,9 @@ class ObjectDivergence(object): positionRange (Optional[float]) """ - value = attrib(validator=instance_of(float)) - azimuthRange = attrib(default=None, validator=optional(instance_of(float))) - positionRange = attrib(default=None, validator=optional(instance_of(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) @@ -155,12 +157,12 @@ class CartesianZone(object): maxZ (float) """ - 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)) + 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) @@ -174,10 +176,10 @@ class PolarZone(object): maxAzimuth (float) """ - minElevation = attrib(validator=instance_of(float)) - maxElevation = attrib(validator=instance_of(float)) - minAzimuth = attrib(validator=instance_of(float)) - maxAzimuth = attrib(validator=instance_of(float)) + minElevation = attrib(validator=finite_float()) + maxElevation = attrib(validator=finite_float()) + minAzimuth = attrib(validator=finite_float()) + maxAzimuth = attrib(validator=finite_float()) @attrs(slots=True) @@ -195,7 +197,6 @@ class AudioBlockFormatObjects(AudioBlockFormat): objectDivergence (Optional[ObjectDivergence]) jumpPosition (JumpPosition) screenRef (bool) - importance (int) zoneExclusion (list[Union[CartesianZone, PolarZone]]) """ @@ -209,7 +210,6 @@ class AudioBlockFormatObjects(AudioBlockFormat): 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))) @@ -243,7 +243,7 @@ class AudioBlockFormatHoa(AudioBlockFormat): 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))) diff --git a/ear/fileio/adm/elements/geom.py b/ear/fileio/adm/elements/geom.py index 9762970..9def88d 100644 --- a/ear/fileio/adm/elements/geom.py +++ b/ear/fileio/adm/elements/geom.py @@ -1,6 +1,14 @@ 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 @@ -124,9 +132,9 @@ class BoundCoordinate(object): max (Optional[float]): value for position element with ``bound="max"`` """ - value = attrib(validator=instance_of(float)) - min = attrib(validator=optional(instance_of(float)), default=None) - max = attrib(validator=optional(instance_of(float)), default=None) + value = attrib(validator=finite_float()) + min = attrib(validator=optional(finite_float()), default=None) + max = attrib(validator=optional(finite_float()), default=None) class DirectSpeakerPosition(object): @@ -229,9 +237,9 @@ class PositionOffset: class PolarPositionOffset(PositionOffset): """representation of a polar positionOffset""" - azimuth = attrib(default=0.0, validator=instance_of(float)) - elevation = attrib(default=0.0, validator=instance_of(float)) - distance = attrib(default=0.0, validator=instance_of(float)) + 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): @@ -250,9 +258,9 @@ def apply(self, pos): class CartesianPositionOffset(PositionOffset): """representation of a cartesian positionOffset""" - X = attrib(default=0.0, validator=instance_of(float)) - Y = attrib(default=0.0, validator=instance_of(float)) - Z = attrib(default=0.0, validator=instance_of(float)) + 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): @@ -276,8 +284,8 @@ class InteractionRange(object): max (Optional[float]): upper bound """ - min = attrib(validator=optional(instance_of(float)), default=None) - max = attrib(validator=optional(instance_of(float)), default=None) + min = attrib(validator=optional(finite_float()), default=None) + max = attrib(validator=optional(finite_float()), default=None) class PositionInteractionRange: diff --git a/ear/fileio/adm/elements/main_elements.py b/ear/fileio/adm/elements/main_elements.py index 3771e77..4b83e58 100644 --- a/ear/fileio/adm/elements/main_elements.py +++ b/ear/fileio/adm/elements/main_elements.py @@ -6,7 +6,13 @@ 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): @@ -61,14 +67,18 @@ class LoudnessMetadata(object): """ 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(instance_of(float))) - loudnessRange = attrib(default=None, validator=optional(instance_of(float))) - maxTruePeak = attrib(default=None, validator=optional(instance_of(float))) - maxMomentary = attrib(default=None, validator=optional(instance_of(float))) - maxShortTerm = attrib(default=None, validator=optional(instance_of(float))) - dialogueLoudness = attrib(default=None, validator=optional(instance_of(float))) + 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) @@ -112,7 +122,7 @@ class AlternativeValueSet(object): id = attrib(default=None) - gain = attrib(validator=optional(instance_of(float)), 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 @@ -252,7 +262,7 @@ class AudioObject(ADMElement): audioObjects = attrib(default=Factory(list), repr=False) audioComplementaryObjects = attrib(default=Factory(list), repr=False) - gain = attrib(validator=instance_of(float), default=1.0) + 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 @@ -326,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) @@ -377,8 +387,8 @@ class Frequency(object): highPass (Optional[float]) """ - lowPass = attrib(default=None, validator=optional(instance_of(float))) - highPass = attrib(default=None, validator=optional(instance_of(float))) + lowPass = attrib(default=None, validator=optional(finite_float())) + highPass = attrib(default=None, validator=optional(finite_float())) @attrs(slots=True) diff --git a/ear/fileio/adm/test/test_xml.py b/ear/fileio/adm/test/test_xml.py index eb8c7f1..717daeb 100644 --- a/ear/fileio/adm/test/test_xml.py +++ b/ear/fileio/adm/test/test_xml.py @@ -238,6 +238,21 @@ def test_gain(base): 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 @@ -431,8 +446,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, @@ -524,6 +540,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): @@ -566,44 +630,85 @@ def test_binaural(base): 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, add_children(bf_path, E.gain("0.5")) + *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)) - - assert with_children(E.gain("0.5")).gain == 0.5 + *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): @@ -680,6 +785,25 @@ 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( diff --git a/ear/fileio/adm/xml.py b/ear/fileio/adm/xml.py index 68b7679..293a41e 100644 --- a/ear/fileio/adm/xml.py +++ b/ear/fileio/adm/xml.py @@ -1245,6 +1245,20 @@ def make_mute_element_v2(self, element_name): ), ) + 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, @@ -1755,7 +1769,6 @@ def make_block_format_props(self): Attribute( adm_name="duration", arg_name="duration", type=self.make_time_type() ), - self.make_gain_element(), ] def make_block_format_objects_handler(self): @@ -1800,13 +1813,14 @@ def make_block_format_objects_handler(self): 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, ), - zone_exclusion_handler.as_handler("zoneExclusion", default=[]), ], ) @@ -1820,12 +1834,22 @@ def make_block_format_direct_speakers_handler(self): 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 + 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): @@ -1844,6 +1868,8 @@ def make_block_format_HOA_handler(self): 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"), ], ) @@ -1905,6 +1931,8 @@ def matrix_to_xml(parent, obj): 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"), ], ) diff --git a/ear/test/test_common.py b/ear/test/test_common.py index da88ebf..34a94dd 100644 --- a/ear/test/test_common.py +++ b/ear/test/test_common.py @@ -1,6 +1,8 @@ -from ..common import cart, PolarPosition +from ..common import cart, PolarPosition, finite_float import numpy.testing as npt import numpy as np +import pytest +from attr import attrs, attrib def test_PolarPosition(): @@ -22,3 +24,18 @@ def test_cart(): npt.assert_allclose(cart(0.0, 0.0, 2.0), np.array([0.0, 2.0, 0.0])) npt.assert_allclose(cart(45.0, 0.0, np.sqrt(2)), np.array([-1.0, 1.0, 0.0])) npt.assert_allclose(cart(0.0, 45.0, np.sqrt(2)), np.array([0.0, 1.0, 1.0])) + + +def test_finite_float(): + @attrs + class HasFiniteFloat: + x = attrib(validator=finite_float()) + + HasFiniteFloat(0.0) + + with pytest.raises(TypeError): + HasFiniteFloat(0) + + for value in float("inf"), float("-inf"), float("NaN"): + with pytest.raises(ValueError, match=f"'x' must be finite, but {value} is not"): + HasFiniteFloat(value)