From 70c5767611f9d6fdaee9682f1cc2b825e24381d6 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Thu, 15 Dec 2022 17:38:35 +0000 Subject: [PATCH 01/31] remove unused import --- ear/core/select_items/hoa.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ear/core/select_items/hoa.py b/ear/core/select_items/hoa.py index dabed2c0..9718a911 100644 --- a/ear/core/select_items/hoa.py +++ b/ear/core/select_items/hoa.py @@ -1,5 +1,4 @@ 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 From 7f5185187bd5fe08db9725a737fb62ae668ad1e5 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 7 Dec 2022 17:39:11 +0000 Subject: [PATCH 02/31] support regular layouts with no defined allocentric positions --- ear/core/allocentric.py | 13 +++++++++ ear/core/bs2051.py | 3 +- ear/core/direct_speakers/panner.py | 8 ++++-- ear/core/layout.py | 10 +++++++ ear/core/objectbased/gain_calc.py | 9 ++++-- ear/core/point_source.py | 44 +++++++++++++++++++++++++++--- 6 files changed, 78 insertions(+), 9 deletions(-) diff --git a/ear/core/allocentric.py b/ear/core/allocentric.py index c649381d..c48561b7 100644 --- a/ear/core/allocentric.py +++ b/ear/core/allocentric.py @@ -39,6 +39,11 @@ def _screen_spk_position_to_cart(position): return pos_left * [np.sign(position.azimuth), 1.0, 1.0] +def positions_defined(layout) -> bool: + """are allocentric positions defined for the given layout?""" + return layout.name in _allo_positions + + def positions_for_layout(layout): layout_positions = _allo_positions[layout.name] @@ -52,6 +57,14 @@ def get_position(channel): for channel in layout.channels]) +def positions_for_layout_if_defined(layout): + """get allocentric positions for layout if they are defined, or None + otherwise + """ + if positions_defined(layout): + return positions_for_layout(layout) + + def get_excluded(channel_positions, is_excluded): is_excluded = np.copy(is_excluded) diff --git a/ear/core/bs2051.py b/ear/core/bs2051.py index 058eefc9..279a8457 100644 --- a/ear/core/bs2051.py +++ b/ear/core/bs2051.py @@ -1,7 +1,7 @@ import pkg_resources from ..compatibility import load_yaml from .geom import PolarPosition -from .layout import Channel, Layout +from .layout import Channel, Layout, LayoutStyle def _dict_to_channel(d): @@ -23,6 +23,7 @@ def _dict_to_layout(d): return Layout( name=d["name"], channels=list(map(_dict_to_channel, d["channels"])), + style=LayoutStyle.__members__[d.get("style", "ITU")], ) diff --git a/ear/core/direct_speakers/panner.py b/ear/core/direct_speakers/panner.py index a6ef2c73..d94fc347 100644 --- a/ear/core/direct_speakers/panner.py +++ b/ear/core/direct_speakers/panner.py @@ -245,8 +245,8 @@ def __init__(self, layout, point_source_opts={}, additional_substitutions={}): self.positions = layout.nominal_positions self.is_lfe = layout.is_lfe - self.allo_positions = allocentric.positions_for_layout(layout) - self.allo_psp = point_source.configure_allocentric(layout.without_lfe) + self.allo_positions = allocentric.positions_for_layout_if_defined(layout) + self.allo_psp = point_source.configure_allocentric_if_defined(layout.without_lfe) self._screen_edge_lock_handler = ScreenEdgeLockHandler(self.layout.screen, layout) @@ -396,6 +396,10 @@ def handle(self, type_metadata): psp = self.psp positions = self.positions elif isinstance(block_format.position, DirectSpeakerCartesianPosition): + if self.allo_psp is None: + raise RuntimeError("allocentric rendering is not defined for this layout; " + "perhaps use conversion or another layout") + psp = self.allo_psp positions = self.allo_positions else: diff --git a/ear/core/layout.py b/ear/core/layout.py index 7e41f84e..175da7c8 100644 --- a/ear/core/layout.py +++ b/ear/core/layout.py @@ -1,6 +1,7 @@ from __future__ import print_function from attr import attrs, attrib, evolve, Factory from attr.validators import instance_of, optional +import enum import numpy as np import sys from .geom import CartesianPosition, PolarPosition, inside_angle_range @@ -82,6 +83,13 @@ def check_position(self, callback=_print_warning): el_range=self.el_range)) +class LayoutStyle(enum.Enum): + # a layout defined in BS.2051, or with a similar structure + ITU = enum.auto() + # a regular layout for which plain VBAP rendering is appropriate + REGULAR = enum.auto() + + @attrs(frozen=True, slots=True) class Layout(object): """Representation of a loudspeaker layout, with a name and a list of channels. @@ -91,11 +99,13 @@ class Layout(object): channels (list[Channel]): list of channels in the layout screen (Optional[Union[CartesianScreen, PolarScreen]]): screen information to use for screen-related content + style (LayoutStyle): indicates the style of layout (default: ITU) """ name = attrib() channels = attrib() screen = attrib(validator=optional(instance_of((CartesianScreen, PolarScreen))), default=default_screen) + style = attrib(validator=instance_of(LayoutStyle), default=LayoutStyle.ITU) @property def positions(self): diff --git a/ear/core/objectbased/gain_calc.py b/ear/core/objectbased/gain_calc.py index 803309a6..1235ee08 100644 --- a/ear/core/objectbased/gain_calc.py +++ b/ear/core/objectbased/gain_calc.py @@ -131,9 +131,10 @@ class AlloChannelLockHandler(ChannelLockHandlerBase): def __init__(self, layout): super(AlloChannelLockHandler, self).__init__(layout) - self.channel_positions = allocentric.positions_for_layout(layout) + self.channel_positions = allocentric.positions_for_layout_if_defined(layout) def get_weighted_distances(self, channel_positions, position): + assert self.channel_positions is not None w = np.array([1.0 / 16, 4, 32]) return np.sqrt(np.sum(w * (position - channel_positions) ** 2, axis=1)) @@ -358,7 +359,7 @@ def __init__(self, layout, point_source_opts): self.is_lfe = layout.is_lfe - self.allo_channel_positions = allocentric.positions_for_layout(layout.without_lfe) + self.allo_channel_positions = allocentric.positions_for_layout_if_defined(layout.without_lfe) def render(self, object_meta): block_format = object_meta.block_format @@ -374,6 +375,10 @@ def render(self, object_meta): block_format.cartesian) if block_format.cartesian: + if self.allo_channel_positions is None: + raise RuntimeError("allocentric rendering is not defined for this layout; " + "perhaps use conversion or another layout") + excluded = allocentric.get_excluded( self.allo_channel_positions, self.zone_exclusion_handler.get_excluded(block_format.zoneExclusion)) diff --git a/ear/core/point_source.py b/ear/core/point_source.py index 5cb7e7fe..da227595 100644 --- a/ear/core/point_source.py +++ b/ear/core/point_source.py @@ -3,7 +3,7 @@ from attr import attrs, attrib, evolve from .util import as_array, has_shape from .geom import ngon_vertex_order, PolarPosition -from .layout import Channel +from .layout import Channel, LayoutStyle from ..options import OptionsHandler from . import bs2051 @@ -525,6 +525,28 @@ def _configure_full(layout): return PointSourcePannerDownmix(PointSourcePanner(regions), downmix=downmix) +def _configure_regular(layout): + positions_real = layout.norm_positions + + facets = _convex_hull_facets(positions_real) + + # Turn the facets into regions for the point source panner. + regions = [] + + for facet_verts in facets: + facet_verts = np.fromiter(facet_verts, int) + if len(facet_verts) == 3: + regions.append(Triplet(output_channels=facet_verts, + positions=positions_real[facet_verts])) + elif len(facet_verts) == 4: + regions.append(QuadRegion(output_channels=facet_verts, + positions=positions_real[facet_verts])) + else: + assert False, "facets with more than 4 vertices are not supported" + + return PointSourcePanner(regions) + + class AllocentricPanner(object): def __init__(self, positions): @@ -709,6 +731,15 @@ def configure_allocentric(layout): return AllocentricPanner(positions) +def configure_allocentric_if_defined(layout): + """Build an allocentric point-source panner for the given layout if the + allocentric positions for it are defined, otherwise return None. + """ + from . import allocentric + if allocentric.positions_defined(layout): + return configure_allocentric(layout) + + configure_options = OptionsHandler() @@ -727,7 +758,12 @@ def configure(layout): _check_screen_speakers(layout) - if layout.name == "0+2+0": - return _configure_stereo(layout) + if layout.style == LayoutStyle.ITU: + if layout.name == "0+2+0": + return _configure_stereo(layout) + else: + return _configure_full(layout) + elif layout.style == LayoutStyle.REGULAR: + return _configure_regular(layout) else: - return _configure_full(layout) + raise RuntimeError(f"unknown layout style: {layout.style}") From 5a2b6d1e584d3aadcc03cc2090bda788d8c1538f Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Mon, 12 Dec 2022 18:03:21 +0000 Subject: [PATCH 03/31] remove development config file mechanism this was more complicated than it needed to be for the current usage, and makes it difficult to make structures where the set of options is more dynamic all the options should be the same; the only API change is the removal of the actual options structures --- CHANGELOG.md | 1 + ear/cmdline/dev_config.py | 19 --- ear/core/direct_speakers/panner.py | 13 -- ear/core/direct_speakers/renderer.py | 7 +- ear/core/objectbased/decorrelate.py | 33 +---- ear/core/objectbased/gain_calc.py | 16 +-- ear/core/objectbased/renderer.py | 27 ++-- ear/core/objectbased/test/test_decorrelate.py | 7 + ear/core/point_source.py | 4 - ear/core/renderer.py | 20 +-- ear/core/scenebased/design.py | 25 +--- ear/core/scenebased/renderer.py | 13 +- ear/options.py | 131 ------------------ 13 files changed, 50 insertions(+), 266 deletions(-) delete mode 100644 ear/cmdline/dev_config.py delete mode 100644 ear/options.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 193a3ed5..c6868e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed - Added a warning for audioBlockFormats which have a duration but no rtime; previously these were fixed silently. See [#54]. +- Removed the development config file mechanism, as it is no longer required and was not used. ## [2.1.0] - 2022-01-26 diff --git a/ear/cmdline/dev_config.py b/ear/cmdline/dev_config.py deleted file mode 100644 index 91414f53..00000000 --- a/ear/cmdline/dev_config.py +++ /dev/null @@ -1,19 +0,0 @@ -def load_config(args): - from ..compatibility import load_yaml - if args.config is not None: - return load_yaml(args.config) - else: - return {} - - -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) - - if args.output is None or args.output == "-": - sys.stdout.write(config_str) - else: - with open(args.output, "w") as f: - f.write(config_str) diff --git a/ear/core/direct_speakers/panner.py b/ear/core/direct_speakers/panner.py index d94fc347..bc55fd44 100644 --- a/ear/core/direct_speakers/panner.py +++ b/ear/core/direct_speakers/panner.py @@ -7,7 +7,6 @@ from .. import point_source from .. import allocentric from ..renderer_common import is_lfe -from ...options import OptionsHandler, SubOptions, Option from ..screen_edge_lock import ScreenEdgeLockHandler from ...fileio.adm.elements import DirectSpeakerCartesianPosition, DirectSpeakerPolarPosition @@ -219,18 +218,6 @@ def opposite_name(channel_name): class DirectSpeakersPanner(object): - options = OptionsHandler( - point_source_opts=SubOptions( - handler=point_source.configure_options, - description="options for point source panner", - ), - additional_substitutions=Option( - default={}, - description="dictionary of additional speaker label substitutions", - ), - ) - - @options.with_defaults def __init__(self, layout, point_source_opts={}, additional_substitutions={}): self.layout = layout self.psp = point_source.configure(layout.without_lfe, **point_source_opts) diff --git a/ear/core/direct_speakers/renderer.py b/ear/core/direct_speakers/renderer.py index 0defa9f4..2d1c8a1f 100644 --- a/ear/core/direct_speakers/renderer.py +++ b/ear/core/direct_speakers/renderer.py @@ -36,10 +36,13 @@ def __call__(self, sample_rate, block): class DirectSpeakersRenderer(object): + """renderer for DirectSpeakers content - options = DirectSpeakersPanner.options + Args: + layout (Layout): layout to render to + **options: options for DirectSpeakersPanner + """ - @options.with_defaults def __init__(self, layout, **options): self._panner = DirectSpeakersPanner(layout, **options) self._nchannels = len(layout.channels) diff --git a/ear/core/objectbased/decorrelate.py b/ear/core/objectbased/decorrelate.py index 77c07ff3..702433ec 100644 --- a/ear/core/objectbased/decorrelate.py +++ b/ear/core/objectbased/decorrelate.py @@ -1,6 +1,5 @@ from __future__ import division import numpy as np -from ...options import Option, SubOptions, OptionsHandler def gen_rand_mt19937(seed, n): @@ -14,16 +13,7 @@ def gen_rand_float(seed, n): return gen_rand_mt19937(seed, n) / 2**32 -basic_options = OptionsHandler( - size=Option( - default=512, - description="decorrelation filter length", - ), -) - - -@basic_options.with_defaults -def design_decorrelator_basic(decorrelator_id, size): +def design_decorrelator_basic(decorrelator_id, size=512): """Design an all-pass random-phase FIR filter. Parameters: @@ -44,24 +34,11 @@ def design_decorrelator_basic(decorrelator_id, size): design_methods = dict( - basic=(design_decorrelator_basic, basic_options), -) - - -design_options = OptionsHandler( - method=Option( - default="basic", - description="filter design method, one of: {}".format(", ".join(design_methods.keys())), - ), - **{ - method + "_opts": SubOptions(handler=opts_handler, - description="options used for design method '{}'".format(method)) - for method, (func, opts_handler) in design_methods.items()} + basic=design_decorrelator_basic, ) -@design_options.with_defaults -def design_decorrelators(layout, method, **options): +def design_decorrelators(layout, method="basic", **options): """Design one filter for each channel in layout. Parameters: @@ -74,7 +51,7 @@ def design_decorrelators(layout, method, **options): """ sorted_channel_names = sorted(layout.channel_names) - design = design_methods[method][0] - decorrelators = [design(sorted_channel_names.index(name), **options[method + "_opts"]) + design = design_methods[method] + decorrelators = [design(sorted_channel_names.index(name), **options.get(method + "_opts", {})) for name in layout.channel_names] return np.array(decorrelators).T diff --git a/ear/core/objectbased/gain_calc.py b/ear/core/objectbased/gain_calc.py index 1235ee08..8dccd6c9 100644 --- a/ear/core/objectbased/gain_calc.py +++ b/ear/core/objectbased/gain_calc.py @@ -3,7 +3,6 @@ import warnings from . import allo_extent, extent from .. import point_source -from ...options import SubOptions, OptionsHandler from ..geom import azimuth, elevation, cart, inside_angle_range, local_coordinate_system from .zone import ZoneExclusionDownmix from .. import allocentric @@ -340,15 +339,14 @@ def direct_diffuse_split(gains, diffuse): class GainCalc(object): - options = OptionsHandler( - point_source_opts=SubOptions( - handler=point_source.configure_options, - description="options for point source panner", - ), - ) + """gain calculator for Objects content + + Args: + layout (Layout): layout to render to + point_sourcec_opts (dict): options for point source panner + """ - @options.with_defaults - def __init__(self, layout, point_source_opts): + def __init__(self, layout, point_source_opts={}): self.point_source_panner = point_source.configure(layout.without_lfe, **point_source_opts) self.screen_edge_lock_handler = ScreenEdgeLockHandler(layout.screen, layout.without_lfe) self.screen_scale_handler = ScreenScaleHandler(layout.screen, layout.without_lfe) diff --git a/ear/core/objectbased/renderer.py b/ear/core/objectbased/renderer.py index e94b6928..000f3c1e 100644 --- a/ear/core/objectbased/renderer.py +++ b/ear/core/objectbased/renderer.py @@ -3,7 +3,6 @@ from fractions import Fraction from ..convolver import OverlapSaveConvolver, VariableBlockSizeAdapter from ..delay import Delay -from ...options import Option, SubOptions, OptionsHandler from .gain_calc import GainCalc from . import decorrelate from ..renderer_common import BlockProcessingChannel, InterpretTimingMetadata, InterpGains, FixedGains @@ -77,24 +76,16 @@ def __call__(self, sample_rate, block): class ObjectRenderer(object): + """renderer for Objects content - options = OptionsHandler( - block_size=Option( - default=512, - description="block size for decorrelator convolution", - ), - gain_calc_opts=SubOptions( - handler=GainCalc.options, - description="options for gain calculator", - ), - decorrelator_opts=SubOptions( - handler=decorrelate.design_options, - description="options for decorrelation filter design", - ), - ) - - @options.with_defaults - def __init__(self, layout, gain_calc_opts, decorrelator_opts, block_size): + Args: + layout (Layout): layout to render to + gain_calc_opts (dict): options for gain calculator + decorrelator_opts (dict): options for decorrelation filter design + block_size (int): block size for decorrelator convolution + """ + + def __init__(self, layout, gain_calc_opts={}, decorrelator_opts={}, block_size=512): self._gain_calc = GainCalc(layout, **gain_calc_opts) self._nchannels = len(layout.channels) diff --git a/ear/core/objectbased/test/test_decorrelate.py b/ear/core/objectbased/test/test_decorrelate.py index 649f847b..f4c66e33 100644 --- a/ear/core/objectbased/test/test_decorrelate.py +++ b/ear/core/objectbased/test/test_decorrelate.py @@ -35,6 +35,13 @@ def test_design_decorrelators(method_name, method_f): npt.assert_allclose(right_filter, method_f(1)) +def test_design_decorrelators_options(): + # check that options are correctly passed to methods + layout = bs2051.get_layout("4+5+0").without_lfe + filters = design_decorrelators(layout, method="basic", basic_opts=dict(size=42)) + assert filters.shape == (42, 9) + + def correlation_coefficient(a, b): cross_corr = np.fft.irfft(np.fft.rfft(a) * np.fft.rfft(b[::-1])) return cross_corr[np.argmax(np.abs(cross_corr))] diff --git a/ear/core/point_source.py b/ear/core/point_source.py index da227595..874b992b 100644 --- a/ear/core/point_source.py +++ b/ear/core/point_source.py @@ -4,7 +4,6 @@ from .util import as_array, has_shape from .geom import ngon_vertex_order, PolarPosition from .layout import Channel, LayoutStyle -from ..options import OptionsHandler from . import bs2051 @@ -740,9 +739,6 @@ def configure_allocentric_if_defined(layout): return configure_allocentric(layout) -configure_options = OptionsHandler() - - def configure(layout): """Configure a point source panner given a loudspeaker layout. diff --git a/ear/core/renderer.py b/ear/core/renderer.py index 93a22e63..2c020b76 100644 --- a/ear/core/renderer.py +++ b/ear/core/renderer.py @@ -2,7 +2,6 @@ from .objectbased.renderer import ObjectRenderer from .direct_speakers.renderer import DirectSpeakersRenderer from .scenebased.renderer import HOARenderer -from ..options import SubOptions, OptionsHandler from .metadata_input import ObjectRenderingItem, DirectSpeakersRenderingItem, HOARenderingItem from .block_aligner import BlockAligner @@ -13,24 +12,11 @@ class Renderer(object): Parameters: layout (.layout.Layout): loudspeaker layout to render to + object_renderer_opts (dict): options for objects renderer + direct_speakers_opts (dict): options for direct speakers renderer + hoa_renderer_opts (dict): options for HOA renderer """ - options = OptionsHandler( - object_renderer_opts=SubOptions( - handler=ObjectRenderer.options, - description="options for object based renderer", - ), - direct_speakers_opts=SubOptions( - handler=DirectSpeakersRenderer.options, - description="options for direct speakers renderer", - ), - hoa_renderer_opts=SubOptions( - handler=HOARenderer.options, - description="options for HOA renderer", - ), - ) - - @options.with_defaults def __init__(self, layout, object_renderer_opts={}, direct_speakers_opts={}, hoa_renderer_opts={}): self.block_aligner = BlockAligner(len(layout.channels)) diff --git a/ear/core/scenebased/design.py b/ear/core/scenebased/design.py index b739216a..7bf1bf8e 100644 --- a/ear/core/scenebased/design.py +++ b/ear/core/scenebased/design.py @@ -2,7 +2,6 @@ import warnings from .. import hoa from .. import point_source -from ...options import OptionsHandler, Option, SubOptions class HOADecoderDesign(object): @@ -10,25 +9,15 @@ class HOADecoderDesign(object): Args: layout (Layout): Loudspeaker layout to design decoders for. + norm_mean_power (bool): normalise the decoder + maxRE (bool): apply maxRE weighting + maxRE_scale (str): normalisation method for maxRE weights; only + relevant if maxRE is set and norm_mean_power is not. + options: none, speakers, components, order + point_source_opts (dict): options for point source panner """ - options = OptionsHandler( - norm_mean_power=Option(default=True, - description="normalize the decoder"), - maxRE=Option(default=False, - description="apply maxRE weighting"), - maxRE_scale=Option(default="none", - description=("normalisation method for maxRE weights; " - "only relevant if maxRE is set and norm_mean_power is not. " - "options: none, speakers, components, order")), - point_source_opts=SubOptions( - handler=point_source.configure_options, - description="options for point source panner", - ), - ) - - @options.with_defaults - def __init__(self, layout, norm_mean_power, maxRE, maxRE_scale, point_source_opts): + def __init__(self, layout, norm_mean_power=True, maxRE=False, maxRE_scale="none", point_source_opts={}): self.psp = point_source.configure(layout, **point_source_opts) self._initialised = False diff --git a/ear/core/scenebased/renderer.py b/ear/core/scenebased/renderer.py index 99903e5b..510722bf 100644 --- a/ear/core/scenebased/renderer.py +++ b/ear/core/scenebased/renderer.py @@ -3,7 +3,6 @@ from .design import HOADecoderDesign from ..renderer_common import BlockProcessingChannel, InterpretTimingMetadata, ProcessingBlock from ..track_processor import MultiTrackProcessor -from ...options import OptionsHandler, SubOptions @attrs(slots=True, frozen=True) @@ -58,14 +57,14 @@ def __call__(self, sample_rate, block): class HOARenderer(object): + """renderer for HOA content - options = OptionsHandler( - design_opts=SubOptions(handler=HOADecoderDesign.options, - description="options for decoder design"), - ) + Args: + layout (Layout): options for decoder design + design_opts (dict): options for decoder design + """ - @options.with_defaults - def __init__(self, layout, design_opts): + def __init__(self, layout, design_opts={}): self._decoder_design = HOADecoderDesign(layout.without_lfe, **design_opts) self._output_channels = ~layout.is_lfe diff --git a/ear/options.py b/ear/options.py deleted file mode 100644 index 93a1fc2e..00000000 --- a/ear/options.py +++ /dev/null @@ -1,131 +0,0 @@ -from attr import attrs, attrib -from ruamel import yaml -from functools import wraps -import warnings - - -@attrs -class Option(object): - """Objects representing a single defaulted option to be passed to a - function. - - Parameters: - default (any type): default value for this option - description (string): description for this option - """ - default = attrib() - description = attrib() - - def _get_default_yaml(self, *args, **kwargs): - return self.default - - -@attrs -class SubOptions(object): - """Objects representing a group of options that will be delegated to some - other function. - - Parameters: - handler (OptionsHandler): options supported by the function that this - option will be delegated to - description (string): description for this option - """ - handler = attrib() - description = attrib() - - @property - def default(self): - return {} - - def _get_default_yaml(self, *args, **kwargs): - return self.handler._get_defaults_yaml(*args, **kwargs) - - -class OptionsHandler(object): - """Objects that represent a set of defaulted options that can be passed to - a function. - - Parameters: - **options: keys represent option names; values may be either Option or - SubOptions instances - """ - def __init__(self, **options): - self.options = options - - def set_defaults(self, options): - """add default values to options - - Parameters: - options (dict): dictionary that defaults are to be added to - """ - for key, option in self.options.items(): - if key not in options: - options[key] = option.default - - def with_defaults(self, f): - """Decorate f, such that the default options are added to the keyword arguments.""" - @wraps(f) - def wrapper(*args, **kwargs): - self.set_defaults(kwargs) - return f(*args, **kwargs) - return wrapper - - def _get_defaults_yaml(self, indent=2, current_indent=0): - mapping = yaml.comments.CommentedMap() - for key, option in self.options.items(): - mapping[key] = option._get_default_yaml(indent=indent, current_indent=current_indent + indent) - mapping.yaml_set_comment_before_after_key(key, option.description, indent=current_indent) - - return mapping - - -def _merge_options_into_defaults(options, defaults): - for key, option in options.items(): - if key in defaults: - if isinstance(defaults[key], yaml.comments.CommentedMap): - _merge_options_into_defaults(option, defaults[key]) - else: - defaults[key] = option - else: - warnings.warn("removing unknown option {}".format(key)) - - -def dump_config_with_comments(root_handler, options={}, indent=4): - """Dump a configuration to a yaml-formatted string, including the default - options, and optionally user-set options, and comments with option - descriptions. - - Parameters: - root_handler (OptionsHandler): handler to get defaults and descriptions from - options (dict): user-provided options that override the defaults in root_handler - indent (int): indent size in spaces - """ - defaults = root_handler._get_defaults_yaml(indent=indent) - - _merge_options_into_defaults(options, defaults) - - return yaml.dump(defaults, - Dumper=yaml.RoundTripDumper, - indent=indent) - - -def test_merge(): - options_a = OptionsHandler( - foo=Option(5, "foo"), - bar=Option(6, "bar"), - ) - options_b = OptionsHandler( - a_opts=SubOptions(options_a, "options for a"), - baz=Option(4, "baz"), - ) - - options = dict( - a_opts=dict( - foo=8) - ) - - defaults = options_b._get_defaults_yaml() - _merge_options_into_defaults(options, defaults) - assert defaults["a_opts"]["foo"] == 8 - assert defaults["a_opts"]["bar"] == 6 - assert defaults["baz"] == 4 From a8654d6651313b888da296b96ea1d6c3d4d52454 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 14 Dec 2022 14:21:58 +0000 Subject: [PATCH 04/31] fix whitespace --- ear/cmdline/render_file.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ear/cmdline/render_file.py b/ear/cmdline/render_file.py index 628ba9b9..b01cd2d5 100644 --- a/ear/cmdline/render_file.py +++ b/ear/cmdline/render_file.py @@ -245,5 +245,6 @@ def main(): with error_handler(logger, debug=args.debug, strict=args.strict): OfflineRenderDriver.from_args(args).run(args.input_file, args.output_file) + if __name__ == "__main__": main() From 2b9733dc3765d5dfa2704fa2a4ec5aabf66e5ecc Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 14 Dec 2022 14:24:04 +0000 Subject: [PATCH 05/31] fix whitespace --- ear/core/point_source.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ear/core/point_source.py b/ear/core/point_source.py index 874b992b..904a4055 100644 --- a/ear/core/point_source.py +++ b/ear/core/point_source.py @@ -465,6 +465,7 @@ def _check_screen_speakers(layout): az=channel.polar_position.azimuth, )) + def _configure_full(layout): layout = _set_screen_speaker_nominal_positions(layout) From ea7ba36694b31dea5443cc1a87133d294d0cec80 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 14 Dec 2022 14:34:39 +0000 Subject: [PATCH 06/31] fix comment --- ear/core/hoa.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ear/core/hoa.py b/ear/core/hoa.py index 9afe10a2..48ba890a 100644 --- a/ear/core/hoa.py +++ b/ear/core/hoa.py @@ -54,7 +54,7 @@ def norm_FuMa(n, abs_m): def sph_harm(n, m, az, el, norm=norm_SN3D): - """Spherical harmonic function Y_n^m(ax, el).""" + """Spherical harmonic function Y_n^m(az, el).""" n, m, az, el = np.broadcast_arrays(n, m, az, el) scale = np.ones_like(m, dtype=float) select = m > 0 From 84cdea7b6db976b5d33d59f0ddee33ac9fde1d7a Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Thu, 15 Dec 2022 18:03:49 +0000 Subject: [PATCH 07/31] fix ambiguous name warning --- ear/core/direct_speakers/panner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ear/core/direct_speakers/panner.py b/ear/core/direct_speakers/panner.py index bc55fd44..5f453ac3 100644 --- a/ear/core/direct_speakers/panner.py +++ b/ear/core/direct_speakers/panner.py @@ -75,7 +75,7 @@ def opposite_name(channel_name): new_rule = evolve(rule, speakerLabel=opposite_name(rule.speakerLabel), - gains=[(opposite_name(l), g) for l, g in rule.gains], + gains=[(opposite_name(name), gain) for name, gain in rule.gains], ) # don't add rules which would have the same effect From e227edea69f4e1bce61e1a8dd2b524166977d945 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Thu, 15 Dec 2022 17:59:16 +0000 Subject: [PATCH 08/31] fix indent --- ear/core/point_source.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ear/core/point_source.py b/ear/core/point_source.py index 904a4055..a1ef2a36 100644 --- a/ear/core/point_source.py +++ b/ear/core/point_source.py @@ -461,9 +461,8 @@ def _check_screen_speakers(layout): raise ValueError("channel {name} has azimuth {az}, which is not " "in the allowed ranges of 5 to 25 and 35 to 60 " "degrees.".format( - name=channel.name, - az=channel.polar_position.azimuth, - )) + name=channel.name, + az=channel.polar_position.azimuth)) def _configure_full(layout): From 8cd8663b3d246c5c23f2d8e369b3df2edbe1929e Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Thu, 15 Dec 2022 17:59:58 +0000 Subject: [PATCH 09/31] remove unused imports --- ear/core/select_items/select_items.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ear/core/select_items/select_items.py b/ear/core/select_items/select_items.py index d6c8c85a..0f451049 100644 --- a/ear/core/select_items/select_items.py +++ b/ear/core/select_items/select_items.py @@ -12,12 +12,12 @@ ) from .pack_allocation import allocate_packs, AllocationPack, AllocationChannel, AllocationTrack 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) + in_by_id, object_paths_from, pack_format_paths_from) from .validate import (validate_structure, validate_selected_audioTrackUID, possible_reference_errors, ) from ..metadata_input import (ExtraData, ADMPath, MetadataSourceIter, - RenderingItem, ObjectTypeMetadata, + ObjectTypeMetadata, ObjectRenderingItem, DirectSpeakersTypeMetadata, DirectSpeakersRenderingItem, HOATypeMetadata, HOARenderingItem, ImportanceData, TrackSpec, From 2a68322fd9d1f2f94b55d4e1c710848c65d613df Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 14 Dec 2022 14:23:03 +0000 Subject: [PATCH 10/31] move noqa labels to correct line --- ear/core/direct_speakers/panner.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ear/core/direct_speakers/panner.py b/ear/core/direct_speakers/panner.py index 5f453ac3..7756145f 100644 --- a/ear/core/direct_speakers/panner.py +++ b/ear/core/direct_speakers/panner.py @@ -299,8 +299,8 @@ def closest_channel_index(self, positions, position, candidates, tol): else: return None - @dispatch(DirectSpeakerPolarPosition, float) # noqa: F811 - def channels_within_bounds(self, position, tol): + @dispatch(DirectSpeakerPolarPosition, float) + def channels_within_bounds(self, position, tol): # noqa: F811 """Get a bit mask of channels within the bounds in position.""" def min_max_default(bound): @@ -320,8 +320,8 @@ def min_max_default(bound): (self.distances > dist_min - tol) & (self.distances < dist_max + tol) ) - @dispatch(DirectSpeakerCartesianPosition, float) # noqa: F811 - def channels_within_bounds(self, position, tol): + @dispatch(DirectSpeakerCartesianPosition, float) + def channels_within_bounds(self, position, tol): # noqa: F811 """Get a bit mask of channels within the bounds in position.""" bounds = [position.bounded_X, position.bounded_Y, position.bounded_Z] From 67f761fd1491d2396d565b8fd990f8a4a7b21d58 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 7 Dec 2022 17:39:11 +0000 Subject: [PATCH 11/31] support regular layouts with no defined allocentric positions --- ear/core/test/test_point_source.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ear/core/test/test_point_source.py b/ear/core/test/test_point_source.py index 5393a756..81390e6a 100644 --- a/ear/core/test/test_point_source.py +++ b/ear/core/test/test_point_source.py @@ -3,7 +3,7 @@ from .. import bs2051 from ..point_source import Triplet, VirtualNgon, StereoPanDownmix, PointSourcePanner, configure, AllocentricPanner from ..geom import cart, azimuth, PolarPosition -from ..layout import Speaker +from ..layout import LayoutStyle, Speaker import pytest @@ -394,6 +394,9 @@ def test_allocentric_point_source(): def test_all_layouts_allo(layout): """Basic tests of the allocentric panner on all layouts""" from ..allocentric import positions_for_layout + if layout.style != LayoutStyle.ITU: + pytest.skip("non-ITU layout") + spks = positions_for_layout(layout) a = AllocentricPanner(spks) From f124d5af1f1a738ef3c7e7ef119b1b4d659b3bef Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Tue, 13 Dec 2022 12:07:57 +0000 Subject: [PATCH 12/31] direct speakers: extract screen edge lock code --- ear/core/direct_speakers/panner.py | 23 +---------------------- ear/core/screen_edge_lock.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/ear/core/direct_speakers/panner.py b/ear/core/direct_speakers/panner.py index 7756145f..28cd82a8 100644 --- a/ear/core/direct_speakers/panner.py +++ b/ear/core/direct_speakers/panner.py @@ -353,27 +353,6 @@ def is_lfe_channel(self, type_metadata): return has_lfe_freq or has_lfe_name - @dispatch(DirectSpeakerPolarPosition) # noqa: F811 - def apply_screen_edge_lock(self, position): - az, el = self._screen_edge_lock_handler.handle_az_el(position.azimuth, - position.elevation, - position.screenEdgeLock) - - return evolve(position, - bounded_azimuth=evolve(position.bounded_azimuth, value=az), - bounded_elevation=evolve(position.bounded_elevation, value=el)) - - @dispatch(DirectSpeakerCartesianPosition) # noqa: F811 - def apply_screen_edge_lock(self, position): - X, Y, Z = self._screen_edge_lock_handler.handle_vector(position.as_cartesian_array(), - position.screenEdgeLock, - cartesian=True) - - return evolve(position, - bounded_X=evolve(position.bounded_X, value=X), - bounded_Y=evolve(position.bounded_Y, value=Y), - bounded_Z=evolve(position.bounded_Z, value=Z)) - def handle(self, type_metadata): tol = 1e-5 @@ -429,7 +408,7 @@ def handle(self, type_metadata): return self.pvs[idx] # shift the nominal speaker position to the screen edges if specified - shifted_position = self.apply_screen_edge_lock(block_format.position) + shifted_position = self._screen_edge_lock_handler.handle_ds_position(block_format.position) # otherwise, find the closest speaker with the correct type within the given bounds diff --git a/ear/core/screen_edge_lock.py b/ear/core/screen_edge_lock.py index 8896f5b1..5cc8b237 100644 --- a/ear/core/screen_edge_lock.py +++ b/ear/core/screen_edge_lock.py @@ -1,6 +1,9 @@ +from ..fileio.adm.elements import DirectSpeakerCartesianPosition, DirectSpeakerPolarPosition from .geom import azimuth, elevation, cart from .screen_common import PolarEdges, compensate_position from .objectbased.conversion import point_cart_to_polar, point_polar_to_cart +from attr import evolve +from functools import singledispatchmethod import numpy as np @@ -48,3 +51,29 @@ def handle_az_el(self, az, el, screen_edge_lock): return self.lock_to_screen_edge(az, el, screen_edge_lock) else: return az, el + + @singledispatchmethod + def handle_ds_position(self, position): + """apply screen edge lock to a DirectSpeakerPosition""" + raise NotImplementedError(f"cannot apply screen edge lock to {position}") + + @handle_ds_position.register(DirectSpeakerPolarPosition) + def _(self, position): + az, el = self.handle_az_el(position.azimuth, + position.elevation, + position.screenEdgeLock) + + return evolve(position, + bounded_azimuth=evolve(position.bounded_azimuth, value=az), + bounded_elevation=evolve(position.bounded_elevation, value=el)) + + @handle_ds_position.register(DirectSpeakerCartesianPosition) + def _(self, position): + X, Y, Z = self.handle_vector(position.as_cartesian_array(), + position.screenEdgeLock, + cartesian=True) + + return evolve(position, + bounded_X=evolve(position.bounded_X, value=X), + bounded_Y=evolve(position.bounded_Y, value=Y), + bounded_Z=evolve(position.bounded_Z, value=Z)) From 8d270f4992b501c782d3be5c794da2770057f450 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Thu, 15 Dec 2022 11:04:53 +0000 Subject: [PATCH 13/31] use multipledispatch for python 3.7 compatibility --- ear/core/screen_edge_lock.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/ear/core/screen_edge_lock.py b/ear/core/screen_edge_lock.py index 5cc8b237..227c6549 100644 --- a/ear/core/screen_edge_lock.py +++ b/ear/core/screen_edge_lock.py @@ -3,7 +3,7 @@ from .screen_common import PolarEdges, compensate_position from .objectbased.conversion import point_cart_to_polar, point_polar_to_cart from attr import evolve -from functools import singledispatchmethod +from multipledispatch import dispatch import numpy as np @@ -52,13 +52,8 @@ def handle_az_el(self, az, el, screen_edge_lock): else: return az, el - @singledispatchmethod - def handle_ds_position(self, position): - """apply screen edge lock to a DirectSpeakerPosition""" - raise NotImplementedError(f"cannot apply screen edge lock to {position}") - - @handle_ds_position.register(DirectSpeakerPolarPosition) - def _(self, position): + @dispatch(DirectSpeakerPolarPosition) + def handle_ds_position(self, position): # noqa: F811 az, el = self.handle_az_el(position.azimuth, position.elevation, position.screenEdgeLock) @@ -67,8 +62,8 @@ def _(self, position): bounded_azimuth=evolve(position.bounded_azimuth, value=az), bounded_elevation=evolve(position.bounded_elevation, value=el)) - @handle_ds_position.register(DirectSpeakerCartesianPosition) - def _(self, position): + @dispatch(DirectSpeakerCartesianPosition) + def handle_ds_position(self, position): # noqa: F811 X, Y, Z = self.handle_vector(position.as_cartesian_array(), position.screenEdgeLock, cartesian=True) From 7435371980874e0f9757c25a7e6fed8f25bbc710 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 14 Dec 2022 14:29:07 +0000 Subject: [PATCH 14/31] add num_channels to Layout layout-like objects may not have a use for a list of channels, so this is easier to generalise --- ear/core/layout.py | 5 +++++ ear/core/test/test_layout.py | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/ear/core/layout.py b/ear/core/layout.py index 175da7c8..91824dad 100644 --- a/ear/core/layout.py +++ b/ear/core/layout.py @@ -107,6 +107,11 @@ class Layout(object): default=default_screen) style = attrib(validator=instance_of(LayoutStyle), default=LayoutStyle.ITU) + @property + def num_channels(self): + """the number of channels in this layout""" + return len(self.channels) + @property def positions(self): """Channel positions as an (n, 3) numpy array.""" diff --git a/ear/core/test/test_layout.py b/ear/core/test/test_layout.py index b39d2b59..291e8216 100644 --- a/ear/core/test/test_layout.py +++ b/ear/core/test/test_layout.py @@ -139,6 +139,10 @@ def test_Layout_check_upmix_matrix(layout): assert errors == ["Speaker idx 1 used by multiple channels: ['M+030', 'M-030']"] +def test_Layout_num_channels(layout): + assert layout.num_channels == 2 + + def test_load_layout_info(): def run_test(yaml_obj, expected, func=load_real_layout): from six import StringIO From 6055fefa9a1e25d7d43bb8375427d85adb24fe10 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 14 Dec 2022 14:33:00 +0000 Subject: [PATCH 15/31] use layout.num_channels just the ones needed; converting all uses would be a lot of pointless changes --- ear/core/direct_speakers/renderer.py | 2 +- ear/core/renderer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ear/core/direct_speakers/renderer.py b/ear/core/direct_speakers/renderer.py index 2d1c8a1f..d3d4c139 100644 --- a/ear/core/direct_speakers/renderer.py +++ b/ear/core/direct_speakers/renderer.py @@ -45,7 +45,7 @@ class DirectSpeakersRenderer(object): def __init__(self, layout, **options): self._panner = DirectSpeakersPanner(layout, **options) - self._nchannels = len(layout.channels) + self._nchannels = layout.num_channels # tuples of a track spec processor and a BlockProcessingChannel to # apply to the samples it produces. diff --git a/ear/core/renderer.py b/ear/core/renderer.py index 2c020b76..998aea82 100644 --- a/ear/core/renderer.py +++ b/ear/core/renderer.py @@ -18,7 +18,7 @@ class Renderer(object): """ def __init__(self, layout, object_renderer_opts={}, direct_speakers_opts={}, hoa_renderer_opts={}): - self.block_aligner = BlockAligner(len(layout.channels)) + self.block_aligner = BlockAligner(layout.num_channels) self._object_renderer = ObjectRenderer(layout, **object_renderer_opts) self._direct_speakers_renderer = DirectSpeakersRenderer(layout, **direct_speakers_opts) From 609b25abb21fd33e0f0b88570e8304427ba32458 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 14 Dec 2022 14:36:27 +0000 Subject: [PATCH 16/31] direct speakers: extract speaker label handling for re-use --- ear/core/direct_speakers/panner.py | 115 ++++++++++++++++------------- 1 file changed, 65 insertions(+), 50 deletions(-) diff --git a/ear/core/direct_speakers/panner.py b/ear/core/direct_speakers/panner.py index 28cd82a8..625b9f0f 100644 --- a/ear/core/direct_speakers/panner.py +++ b/ear/core/direct_speakers/panner.py @@ -216,29 +216,14 @@ def opposite_name(channel_name): } -class DirectSpeakersPanner(object): - - def __init__(self, layout, point_source_opts={}, additional_substitutions={}): - self.layout = layout - self.psp = point_source.configure(layout.without_lfe, **point_source_opts) - - self.n_channels = len(layout.channels) - self.channel_names = layout.channel_names - - self.azimuths = np.array([channel.polar_nominal_position.azimuth for channel in layout.channels]) - self.elevations = np.array([channel.polar_nominal_position.elevation for channel in layout.channels]) - self.distances = np.array([channel.polar_nominal_position.distance for channel in layout.channels]) +class SpeakerLabelHandler(object): + """utilities for things related to speakerLabels - self.positions = layout.nominal_positions - self.is_lfe = layout.is_lfe - - self.allo_positions = allocentric.positions_for_layout_if_defined(layout) - self.allo_psp = point_source.configure_allocentric_if_defined(layout.without_lfe) - - self._screen_edge_lock_handler = ScreenEdgeLockHandler(self.layout.screen, layout) - - self.pvs = np.eye(self.n_channels) + this is a bit random, but useful for rendering to formats other than + loudspeakers + """ + def __init__(self, additional_substitutions={}): self.substitutions = { "LFE": "LFE1", "LFEL": "LFE1", @@ -263,6 +248,62 @@ def nominal_speaker_label(self, label): return label + def is_lfe_channel(self, type_metadata): + """Determine if type_metadata is an LFE channel, issuing a warning if + there's a discrepancy between the speakerLabel and the frequency + element, or if a speakerLabel contains "LFE" but is not treated as one + by the standard + """ + has_lfe_freq = is_lfe(type_metadata.extra_data.channel_frequency) + + has_lfe_name = False + for label in type_metadata.block_format.speakerLabel: + nominal_label = self.nominal_speaker_label(label) + + if nominal_label in ("LFE1", "LFE2"): + has_lfe_name = True + + if has_lfe_freq != has_lfe_name and type_metadata.block_format.speakerLabel: + warnings.warn("LFE indication from frequency element does not match speakerLabel.") + + tm_is_lfe = has_lfe_freq or has_lfe_name + + if not tm_is_lfe and any("LFE" in label.upper() for label in type_metadata.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=type_metadata.block_format + ) + ) + + return tm_is_lfe + + +class DirectSpeakersPanner(object): + + def __init__(self, layout, point_source_opts={}, additional_substitutions={}): + self.layout = layout + self.psp = point_source.configure(layout.without_lfe, **point_source_opts) + + self.n_channels = len(layout.channels) + self.channel_names = layout.channel_names + + self.azimuths = np.array([channel.polar_nominal_position.azimuth for channel in layout.channels]) + self.elevations = np.array([channel.polar_nominal_position.elevation for channel in layout.channels]) + self.distances = np.array([channel.polar_nominal_position.distance for channel in layout.channels]) + + self.positions = layout.nominal_positions + self.is_lfe = layout.is_lfe + + self.allo_positions = allocentric.positions_for_layout_if_defined(layout) + self.allo_psp = point_source.configure_allocentric_if_defined(layout.without_lfe) + + self._screen_edge_lock_handler = ScreenEdgeLockHandler(self.layout.screen, layout) + + self.pvs = np.eye(self.n_channels) + + self.label_handler = SpeakerLabelHandler(additional_substitutions) + def closest_channel_index(self, positions, position, candidates, tol): """Get the index of the candidate speaker closest to a given position. @@ -335,24 +376,6 @@ def channels_within_bounds(self, position, tol): # noqa: F811 np.all(self.allo_positions - tol <= bounds_max, axis=1) ) - def is_lfe_channel(self, type_metadata): - """Determine if type_metadata is an LFE channel, issuing a warning if - there's a discrepancy between the speakerLabel and the frequency - element.""" - has_lfe_freq = is_lfe(type_metadata.extra_data.channel_frequency) - - has_lfe_name = False - for label in type_metadata.block_format.speakerLabel: - nominal_label = self.nominal_speaker_label(label) - - if nominal_label in ("LFE1", "LFE2"): - has_lfe_name = True - - if has_lfe_freq != has_lfe_name and type_metadata.block_format.speakerLabel: - warnings.warn("LFE indication from frequency element does not match speakerLabel.") - - return has_lfe_freq or has_lfe_name - def handle(self, type_metadata): tol = 1e-5 @@ -371,22 +394,14 @@ def handle(self, type_metadata): else: assert False, "unexpected type" - 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 - ) - ) + is_lfe_channel = self.label_handler.is_lfe_channel(type_metadata) if type_metadata.audioPackFormats is not None: pack = type_metadata.audioPackFormats[-1] if pack.is_common_definition and pack.id in itu_packs: itu_layout_name = itu_packs[pack.id] label = block_format.speakerLabel[0] - nominal_label = self.nominal_speaker_label(label) + nominal_label = self.label_handler.nominal_speaker_label(label) for rule in rules: gains = rule.apply(itu_layout_name, nominal_label, self.layout) @@ -401,7 +416,7 @@ def handle(self, type_metadata): # speakerLabel values have higher priority for label in block_format.speakerLabel: - nominal_label = self.nominal_speaker_label(label) + nominal_label = self.label_handler.nominal_speaker_label(label) if nominal_label in self.channel_names: idx = self.channel_names.index(nominal_label) if is_lfe_channel == self.is_lfe[idx]: From 36cddb9a337dd936924e4c38e3ca2f81f1622056 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 14 Dec 2022 14:39:51 +0000 Subject: [PATCH 17/31] direct speakers: allow overloading panner for other formats --- ear/core/direct_speakers/panner.py | 12 ++++++++++++ ear/core/direct_speakers/renderer.py | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ear/core/direct_speakers/panner.py b/ear/core/direct_speakers/panner.py index 625b9f0f..83fdf1ec 100644 --- a/ear/core/direct_speakers/panner.py +++ b/ear/core/direct_speakers/panner.py @@ -1,4 +1,5 @@ from attr import attrs, attrib, evolve +from functools import singledispatch from multipledispatch import dispatch import numpy as np import re @@ -6,6 +7,7 @@ from ..geom import inside_angle_range from .. import point_source from .. import allocentric +from ..layout import Layout from ..renderer_common import is_lfe from ..screen_edge_lock import ScreenEdgeLockHandler from ...fileio.adm.elements import DirectSpeakerCartesianPosition, DirectSpeakerPolarPosition @@ -463,3 +465,13 @@ def handle(self, type_metadata): pv = np.zeros(self.n_channels) pv[~self.is_lfe] = psp.handle(position) return pv + + +@singledispatch +def build_direct_speakers_panner(layout, **options): + return None + + +@build_direct_speakers_panner.register(Layout) +def _build_direct_speakers_panner_speakers(layout, **options): + return DirectSpeakersPanner(layout, **options) diff --git a/ear/core/direct_speakers/renderer.py b/ear/core/direct_speakers/renderer.py index d3d4c139..7e749ebf 100644 --- a/ear/core/direct_speakers/renderer.py +++ b/ear/core/direct_speakers/renderer.py @@ -1,5 +1,5 @@ import numpy as np -from .panner import DirectSpeakersPanner +from .panner import build_direct_speakers_panner from ..renderer_common import BlockProcessingChannel, InterpretTimingMetadata, FixedGains from ..track_processor import TrackProcessor @@ -44,7 +44,7 @@ class DirectSpeakersRenderer(object): """ def __init__(self, layout, **options): - self._panner = DirectSpeakersPanner(layout, **options) + self._panner = build_direct_speakers_panner(layout, **options) self._nchannels = layout.num_channels # tuples of a track spec processor and a BlockProcessingChannel to From 238234864f597873e86256deb46179699927eb7e Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 14 Dec 2022 14:40:35 +0000 Subject: [PATCH 18/31] objects: allow overloading renderer for other formats --- ear/core/objectbased/renderer.py | 18 ++++++++++++++++++ ear/core/renderer.py | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/ear/core/objectbased/renderer.py b/ear/core/objectbased/renderer.py index 000f3c1e..842a31fc 100644 --- a/ear/core/objectbased/renderer.py +++ b/ear/core/objectbased/renderer.py @@ -1,10 +1,12 @@ import numpy as np import math from fractions import Fraction +from functools import singledispatch from ..convolver import OverlapSaveConvolver, VariableBlockSizeAdapter from ..delay import Delay from .gain_calc import GainCalc from . import decorrelate +from ..layout import Layout from ..renderer_common import BlockProcessingChannel, InterpretTimingMetadata, InterpGains, FixedGains from ..track_processor import TrackProcessor @@ -148,3 +150,19 @@ def render(self, sample_rate, start_sample, input_samples): direct_out = self.delays.process(interpolated[:, :self._nchannels]) diffuse_out = self.decorrelators_vbs.process(interpolated[:, self._nchannels:]) return direct_out + diffuse_out + + +@singledispatch +def build_objects_renderer(_layout, **_options): + """build an objects renderer (e.g. ObjectRenderer) given a loudspeaker + layout or other output format; this can be overridden from other modules to + add support for different formats + + returns None if no handler renderer is defined for this format + """ + return None + + +@build_objects_renderer.register(Layout) +def _build_objects_renderer_speakers(layout, **options): + return ObjectRenderer(layout, **options) diff --git a/ear/core/renderer.py b/ear/core/renderer.py index 998aea82..36413b8e 100644 --- a/ear/core/renderer.py +++ b/ear/core/renderer.py @@ -1,5 +1,5 @@ import numpy as np -from .objectbased.renderer import ObjectRenderer +from .objectbased.renderer import build_objects_renderer from .direct_speakers.renderer import DirectSpeakersRenderer from .scenebased.renderer import HOARenderer from .metadata_input import ObjectRenderingItem, DirectSpeakersRenderingItem, HOARenderingItem @@ -20,7 +20,7 @@ class Renderer(object): def __init__(self, layout, object_renderer_opts={}, direct_speakers_opts={}, hoa_renderer_opts={}): self.block_aligner = BlockAligner(layout.num_channels) - self._object_renderer = ObjectRenderer(layout, **object_renderer_opts) + self._object_renderer = build_objects_renderer(layout, **object_renderer_opts) self._direct_speakers_renderer = DirectSpeakersRenderer(layout, **direct_speakers_opts) self._hoa_renderer = HOARenderer(layout, **hoa_renderer_opts) From e983d0884d4bb600e7727daf5f4de3cfad9ad183 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 14 Dec 2022 14:41:44 +0000 Subject: [PATCH 19/31] HOA: allow overloading decoder design for other formats --- ear/core/scenebased/design.py | 12 ++++++++++++ ear/core/scenebased/renderer.py | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ear/core/scenebased/design.py b/ear/core/scenebased/design.py index 7bf1bf8e..1a4efcef 100644 --- a/ear/core/scenebased/design.py +++ b/ear/core/scenebased/design.py @@ -1,6 +1,8 @@ +from functools import singledispatch import numpy as np import warnings from .. import hoa +from ..layout import Layout from .. import point_source @@ -85,3 +87,13 @@ def design(self, type_metadata): decoder /= np.sqrt(np.mean(np.sum(np.dot(decoder, K_v) ** 2, axis=0))) return decoder + + +@singledispatch +def build_hoa_decoder_design(layout, **options): + return None + + +@build_hoa_decoder_design.register(Layout) +def _build_hoa_decoder_design_speakers(layout, **options): + return HOADecoderDesign(layout, **options) diff --git a/ear/core/scenebased/renderer.py b/ear/core/scenebased/renderer.py index 510722bf..d6ef69bb 100644 --- a/ear/core/scenebased/renderer.py +++ b/ear/core/scenebased/renderer.py @@ -1,6 +1,6 @@ import numpy as np from attr import attrs, attrib -from .design import HOADecoderDesign +from .design import build_hoa_decoder_design from ..renderer_common import BlockProcessingChannel, InterpretTimingMetadata, ProcessingBlock from ..track_processor import MultiTrackProcessor @@ -65,7 +65,7 @@ class HOARenderer(object): """ def __init__(self, layout, design_opts={}): - self._decoder_design = HOADecoderDesign(layout.without_lfe, **design_opts) + self._decoder_design = build_hoa_decoder_design(layout.without_lfe, **design_opts) self._output_channels = ~layout.is_lfe self.block_processing_channels = [] From f9795cbdf716b2883359519493dc8183a4fbc21b Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 14 Dec 2022 14:48:14 +0000 Subject: [PATCH 20/31] add renderer overloads for HOA output --- ear/cmdline/render_file.py | 6 ++ ear/core/data/N003_M6_Octa.dat | 8 ++ ear/core/data/N005_M12_Ico.dat | 14 +++ ear/core/data/N007_M24_Octa.dat | 26 +++++ ear/core/data/N009_M48_Octa.dat | 50 ++++++++++ ear/core/data/N011_M70_C5.dat | 72 ++++++++++++++ ear/core/data/N013_M94_Inv.dat | 96 +++++++++++++++++++ ear/core/data/README.md | 9 +- ear/core/direct_speakers/panner_hoa.py | 28 ++++++ ear/core/hoa_adapter.py | 93 ++++++++++++++++++ ear/core/hoa_overloads.py | 6 ++ ear/core/objectbased/gain_calc_hoa.py | 126 +++++++++++++++++++++++++ ear/core/objectbased/renderer_hoa.py | 105 +++++++++++++++++++++ ear/core/quadrature.py | 26 +++++ ear/core/scenebased/design_hoa.py | 65 +++++++++++++ 15 files changed, 727 insertions(+), 3 deletions(-) create mode 100644 ear/core/data/N003_M6_Octa.dat create mode 100644 ear/core/data/N005_M12_Ico.dat create mode 100644 ear/core/data/N007_M24_Octa.dat create mode 100644 ear/core/data/N009_M48_Octa.dat create mode 100644 ear/core/data/N011_M70_C5.dat create mode 100644 ear/core/data/N013_M94_Inv.dat create mode 100644 ear/core/direct_speakers/panner_hoa.py create mode 100644 ear/core/hoa_adapter.py create mode 100644 ear/core/hoa_overloads.py create mode 100644 ear/core/objectbased/gain_calc_hoa.py create mode 100644 ear/core/objectbased/renderer_hoa.py create mode 100644 ear/core/quadrature.py create mode 100644 ear/core/scenebased/design_hoa.py diff --git a/ear/cmdline/render_file.py b/ear/cmdline/render_file.py index b01cd2d5..d7c0d4d2 100644 --- a/ear/cmdline/render_file.py +++ b/ear/cmdline/render_file.py @@ -4,6 +4,8 @@ import scipy.sparse from itertools import chain from ..core import bs2051, layout, Renderer +from ..core import hoa_overloads # noqa: F401 +from ..core.hoa_adapter import HOAFormat from ..core.monitor import PeakMonitor from ..core.metadata_processing import preprocess_rendering_items, convert_objects_to_cartesian, convert_objects_to_polar from ..core.select_items import select_rendering_items @@ -83,6 +85,10 @@ def load_output_layout(self): upmix (sparse array or None): optional matrix to apply after rendering n_channels (int): number of channels required in output file """ + spkr_layout = HOAFormat.parse_or_none(self.target_layout) + if spkr_layout is not None: + return spkr_layout, None, spkr_layout.num_channels + spkr_layout = bs2051.get_layout(self.target_layout) if self.speakers_file is not None: diff --git a/ear/core/data/N003_M6_Octa.dat b/ear/core/data/N003_M6_Octa.dat new file mode 100644 index 00000000..d56d73ee --- /dev/null +++ b/ear/core/data/N003_M6_Octa.dat @@ -0,0 +1,8 @@ +3 +6 +1 0 0 +0 1 0 +0 0 1 +-1 0 0 +0 -1 0 +0 0 -1 \ No newline at end of file diff --git a/ear/core/data/N005_M12_Ico.dat b/ear/core/data/N005_M12_Ico.dat new file mode 100644 index 00000000..2a118e18 --- /dev/null +++ b/ear/core/data/N005_M12_Ico.dat @@ -0,0 +1,14 @@ +3 +12 +0.8506508083520399 0.5257311121191336 0 +0.8506508083520399 -0.5257311121191336 0 +-0.8506508083520399 0.5257311121191336 0 +-0.8506508083520399 -0.5257311121191336 0 +0 0.8506508083520399 0.5257311121191336 +0 0.8506508083520399 -0.5257311121191336 +0 -0.8506508083520399 0.5257311121191336 +0 -0.8506508083520399 -0.5257311121191336 +0.5257311121191336 0 0.8506508083520399 +-0.5257311121191336 0 0.8506508083520399 +0.5257311121191336 0 -0.8506508083520399 +-0.5257311121191336 0 -0.8506508083520399 \ No newline at end of file diff --git a/ear/core/data/N007_M24_Octa.dat b/ear/core/data/N007_M24_Octa.dat new file mode 100644 index 00000000..a0033e50 --- /dev/null +++ b/ear/core/data/N007_M24_Octa.dat @@ -0,0 +1,26 @@ +3 +24 +0.86624681811 0.42251865376 0.26663540152 +0.86624681811 0.26663540152 -0.42251865376 +-0.26663540152 0.42251865376 0.86624681811 +0.42251865376 -0.86624681811 0.26663540152 +0.86624681811 -0.26663540152 0.42251865376 +0.26663540152 0.42251865376 -0.86624681811 +-0.42251865376 0.86624681811 0.26663540152 +0.42251865376 0.26663540152 0.86624681811 +0.42251865376 -0.26663540152 -0.86624681811 +-0.42251865376 -0.26663540152 0.86624681811 +-0.42251865376 0.26663540152 -0.86624681811 +0.26663540152 0.86624681811 0.42251865376 +-0.26663540152 0.86624681811 -0.42251865376 +0.26663540152 -0.86624681811 -0.42251865376 +-0.26663540152 -0.86624681811 0.42251865376 +0.86624681811 -0.42251865376 -0.26663540152 +-0.86624681811 0.42251865376 -0.26663540152 +-0.86624681811 -0.42251865376 0.26663540152 +0.42251865376 0.86624681811 -0.26663540152 +-0.42251865376 -0.86624681811 -0.26663540152 +-0.86624681811 0.26663540152 0.42251865376 +-0.86624681811 -0.26663540152 -0.42251865376 +0.26663540152 -0.42251865376 0.86624681811 +-0.26663540152 -0.42251865376 -0.86624681811 diff --git a/ear/core/data/N009_M48_Octa.dat b/ear/core/data/N009_M48_Octa.dat new file mode 100644 index 00000000..99b994dc --- /dev/null +++ b/ear/core/data/N009_M48_Octa.dat @@ -0,0 +1,50 @@ +3 +48 +0.70684169771 0.63974009862 0.30184005797 +0.35354218892 0.9333646932 0.061953774232 +0.70684169771 0.30184005797 -0.63974009862 +0.35354218892 0.061953774232 -0.9333646932 +-0.30184005797 0.63974009862 0.70684169771 +-0.061953774232 0.9333646932 0.35354218892 +0.63974009862 -0.70684169771 0.30184005797 +0.9333646932 -0.35354218892 0.061953774232 +0.70684169771 -0.30184005797 0.63974009862 +0.35354218892 -0.061953774232 0.9333646932 +0.30184005797 0.63974009862 -0.70684169771 +0.061953774232 0.9333646932 -0.35354218892 +-0.63974009862 0.70684169771 0.30184005797 +-0.9333646932 0.35354218892 0.061953774232 +0.63974009862 0.30184005797 0.70684169771 +0.9333646932 0.061953774232 0.35354218892 +0.63974009862 -0.30184005797 -0.70684169771 +0.9333646932 -0.061953774232 -0.35354218892 +-0.63974009862 -0.30184005797 0.70684169771 +-0.9333646932 -0.061953774232 0.35354218892 +-0.63974009862 0.30184005797 -0.70684169771 +-0.9333646932 0.061953774232 -0.35354218892 +0.30184005797 0.70684169771 0.63974009862 +0.061953774232 0.35354218892 0.9333646932 +-0.30184005797 0.70684169771 -0.63974009862 +-0.061953774232 0.35354218892 -0.9333646932 +0.30184005797 -0.70684169771 -0.63974009862 +0.061953774232 -0.35354218892 -0.9333646932 +-0.30184005797 -0.70684169771 0.63974009862 +-0.061953774232 -0.35354218892 0.9333646932 +0.70684169771 -0.63974009862 -0.30184005797 +0.35354218892 -0.9333646932 -0.061953774232 +-0.70684169771 0.63974009862 -0.30184005797 +-0.35354218892 0.9333646932 -0.061953774232 +-0.70684169771 -0.63974009862 0.30184005797 +-0.35354218892 -0.9333646932 0.061953774232 +0.63974009862 0.70684169771 -0.30184005797 +0.9333646932 0.35354218892 -0.061953774232 +-0.63974009862 -0.70684169771 -0.30184005797 +-0.9333646932 -0.35354218892 -0.061953774232 +-0.70684169771 0.30184005797 0.63974009862 +-0.35354218892 0.061953774232 0.9333646932 +-0.70684169771 -0.30184005797 -0.63974009862 +-0.35354218892 -0.061953774232 -0.9333646932 +0.30184005797 -0.63974009862 0.70684169771 +0.061953774232 -0.9333646932 0.35354218892 +-0.30184005797 -0.63974009862 -0.70684169771 +-0.061953774232 -0.9333646932 -0.35354218892 diff --git a/ear/core/data/N011_M70_C5.dat b/ear/core/data/N011_M70_C5.dat new file mode 100644 index 00000000..432e415c --- /dev/null +++ b/ear/core/data/N011_M70_C5.dat @@ -0,0 +1,72 @@ +3 +70 +0.34697401547 0.92871517263 0.13075611159 +0.30826763892 0.86359439267 -0.39896840445 +0.19846719975 -0.64131193892 0.74116784038 +0.0055580704907 0.98569909422 -0.16842328672 +-0.53557664796 0.83430239928 -0.13075611159 +0.73865905916 -0.65269931117 0.16842328672 +0.87676666988 0.1789933757 0.44636484857 +0.84933311523 -0.34562620217 0.39896840445 +0.61802851007 -0.2621278183 -0.74116784038 +-0.18511501171 -0.275682451 -0.94325586065 +-0.71379207496 -0.010481253665 0.70027924219 +0.082409348656 0.70909638429 -0.70027924219 +-0.32890958018 0.045682265894 0.94325586065 +0.71771459795 0.5344568999 -0.44636484857 +0.9904814841 -0.043003127119 0.13075611159 +0.91658701382 -0.026314603177 -0.39896840445 +-0.54859416094 -0.38692981141 0.74116784038 +0.9391730849 0.2993117323 -0.16842328672 +0.62796644739 0.76717728084 -0.13075611159 +-0.39249573074 -0.90420169091 0.16842328672 +0.44116861743 -0.77854265969 0.44636484857 +-0.066251685284 -0.91456816391 0.39896840445 +-0.058317057073 -0.66878199231 -0.74116784038 +-0.31939327598 0.090864275742 -0.94325586065 +-0.23054214621 0.67561771866 0.70027924219 +0.69985662619 0.14074688535 -0.70027924219 +-0.058192233229 0.326928196 0.94325586065 +0.73008472521 -0.51743088039 -0.44636484857 +0.26517720693 -0.95529256681 0.13075611159 +0.25821428927 -0.87985771183 -0.39896840445 +-0.53751703724 0.40217616421 0.74116784038 +0.5748828173 -0.80071427043 -0.16842328672 +0.92368125624 -0.36016076432 -0.13075611159 +-0.9812347612 0.093871933508 0.16842328672 +-0.60410946954 -0.66015920108 0.44636484857 +-0.89027890855 -0.21960800816 0.39896840445 +-0.65407043346 -0.15120218401 -0.74116784038 +-0.012280888625 0.33183966177 -0.94325586065 +0.57130919276 0.4280359672 0.70027924219 +0.35012583358 -0.62211002533 -0.70027924219 +0.29294480216 0.15637047111 0.94325586065 +-0.26649742311 -0.85424677081 -0.44636484857 +-0.82659295717 -0.54740014837 0.13075611159 +-0.75700180667 -0.517467368 -0.39896840445 +0.2163903624 0.63548835036 0.74116784038 +-0.58387596426 -0.7941803667 -0.16842328672 +-0.057100036259 -0.98976887461 -0.13075611159 +-0.21394070262 0.96221773641 0.16842328672 +-0.81452880253 0.37054183544 0.44636484857 +-0.48397093966 0.77884295067 0.39896840445 +-0.34592070184 0.57533390341 -0.74116784038 +0.3118032694 0.11422391405 -0.94325586065 +0.58363064542 -0.41107694253 0.70027924219 +-0.4834669607 -0.52523202575 -0.70027924219 +0.23924207779 -0.23028593001 0.94325586065 +-0.8947891906 -0.01052265875 -0.44636484857 +-0.77603974933 0.61698066967 0.13075611159 +-0.72606713534 0.56004529034 -0.39896840445 +0.67125363604 -0.0094227642326 0.74116784038 +-0.93573800843 0.30988381061 -0.16842328672 +-0.9589710194 -0.25155004119 -0.13075611159 +0.8490121354 0.50081133217 0.16842328672 +0.10070298476 0.88916664964 0.44636484857 +0.59116841827 0.70095942357 0.39896840445 +0.44027968231 0.5067780912 -0.74116784038 +0.20498590692 -0.26124540056 -0.94325586065 +-0.21060561702 -0.68209548967 0.70027924219 +-0.64892484773 0.29749878144 -0.70027924219 +-0.14508506654 -0.29869500299 0.94325586065 +-0.28651270945 0.84774341005 -0.44636484857 diff --git a/ear/core/data/N013_M94_Inv.dat b/ear/core/data/N013_M94_Inv.dat new file mode 100644 index 00000000..d1741654 --- /dev/null +++ b/ear/core/data/N013_M94_Inv.dat @@ -0,0 +1,96 @@ +3 +94 + 0 0 1 + 0 0 -1 +0.90358266073 0.31001584946 0.2956831891 +0.27205350245 0.34122479739 0.89975137091 +-0.89529983598 -0.012610972536 -0.44528548939 +0.26445259993 -0.77933688788 -0.56806587434 +0.43894956759 -0.34158306995 0.83105010886 +0.26634151334 0.88743464306 -0.37619403579 +-0.81969971111 -0.55879654137 0.12585232996 +0.41878241801 0.61165538353 -0.67119220657 +-0.55856136712 -0.79338207212 0.24197951732 +0.97483086114 -0.20783799137 0.080673177117 +-0.086784973076 -0.8191723863 -0.56694353331 +-0.5820111547 0.72313215495 0.37194475702 +-0.081787933469 -0.75049241994 0.65579864407 +-0.4631554094 -0.11166755025 0.87921409507 +0.92771448215 -0.27035918355 -0.25739415589 +-0.86159634658 0.37860838095 -0.3380938175 +-0.20955716157 0.64098201137 -0.73839546121 +0.59517740918 -0.21952399041 -0.7730285048 +0.68855338622 -0.45885662449 -0.56155572517 +0.7170376672 -0.12185463063 0.68630054117 +0.15050195477 -0.92438144236 0.35052547788 +-0.92389877985 -0.24430981897 0.29448897593 +0.40598767751 0.91240652107 0.051849262548 +0.064339972997 0.96333630215 0.260467919 +-0.68068022097 -0.47360457257 -0.55890352086 +0.4505293297 -0.89000586376 0.07009197924 +0.48492970933 0.80289395461 0.34670516964 +0.71324323816 0.21598573096 -0.66680900357 +-0.025469278342 -0.99640136296 0.080843303713 +0.3618098015 -0.010200555555 0.9321961254 +-0.99045389913 -0.13776245782 0.0047517268343 +-0.71751057737 0.67721220183 -0.16297915527 +0.26703347585 0.3824992502 -0.88452667929 +-0.015107452646 -0.51947915579 -0.85434956053 +-0.86237250536 0.068182883554 0.50166199416 +-0.22770049205 0.19460191187 0.95408730304 +0.61911414596 0.22839985808 0.75135289918 +0.63740045321 -0.51226586885 0.57559042892 +-0.37519412572 -0.64816504176 -0.66265484732 +-0.8003485757 0.58426970185 0.13442869067 +0.31195345167 -0.92829833165 -0.20235427214 +0.43865342261 -0.77020270456 0.46300212604 +-0.7579804929 -0.62691890536 -0.18010624219 +-0.058344307615 0.35791730653 -0.9319287223 +0.68738290323 0.52298205603 -0.50397868349 +0.33724668345 -0.52493863322 -0.78147559517 +-0.90358266073 -0.31001584946 -0.2956831891 +-0.27205350245 -0.34122479739 -0.89975137091 +0.89529983598 0.012610972536 0.44528548939 +-0.26445259993 0.77933688788 0.56806587434 +-0.43894956759 0.34158306995 -0.83105010886 +-0.26634151334 -0.88743464306 0.37619403579 +0.81969971111 0.55879654137 -0.12585232996 +-0.41878241801 -0.61165538353 0.67119220657 +0.55856136712 0.79338207212 -0.24197951732 +-0.97483086114 0.20783799137 -0.080673177117 +0.086784973076 0.8191723863 0.56694353331 +0.5820111547 -0.72313215495 -0.37194475702 +0.081787933469 0.75049241994 -0.65579864407 +0.4631554094 0.11166755025 -0.87921409507 +-0.92771448215 0.27035918355 0.25739415589 +0.86159634658 -0.37860838095 0.3380938175 +0.20955716157 -0.64098201137 0.73839546121 +-0.59517740918 0.21952399041 0.7730285048 +-0.68855338622 0.45885662449 0.56155572517 +-0.7170376672 0.12185463063 -0.68630054117 +-0.15050195477 0.92438144236 -0.35052547788 +0.92389877985 0.24430981897 -0.29448897593 +-0.40598767751 -0.91240652107 -0.051849262548 +-0.064339972997 -0.96333630215 -0.260467919 +0.68068022097 0.47360457257 0.55890352086 +-0.4505293297 0.89000586376 -0.07009197924 +-0.48492970933 -0.80289395461 -0.34670516964 +-0.71324323816 -0.21598573096 0.66680900357 +0.025469278342 0.99640136296 -0.080843303713 +-0.3618098015 0.010200555555 -0.9321961254 +0.99045389913 0.13776245782 -0.0047517268343 +0.71751057737 -0.67721220183 0.16297915527 +-0.26703347585 -0.3824992502 0.88452667929 +0.015107452646 0.51947915579 0.85434956053 +0.86237250536 -0.068182883554 -0.50166199416 +0.22770049205 -0.19460191187 -0.95408730304 +-0.61911414596 -0.22839985808 -0.75135289918 +-0.63740045321 0.51226586885 -0.57559042892 +0.37519412572 0.64816504176 0.66265484732 +0.8003485757 -0.58426970185 -0.13442869067 +-0.31195345167 0.92829833165 0.20235427214 +-0.43865342261 0.77020270456 -0.46300212604 +0.7579804929 0.62691890536 0.18010624219 +0.058344307615 -0.35791730653 0.9319287223 +-0.68738290323 -0.52298205603 0.50397868349 +-0.33724668345 0.52493863322 0.78147559517 diff --git a/ear/core/data/README.md b/ear/core/data/README.md index 0699fdf7..dbc83f0c 100644 --- a/ear/core/data/README.md +++ b/ear/core/data/README.md @@ -2,7 +2,12 @@ Loudspeaker layouts derived from ITU-R BS.2051-1. -# Design_5200_100_random.dat +# t-designs (.dat files) + +Obtained from http://homepage.univie.ac.at/manuel.graef/quadrature.php +and https://www-user.tu-chemnitz.de/~potts/workgroup/graef/quadrature/index.php.en + +## Design_5200_100_random.dat Approximate spherical t-design for t=100, as in [0]. @@ -10,8 +15,6 @@ Approximate spherical t-design for t=100, as in [0]. spherical coordinates (phi,theta) in [0,2pi] x [0,pi) of the quadrature points p = ( sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta))." -Obtained from http://homepage.univie.ac.at/manuel.graef/quadrature.php - [0] M. Graf and D. Potts, β€œOn the computation of spherical designs by a new optimization approach based on fast spherical Fourier transforms,” Numerische Mathematik, vol. 119, no. 4, pp. 699–724, Dec. 2011. diff --git a/ear/core/direct_speakers/panner_hoa.py b/ear/core/direct_speakers/panner_hoa.py new file mode 100644 index 00000000..c92036d3 --- /dev/null +++ b/ear/core/direct_speakers/panner_hoa.py @@ -0,0 +1,28 @@ +import numpy as np +import warnings +from ..hoa_adapter import HOAFormat, HOAPointSourceAdapter +from ..screen_edge_lock import ScreenEdgeLockHandler +from .panner import SpeakerLabelHandler, build_direct_speakers_panner + + +class DirectSpeakersPannerHOA(object): + def __init__(self, fmt, additional_substitutions={}): + self._panner = HOAPointSourceAdapter.build(fmt) + self._label_handler = SpeakerLabelHandler(additional_substitutions) + self._screen_edge_lock_handler = ScreenEdgeLockHandler(fmt.screen, fmt) + + def handle(self, type_metadata): + if self._label_handler.is_lfe_channel(type_metadata): + warnings.warn("discarding DirectSpeakers LFE channel") + return np.zeros(self._panner.num_channels) + + position = self._screen_edge_lock_handler.handle_ds_position( + type_metadata.block_format.position + ) + + return self._panner.handle(position.as_cartesian_array()) + + +@build_direct_speakers_panner.register(HOAFormat) +def _build_direct_speakers_panner_hoa(layout, **options): + return DirectSpeakersPannerHOA(layout, **options) diff --git a/ear/core/hoa_adapter.py b/ear/core/hoa_adapter.py new file mode 100644 index 00000000..aa52497d --- /dev/null +++ b/ear/core/hoa_adapter.py @@ -0,0 +1,93 @@ +import numpy as np +import re +from ..common import CartesianScreen, PolarScreen, default_screen +from . import hoa +from attr import attrib, attrs +from attr.validators import instance_of, optional + +HOA_FORMAT_RE = re.compile("hoa_([^_]+)_([^_]+)_([0-9]+)") +AMBIX_RE = re.compile("ambix_([^_]+)") + + +@attrs +class HOAFormat: + max_order = attrib() + normalization = attrib(default="SN3D") + channel_order = attrib(default="ACN") + + screen = attrib( + validator=optional(instance_of((CartesianScreen, PolarScreen))), + default=default_screen, + ) + + @property + def num_channels(self): + return (self.max_order + 1) ** 2 + + @property + def without_lfe(self): + return self + + @property + def is_lfe(self): + return np.zeros(self.num_channels, dtype=bool) + + @property + def orders_degrees(self): + assert self.channel_order == "ACN" + + n = (self.max_order + 1) ** 2 + acn = np.arange(n) + return hoa.from_acn(acn) + + @property + def norm_fn(self): + return hoa.norm_functions[self.normalization] + + @classmethod + def parse_or_none(cls, fmt_string): + match = HOA_FORMAT_RE.fullmatch(fmt_string) + if match is not None: + normalization, channel_order, max_order_str = match.groups() + return cls( + normalization=normalization, + channel_order=channel_order, + max_order=int(max_order_str), + ) + + match = AMBIX_RE.fullmatch(fmt_string) + if match is not None: + (max_order_str,) = match.groups() + return cls( + normalization="SN3D", channel_order="ACN", max_order=int(max_order_str) + ) + + +@attrs +class HOAPointSourceAdapter: + norm = attrib() + max_order = attrib() + orders = attrib() + degrees = attrib() + + @classmethod + def build(cls, fmt: HOAFormat): + orders, degrees = fmt.orders_degrees + + return cls( + norm=hoa.norm_functions[fmt.normalization], + max_order=fmt.max_order, + orders=orders, + degrees=degrees, + ) + + @property + def num_channels(self): + return len(self.orders) + + def handle(self, position): + x, y, z = position + az = -np.arctan2(x, y) + el = np.arctan2(z, np.hypot(x, y)) + + return hoa.sph_harm(self.orders, self.degrees, az, el, self.norm) diff --git a/ear/core/hoa_overloads.py b/ear/core/hoa_overloads.py new file mode 100644 index 00000000..89707780 --- /dev/null +++ b/ear/core/hoa_overloads.py @@ -0,0 +1,6 @@ +# importing these registers overloads to make various renderer components +# compatible with HOA output +from . import hoa_adapter # noqa: F401 +from .direct_speakers import panner_hoa # noqa: F401 +from .objectbased import renderer_hoa # noqa: F401 +from .scenebased import design_hoa # noqa: F401 diff --git a/ear/core/objectbased/gain_calc_hoa.py b/ear/core/objectbased/gain_calc_hoa.py new file mode 100644 index 00000000..c173c6d1 --- /dev/null +++ b/ear/core/objectbased/gain_calc_hoa.py @@ -0,0 +1,126 @@ +import numpy as np +from ..hoa_adapter import HOAPointSourceAdapter +from .extent import PolarExtentPanner +from .gain_calc import ( + PolarExtentHandler, + ScreenEdgeLockHandler, + ScreenScaleHandler, + coord_trans, + direct_diffuse_split, + diverge, +) + + +class PolarExtentPannerHOA(PolarExtentPanner): + def calc_pv_spread(self, position, width, height): + ammount_spread = np.interp(max(width, height), [0, self.fade_width], [0, 1]) + ammount_point = 1.0 - ammount_spread + + pv = 0.0 + if ammount_point > 1e-10: + pv += ammount_point * self.panning_func(position) + if ammount_spread > 1e-10: + width = np.maximum(width, self.fade_width / 2) + height = np.maximum(height, self.fade_width / 2) + + weight_f = self.get_weight_func(position, width, height) + spread_pvs = self.spreading_panner.panning_values_for_weight(weight_f) + spread_pvs /= spread_pvs[0] # normalise based on omni channel + pv += ammount_spread * spread_pvs + + return pv + + +class PolarExtentHandlerHOA(PolarExtentHandler): + def __init__(self, point_source_panner): + self.polar_extent_panner = PolarExtentPannerHOA(point_source_panner.handle) + + def handle(self, position, width, height, depth): + """Calculate loudspeaker gains given position and extent parameters. + + Parameters: + position (array of length 3): Cartesian source position + width (float): block format width parameter + height (float): block format height parameter + depth (float): block format depth parameter + Returns: + gain (array of length n): loudspeaker gains of length + self.point_source_panner.num_channels. + """ + distance = np.linalg.norm(position) + + if depth != 0: + distances = np.array([distance + depth / 2.0, distance - depth / 2.0]) + distances[distances < 0] = 0.0 + else: + distances = [distance] + + pvs = [ + self.polar_extent_panner.calc_pv_spread( + position, + self.extent_mod(width, end_distance), + self.extent_mod(height, end_distance), + ) + for end_distance in distances + ] + + if len(pvs) == 1: + return pvs[0] + else: + return np.mean(pvs, axis=0) + + +class GainCalcHOA(object): + def __init__(self, layout): + self.point_source_panner = HOAPointSourceAdapter.build(layout) + self.screen_edge_lock_handler = ScreenEdgeLockHandler( + layout.screen, layout.without_lfe + ) + self.screen_scale_handler = ScreenScaleHandler( + layout.screen, layout.without_lfe + ) + self.polar_extent_panner = PolarExtentHandlerHOA(self.point_source_panner) + + def render(self, object_meta): + block_format = object_meta.block_format + + position = coord_trans(block_format.position) + + position = self.screen_scale_handler.handle( + position, + block_format.screenRef, + object_meta.extra_data.reference_screen, + block_format.cartesian, + ) + + position = self.screen_edge_lock_handler.handle_vector( + position, block_format.position.screenEdgeLock, block_format.cartesian + ) + + if block_format.cartesian: + raise RuntimeError( + "HOA rendering is not defined for Cartesian coordinates; perhaps use conversion" + ) + else: + extent_pan = self.polar_extent_panner.handle + + diverged_gains, diverged_positions = diverge( + position, block_format.objectDivergence, block_format.cartesian + ) + + gains_for_each_pos = np.apply_along_axis( + extent_pan, + 1, + diverged_positions, + block_format.width, + block_format.height, + block_format.depth, + ) + + gains = np.dot(diverged_gains, gains_for_each_pos) + + gains = np.nan_to_num(gains) + + gains *= block_format.gain + + return direct_diffuse_split(gains, block_format.diffuse) diff --git a/ear/core/objectbased/renderer_hoa.py b/ear/core/objectbased/renderer_hoa.py new file mode 100644 index 00000000..e24f94f5 --- /dev/null +++ b/ear/core/objectbased/renderer_hoa.py @@ -0,0 +1,105 @@ +import numpy as np +from ..convolver import OverlapSaveConvolver, VariableBlockSizeAdapter +from ..delay import Delay +from ..hoa_adapter import HOAFormat +from .gain_calc_hoa import GainCalcHOA +from .renderer import ObjectRenderer, build_objects_renderer + + +def design_decorrelators(layout): + from .. import hoa + from ..quadrature import get_t_design + from .decorrelate import design_decorrelator_basic + + points = get_t_design((layout.max_order * 2) + 1) + + size = 128 + decorrelators = np.array( + [design_decorrelator_basic(i, size=size) for i in range(len(points))] + ) + + az = -np.arctan2(points[:, 0], points[:, 1]) + el = np.arctan2(points[:, 2], np.hypot(points[:, 0], points[:, 1])) + + n, m = layout.orders_degrees + Y = hoa.sph_harm( + n[:, np.newaxis], + m[:, np.newaxis], + az[np.newaxis], + el[np.newaxis], + norm=hoa.norm_N3D, + ) + + # TODO: apply normalisation + + # decode to t-design, decorrelate, then re-encode + # order: in, out, sample + decorr_mat = np.einsum("ij,jk,jl->ilk", Y, decorrelators, Y.T) + + return decorr_mat + + +class ObjectRendererHOA(ObjectRenderer): + def __init__(self, layout, gain_calc_opts={}, decorrelator_opts={}, block_size=512): + self._gain_calc = GainCalcHOA(layout, **gain_calc_opts) + self._nchannels = n = layout.num_channels + + # tuples of a track spec processor and a BlockProcessingChannel to + # apply to the samples it produces. + self.block_processing_channels = [] + + decorrlation_filters = design_decorrelators(layout) + decorrelator_delay = (decorrlation_filters.shape[-1] - 1) // 2 + + decorrlation_filters_flat = decorrlation_filters.reshape( + -1, decorrlation_filters.shape[-1] + ) + + decorrelators = OverlapSaveConvolver( + block_size, decorrlation_filters_flat.shape[0], decorrlation_filters_flat.T + ) + + def filter_block(in_block): + # adapt OverlapSaveConvolver to work with a matrix of filters + decorr_in = np.repeat(in_block, n, axis=1) + decor_out = decorrelators.filter_block(decorr_in) + return np.sum(decor_out.reshape(-1, n, n), axis=2) + + self.decorrelators_vbs = VariableBlockSizeAdapter( + block_size, self._nchannels, filter_block + ) + + self.overall_delay = self.decorrelators_vbs.delay(decorrelator_delay) + + self.delays = Delay(self._nchannels, self.overall_delay) + + def render(self, sample_rate, start_sample, input_samples): + """Process n input samples to produce n output samples. + + Args: + sample_rate (int): Sample rate. + start_sample (int): Index of the first sample in input_samples. + input_samples (ndarray of (k, k) float): Multi-channel input sample + block; there must be at least as many channels as referenced in the + rendering items. + + Returns: + (ndarray of (n, l) float): l channels of output samples + corresponding to the l loudspeakers in layout. + """ + interpolated = np.zeros((len(input_samples), self._nchannels * 2)) + + for track_spec_processor, block_processing in self.block_processing_channels: + track_samples = track_spec_processor.process(sample_rate, input_samples) + block_processing.process( + sample_rate, start_sample, track_samples, interpolated + ) + + direct_out = self.delays.process(interpolated[:, : self._nchannels]) + diffuse_out = self.decorrelators_vbs.process(interpolated[:, self._nchannels :]) + return direct_out + diffuse_out + + +@build_objects_renderer.register(HOAFormat) +def _build_objects_renderer_hoa(layout): + return ObjectRendererHOA(layout) diff --git a/ear/core/quadrature.py b/ear/core/quadrature.py new file mode 100644 index 00000000..3c212f02 --- /dev/null +++ b/ear/core/quadrature.py @@ -0,0 +1,26 @@ +from functools import partial + + +def _load_unweighted_cartesian(fname): + import numpy as np + import pkg_resources + + with pkg_resources.resource_stream(__name__, fname) as points_file: + return np.loadtxt(points_file, skiprows=2) + + +_t_designs = { + 3: partial(_load_unweighted_cartesian, "data/N003_M6_Octa.dat"), + 5: partial(_load_unweighted_cartesian, "data/N005_M12_Ico.dat"), + 7: partial(_load_unweighted_cartesian, "data/N007_M24_Octa.dat"), + 9: partial(_load_unweighted_cartesian, "data/N009_M48_Octa.dat"), + 11: partial(_load_unweighted_cartesian, "data/N011_M70_C5.dat"), + 13: partial(_load_unweighted_cartesian, "data/N013_M94_Inv.dat"), +} + + +def get_t_design(N): + if N in _t_designs: + return _t_designs[N]() + else: + raise KeyError(f"t-design of order {N} not found") diff --git a/ear/core/scenebased/design_hoa.py b/ear/core/scenebased/design_hoa.py new file mode 100644 index 00000000..ea7ec96c --- /dev/null +++ b/ear/core/scenebased/design_hoa.py @@ -0,0 +1,65 @@ +import numpy as np +import warnings +from .. import hoa +from ..hoa_adapter import HOAFormat +from .design import build_hoa_decoder_design + + +class HOAFormatConvert: + """replacement for HOADecoderDesign which designs matrices to convert from + one HOA format to another + """ + + def __init__(self, fmt): + self.out_fmt = fmt + + def design(self, type_metadata): + """Design a decoder matrix for the given HOA format. + + Args: + type_metadata (HOATypeMetadata): HOA metadata. + + Returns: + l, m decoder matrix from m HOA channels to l loudspeaker channels + """ + + in_orders, in_degrees = np.array(type_metadata.orders), np.array( + type_metadata.degrees + ) + out_orders, out_degrees = self.out_fmt.orders_degrees + + in_norm = hoa.norm_functions[type_metadata.normalization] + out_norm = self.out_fmt.norm_fn + + def find(ns, ms, n, m): + (where,) = np.where((ns == n) & (ms == m)) + assert len(where <= 1) + if len(where): + return where[0] + + # more in than out -> discard high orders + # more out than in -> high orders are silent + # therefore only process minimum of in and out + max_order = min(max(in_orders), max(out_orders)) + + out = np.zeros((len(out_orders), len(in_orders))) + + acns = np.arange((max_order + 1) ** 2) + orders, degrees = hoa.from_acn(acns) + norm_factors = out_norm(orders, np.abs(degrees)) / in_norm( + orders, np.abs(degrees) + ) + + for n, m, norm_factor in zip(orders, degrees, norm_factors): + in_channel = find(in_orders, in_degrees, n, m) + out_channel = find(out_orders, out_degrees, n, m) + + if in_channel is not None and out_channel is not None: + out[out_channel, in_channel] = norm_factor + + return out + + +@build_hoa_decoder_design.register(HOAFormat) +def _build_hoa_decoder_design_hoa(layout, **options): + return HOAFormatConvert(layout, **options) From 452e75074970c7800136504e755da790bce65f46 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Wed, 14 Dec 2022 15:12:28 +0000 Subject: [PATCH 21/31] document HOA output on command line --- ear/cmdline/render_file.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/ear/cmdline/render_file.py b/ear/cmdline/render_file.py index d7c0d4d2..49e07e65 100644 --- a/ear/cmdline/render_file.py +++ b/ear/cmdline/render_file.py @@ -14,6 +14,7 @@ from ..fileio.bw64.chunks import FormatInfoChunk from ..fileio.adm import timing_fixes import logging +import textwrap from .error_handler import error_handler @@ -39,13 +40,34 @@ class OfflineRenderDriver(object): blocksize = 8192 + @classmethod + def get_systems_help(cls): + formats_string = ", ".join(bs2051.layout_names) + + from ..core import hoa + norm_names = ", ".join(hoa.norm_functions) + + text = f"""\ + output system, accoring to ITU-R BS.2051 + + available systems: + {formats_string} + + HOA output can be specified as "hoa_norm_order_n" or "ambix_n" where: + - norm is the normalization style, one of {norm_names} + - order is the channel order, currently only ACN + - n is the maximum order (e.g. 1 for first-order) + + ambix_n is equivalent to hoa_SN3D_ACN_n + """ + + return textwrap.dedent(text) + @classmethod def add_args(cls, parser): """Add arguments to an ArgumentParser that can be used by from_args.""" - formats_string = ", ".join(bs2051.layout_names) parser.add_argument("-s", "--system", required=True, metavar="target_system", - help="Target output system, accoring to ITU-R BS.2051. " - "Available systems are: {}".format(formats_string)) + help=cls.get_systems_help()) parser.add_argument("-l", "--layout", type=argparse.FileType("r"), metavar="layout_file", help="Layout config file") @@ -221,7 +243,7 @@ def run(self, input_file, output_file): def make_parser(): - parser = argparse.ArgumentParser(description="EBU ADM renderer") + parser = argparse.ArgumentParser(description="EBU ADM renderer", formatter_class=argparse.RawTextHelpFormatter) parser.add_argument("-d", "--debug", help="print debug information when an error occurs", From 12234bbaaf978769539c142e308bc0ff13833c30 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Thu, 15 Dec 2022 12:39:53 +0000 Subject: [PATCH 22/31] fixup: add HOA warnings --- ear/core/scenebased/design_hoa.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ear/core/scenebased/design_hoa.py b/ear/core/scenebased/design_hoa.py index ea7ec96c..dbc05224 100644 --- a/ear/core/scenebased/design_hoa.py +++ b/ear/core/scenebased/design_hoa.py @@ -22,6 +22,13 @@ def design(self, type_metadata): Returns: l, m decoder matrix from m HOA channels to l loudspeaker channels """ + if type_metadata.screenRef: + warnings.warn("screenRef for HOA is not implemented; ignoring") + if ( + type_metadata.extra_data.channel_frequency.lowPass is not None + or type_metadata.extra_data.channel_frequency.highPass is not None + ): + warnings.warn("frequency information for HOA is not implemented; ignoring") in_orders, in_degrees = np.array(type_metadata.orders), np.array( type_metadata.degrees From d5d66b04b97a209c1b662de19fdc366cac98d52b Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Thu, 15 Dec 2022 13:52:09 +0000 Subject: [PATCH 23/31] test scenebased --- ear/core/scenebased/test/__init__.py | 0 ear/core/scenebased/test/test_design_hoa.py | 64 +++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 ear/core/scenebased/test/__init__.py create mode 100644 ear/core/scenebased/test/test_design_hoa.py 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_hoa.py b/ear/core/scenebased/test/test_design_hoa.py new file mode 100644 index 00000000..3e78075d --- /dev/null +++ b/ear/core/scenebased/test/test_design_hoa.py @@ -0,0 +1,64 @@ +import numpy as np +from ...hoa import norm_FuMa, norm_SN3D +from ...hoa_adapter import HOAFormat +from ...metadata_input import HOATypeMetadata +from .. import design_hoa # noqa: F401 +from ..design import build_hoa_decoder_design + + +def test_convert_order_norm(): + fmt = HOAFormat(max_order=1, normalization="SN3D", channel_order="ACN") + + tm = HOATypeMetadata( + orders=[0, 1, 1, 1], degrees=[0, 1, -1, 0], normalization="FuMa" + ) + + designer = build_hoa_decoder_design(fmt) + actual = designer.design(tm) + + # [out, in] + expected = np.zeros((4, 4)) + + expected[0, 0] = 1 + expected[1, 2] = 1 + expected[2, 3] = 1 + expected[3, 1] = 1 + + n, m = fmt.orders_degrees + expected *= (norm_SN3D(n, np.abs(m)) / norm_FuMa(n, np.abs(m)))[:, np.newaxis] + + np.testing.assert_allclose(actual, expected) + + +def test_upmix(): + fmt = HOAFormat(max_order=2, normalization="SN3D", channel_order="ACN") + + tm = HOATypeMetadata( + orders=[0, 1, 1, 1], degrees=[0, -1, 0, 1], normalization="SN3D" + ) + + designer = build_hoa_decoder_design(fmt) + actual = designer.design(tm) + + # [out, in] + expected = np.eye(9, 4) + + np.testing.assert_allclose(actual, expected) + + +def test_downmix(): + fmt = HOAFormat(max_order=1, normalization="SN3D", channel_order="ACN") + + tm = HOATypeMetadata( + orders=[0, 1, 1, 1, 2, 2, 2, 2, 2], + degrees=[0, -1, 0, 1, -2, -1, 0, 1, 2], + normalization="SN3D", + ) + + designer = build_hoa_decoder_design(fmt) + actual = designer.design(tm) + + # [out, in] + expected = np.eye(4, 9) + + np.testing.assert_allclose(actual, expected) From 67435f4452698e5423114439b63a4e052020a3a2 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Thu, 15 Dec 2022 14:51:13 +0000 Subject: [PATCH 24/31] improve sph_harm docs --- ear/core/hoa.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ear/core/hoa.py b/ear/core/hoa.py index 48ba890a..b71ed3f4 100644 --- a/ear/core/hoa.py +++ b/ear/core/hoa.py @@ -54,7 +54,15 @@ def norm_FuMa(n, abs_m): def sph_harm(n, m, az, el, norm=norm_SN3D): - """Spherical harmonic function Y_n^m(az, el).""" + """Spherical harmonic function Y_n^m(az, el). + + azimuth and elevation are the same as used elsewhere in the ADM (azimuth is + measured left from the front; elevation is measured up from centre), except + in radians rather than degrees + + this differs slightly from the definitions in BS.2076, where the elevation + is measured down from the top + """ n, m, az, el = np.broadcast_arrays(n, m, az, el) scale = np.ones_like(m, dtype=float) select = m > 0 From bfc844de85e46e920d5b6547d0def01154437dfa Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Thu, 15 Dec 2022 14:56:38 +0000 Subject: [PATCH 25/31] test directspeakers hoa --- .../direct_speakers/test/test_panner_hoa.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 ear/core/direct_speakers/test/test_panner_hoa.py diff --git a/ear/core/direct_speakers/test/test_panner_hoa.py b/ear/core/direct_speakers/test/test_panner_hoa.py new file mode 100644 index 00000000..a4276c1e --- /dev/null +++ b/ear/core/direct_speakers/test/test_panner_hoa.py @@ -0,0 +1,73 @@ +import numpy as np +import pytest +from ....fileio.adm.elements import ( + BoundCoordinate, + DirectSpeakerPolarPosition, + Frequency, +) +from ...hoa import sph_harm +from ...hoa_adapter import HOAFormat +from ...metadata_input import ( + AudioBlockFormatDirectSpeakers, + DirectSpeakersTypeMetadata, + ExtraData, +) +from .. import panner_hoa # noqa: F401 +from ..panner import build_direct_speakers_panner + + +def test_panner(): + fmt = HOAFormat(max_order=1, normalization="SN3D", channel_order="ACN") + n, m = fmt.orders_degrees + norm = fmt.norm_fn + + panner = build_direct_speakers_panner(fmt) + + positions = [ + (0.0, 0.0), + (90.0, 0.0), + (-90.0, 0.0), + (180.0, 0.0), + (0.0, 90.0), + (0.0, -90.0), + ] + + for az, el in positions: + expected = sph_harm(n, m, np.radians(az), np.radians(el), norm) + + tm = DirectSpeakersTypeMetadata( + block_format=AudioBlockFormatDirectSpeakers( + position=DirectSpeakerPolarPosition( + bounded_azimuth=BoundCoordinate(az), + bounded_elevation=BoundCoordinate(el), + ), + speakerLabel=["label"], + ) + ) + + actual = panner.handle(tm) + + np.testing.assert_allclose(actual, expected) + + +def test_panner_lfe(): + fmt = HOAFormat(max_order=1, normalization="SN3D", channel_order="ACN") + panner = build_direct_speakers_panner(fmt) + + expected = np.zeros(4) + + tm = DirectSpeakersTypeMetadata( + block_format=AudioBlockFormatDirectSpeakers( + position=DirectSpeakerPolarPosition( + bounded_azimuth=BoundCoordinate(0.0), + bounded_elevation=BoundCoordinate(0.0), + ), + speakerLabel=["LFE1"], + ), + extra_data=ExtraData(channel_frequency=Frequency(lowPass=120.0)), + ) + + with pytest.warns(UserWarning, match="discarding DirectSpeakers LFE channel"): + actual = panner.handle(tm) + + np.testing.assert_allclose(actual, expected) From 95e020265b3f886de11aa548141a5d2f4209f887 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Thu, 15 Dec 2022 17:15:26 +0000 Subject: [PATCH 26/31] test objectbased hoa --- .../objectbased/test/test_gain_calc_hoa.py | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 ear/core/objectbased/test/test_gain_calc_hoa.py diff --git a/ear/core/objectbased/test/test_gain_calc_hoa.py b/ear/core/objectbased/test/test_gain_calc_hoa.py new file mode 100644 index 00000000..e337fdb7 --- /dev/null +++ b/ear/core/objectbased/test/test_gain_calc_hoa.py @@ -0,0 +1,169 @@ +import numpy as np +import pytest +from ....fileio.adm.elements import ( + AudioBlockFormatObjects, + ObjectDivergence, + ObjectPolarPosition, +) +from ...hoa import sph_harm +from ...hoa_adapter import HOAFormat +from ...metadata_input import ExtraData, ObjectTypeMetadata +from ..gain_calc_hoa import GainCalcHOA + + +@pytest.fixture(scope="module") +def fmt(): + return HOAFormat(max_order=1, normalization="SN3D", channel_order="ACN") + + +@pytest.fixture(scope="module") +def gain_calc(fmt): + return GainCalcHOA(fmt) + + +@pytest.fixture(scope="module") +def pan(fmt): + def f(az, el): + n, m = fmt.orders_degrees + return sph_harm(n, m, np.radians(az), np.radians(el), fmt.norm_fn) + + return f + + +@pytest.fixture(scope="module") +def run_test(fmt, gain_calc, pan): + def f( + block_format, + extra_data=ExtraData(), + direct_gains=None, + diffuse_gains=None, + direct_position=None, + diffuse_position=None, + atol=1e-10, + rtol=1e-6, + ): + block_format = AudioBlockFormatObjects(**block_format) + + actual = gain_calc.render( + ObjectTypeMetadata(block_format=block_format, extra_data=extra_data) + ) + + if direct_position is not None: + direct_gains = pan(*direct_position) + if diffuse_position is not None: + diffuse_gains = pan(*diffuse_position) + + if direct_gains is None: + direct_gains = np.zeros(fmt.num_channels) + if diffuse_gains is None: + diffuse_gains = np.zeros(fmt.num_channels) + + np.testing.assert_allclose(actual.direct, direct_gains, atol=atol, rtol=rtol) + np.testing.assert_allclose(actual.diffuse, diffuse_gains, atol=atol, rtol=rtol) + + return f + + +@pytest.mark.parametrize( + "az,el", + [ + (0.0, 0.0), + (90.0, 0.0), + (-90.0, 0.0), + (180.0, 0.0), + (0.0, 90.0), + (0.0, -90.0), + ], +) +def test_direct_pos(run_test, az, el): + run_test( + dict(position=ObjectPolarPosition(azimuth=az, elevation=el)), + direct_position=(az, el), + ) + + +def test_gain(run_test, pan): + run_test( + dict(position=ObjectPolarPosition(azimuth=0.0, elevation=0.0), gain=0.5), + direct_gains=pan(0.0, 0.0) * 0.5, + ) + + +def test_full_diffuse(run_test, pan): + run_test( + dict(position=ObjectPolarPosition(azimuth=0.0, elevation=0.0), diffuse=1.0), + diffuse_position=(0.0, 0.0), + ) + + +def test_half_diffuse(run_test, pan): + run_test( + dict(position=ObjectPolarPosition(azimuth=0.0, elevation=0.0), diffuse=0.5), + direct_gains=pan(0.0, 0.0) * np.sqrt(0.5), + diffuse_gains=pan(0.0, 0.0) * np.sqrt(0.5), + ) + + +def test_spread_small(run_test, pan): + run_test( + dict( + position=ObjectPolarPosition(azimuth=0.0, elevation=0.0), + width=10.0, + height=10.0, + ), + direct_gains=[1, 0, 0, 0.99], + atol=1e-2, + ) + + run_test( + dict( + position=ObjectPolarPosition(azimuth=180.0, elevation=0.0), + width=10.0, + height=10.0, + ), + direct_gains=[1, 0, 0, -0.99], + atol=1e-2, + ) + + run_test( + dict( + position=ObjectPolarPosition(azimuth=90.0, elevation=0.0), + width=10.0, + height=10.0, + ), + direct_gains=[1, 0.99, 0, 0], + atol=1e-2, + ) + + +def test_spread_large(run_test, pan): + run_test( + dict( + position=ObjectPolarPosition(azimuth=0.0, elevation=0.0), + width=360.0, + height=360.0, + ), + direct_gains=[1, 0, 0, 0], + atol=1e-2, + ) + + # for FOA, full width is equivalent to full extent + run_test( + dict( + position=ObjectPolarPosition(azimuth=0.0, elevation=0.0), + width=360.0, + height=0.0, + ), + direct_gains=[1, 0, 0, 0], + atol=1e-2, + ) + + +def test_diverge(run_test, pan): + run_test( + dict( + position=ObjectPolarPosition(azimuth=0.0, elevation=0.0), + objectDivergence=ObjectDivergence(0.5, azimuthRange=360 / 3), + ), + direct_gains=[1, 0, 0, 0], + ) From 4bb403c17ef9cf942ccbcee25d8d1bb4fccd37d0 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Fri, 16 Dec 2022 17:59:50 +0000 Subject: [PATCH 27/31] objectbased hoa: correct decorrelation normalisation --- ear/core/objectbased/renderer_hoa.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ear/core/objectbased/renderer_hoa.py b/ear/core/objectbased/renderer_hoa.py index e24f94f5..f65ee28a 100644 --- a/ear/core/objectbased/renderer_hoa.py +++ b/ear/core/objectbased/renderer_hoa.py @@ -22,7 +22,7 @@ def design_decorrelators(layout): el = np.arctan2(points[:, 2], np.hypot(points[:, 0], points[:, 1])) n, m = layout.orders_degrees - Y = hoa.sph_harm( + encoder = hoa.sph_harm( n[:, np.newaxis], m[:, np.newaxis], az[np.newaxis], @@ -30,11 +30,21 @@ def design_decorrelators(layout): norm=hoa.norm_N3D, ) - # TODO: apply normalisation + decoder = encoder.T / len(points) # decode to t-design, decorrelate, then re-encode # order: in, out, sample - decorr_mat = np.einsum("ij,jk,jl->ilk", Y, decorrelators, Y.T) + decorr_mat = np.einsum("ij,jk,jl->ilk", encoder, decorrelators, decoder) + + # normalisebased on an omni source + decorr_mat /= np.linalg.norm(decorr_mat[0]) + + # apply normalisation -- do this at the end to ensure it behaves the same + # with different normalisations + norm = layout.norm_fn(n, np.abs(m)) / hoa.norm_N3D(n, np.abs(m)) + decorr_mat *= (1 / norm[:, np.newaxis, np.newaxis]) * norm[ + np.newaxis, :, np.newaxis + ] return decorr_mat From 5fa9797bba253334a76614cadbde46125ae6c67d Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Fri, 16 Dec 2022 18:22:44 +0000 Subject: [PATCH 28/31] objectbased hoa: rework decorrelator application - extract convolver matrix - put the samples in the last axis --- ear/core/objectbased/renderer_hoa.py | 51 ++++++++++++++++------------ 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/ear/core/objectbased/renderer_hoa.py b/ear/core/objectbased/renderer_hoa.py index f65ee28a..c218907e 100644 --- a/ear/core/objectbased/renderer_hoa.py +++ b/ear/core/objectbased/renderer_hoa.py @@ -16,7 +16,7 @@ def design_decorrelators(layout): size = 128 decorrelators = np.array( [design_decorrelator_basic(i, size=size) for i in range(len(points))] - ) + ).T az = -np.arctan2(points[:, 0], points[:, 1]) el = np.arctan2(points[:, 2], np.hypot(points[:, 0], points[:, 1])) @@ -33,22 +33,41 @@ def design_decorrelators(layout): decoder = encoder.T / len(points) # decode to t-design, decorrelate, then re-encode - # order: in, out, sample - decorr_mat = np.einsum("ij,jk,jl->ilk", encoder, decorrelators, decoder) + # order: sample, in, out + decorr_mat = np.einsum("ij,kj,jl->kil", encoder, decorrelators, decoder) # normalisebased on an omni source - decorr_mat /= np.linalg.norm(decorr_mat[0]) + decorr_mat /= np.linalg.norm(decorr_mat[:, 0]) # apply normalisation -- do this at the end to ensure it behaves the same # with different normalisations norm = layout.norm_fn(n, np.abs(m)) / hoa.norm_N3D(n, np.abs(m)) - decorr_mat *= (1 / norm[:, np.newaxis, np.newaxis]) * norm[ - np.newaxis, :, np.newaxis - ] + decorr_mat *= norm / norm[:, np.newaxis] return decorr_mat +class OverlapSaveConvolverMatrix: + def __init__(self, block_size, filters): + """ + Args: + block_size (int): block size + filters (ndarray of shape (i, o, n)): filters with i input channels, o output channels and n samples + """ + self._num_in, self._num_out = filters.shape[1:] + + filters_flat = filters.reshape(filters.shape[0], -1) + + self._decorrelators = OverlapSaveConvolver( + block_size, filters_flat.shape[1], filters_flat + ) + + def filter_block(self, in_block): + decorr_in = np.repeat(in_block, self._num_out, axis=1) + decor_out = self._decorrelators.filter_block(decorr_in) + return np.sum(decor_out.reshape(-1, self._num_in, self._num_out), axis=1) + + class ObjectRendererHOA(ObjectRenderer): def __init__(self, layout, gain_calc_opts={}, decorrelator_opts={}, block_size=512): self._gain_calc = GainCalcHOA(layout, **gain_calc_opts) @@ -59,24 +78,12 @@ def __init__(self, layout, gain_calc_opts={}, decorrelator_opts={}, block_size=5 self.block_processing_channels = [] decorrlation_filters = design_decorrelators(layout) - decorrelator_delay = (decorrlation_filters.shape[-1] - 1) // 2 - - decorrlation_filters_flat = decorrlation_filters.reshape( - -1, decorrlation_filters.shape[-1] - ) - - decorrelators = OverlapSaveConvolver( - block_size, decorrlation_filters_flat.shape[0], decorrlation_filters_flat.T - ) + decorrelator_delay = (decorrlation_filters.shape[0] - 1) // 2 - def filter_block(in_block): - # adapt OverlapSaveConvolver to work with a matrix of filters - decorr_in = np.repeat(in_block, n, axis=1) - decor_out = decorrelators.filter_block(decorr_in) - return np.sum(decor_out.reshape(-1, n, n), axis=2) + decorrelators = OverlapSaveConvolverMatrix(block_size, decorrlation_filters) self.decorrelators_vbs = VariableBlockSizeAdapter( - block_size, self._nchannels, filter_block + block_size, self._nchannels, decorrelators.filter_block ) self.overall_delay = self.decorrelators_vbs.delay(decorrelator_delay) From 510d55b4500b197d5f7029568b76f8dd79bd6301 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Fri, 16 Dec 2022 18:25:54 +0000 Subject: [PATCH 29/31] make decorrelator size configurable and default to 512 samples --- ear/core/objectbased/renderer_hoa.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ear/core/objectbased/renderer_hoa.py b/ear/core/objectbased/renderer_hoa.py index c218907e..a840a5f1 100644 --- a/ear/core/objectbased/renderer_hoa.py +++ b/ear/core/objectbased/renderer_hoa.py @@ -6,14 +6,13 @@ from .renderer import ObjectRenderer, build_objects_renderer -def design_decorrelators(layout): +def design_decorrelators(layout, size=512): from .. import hoa from ..quadrature import get_t_design from .decorrelate import design_decorrelator_basic points = get_t_design((layout.max_order * 2) + 1) - size = 128 decorrelators = np.array( [design_decorrelator_basic(i, size=size) for i in range(len(points))] ).T From 2c731ba02719bf1374abaf7e186c6c7bbc1dd936 Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Fri, 16 Dec 2022 18:30:32 +0000 Subject: [PATCH 30/31] fixup unused --- ear/core/objectbased/renderer_hoa.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ear/core/objectbased/renderer_hoa.py b/ear/core/objectbased/renderer_hoa.py index a840a5f1..5c242095 100644 --- a/ear/core/objectbased/renderer_hoa.py +++ b/ear/core/objectbased/renderer_hoa.py @@ -70,7 +70,7 @@ def filter_block(self, in_block): class ObjectRendererHOA(ObjectRenderer): def __init__(self, layout, gain_calc_opts={}, decorrelator_opts={}, block_size=512): self._gain_calc = GainCalcHOA(layout, **gain_calc_opts) - self._nchannels = n = layout.num_channels + self._nchannels = layout.num_channels # tuples of a track spec processor and a BlockProcessingChannel to # apply to the samples it produces. From 52171326a216ee7ae741d46b0c5be9eadcbc0bae Mon Sep 17 00:00:00 2001 From: Thomas Nixon Date: Fri, 16 Dec 2022 18:31:29 +0000 Subject: [PATCH 31/31] add tests for decorrelators and convolution matrix --- .../objectbased/test/test_renderer_hoa.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 ear/core/objectbased/test/test_renderer_hoa.py diff --git a/ear/core/objectbased/test/test_renderer_hoa.py b/ear/core/objectbased/test/test_renderer_hoa.py new file mode 100644 index 00000000..55745847 --- /dev/null +++ b/ear/core/objectbased/test/test_renderer_hoa.py @@ -0,0 +1,37 @@ +import numpy as np +from ...hoa_adapter import HOAFormat, HOAPointSourceAdapter +from ..renderer_hoa import OverlapSaveConvolverMatrix, design_decorrelators + + +def test_decorrelator_normalisation(): + """check that the decorrelation filters are correctly normalised, so that + the output is the same for different output normalisations + """ + fmt1 = HOAFormat(2, "N3D") + fmt2 = HOAFormat(2, "SN3D") + n, m = fmt1.orders_degrees + + panner1 = HOAPointSourceAdapter.build(fmt1) + panner2 = HOAPointSourceAdapter.build(fmt2) + + conv_2_1 = fmt1.norm_fn(n, np.abs(m)) / fmt2.norm_fn(n, np.abs(m)) + + dec1 = design_decorrelators(fmt1) + dec2 = design_decorrelators(fmt2) + + out1 = np.einsum("ijk,j->ik", dec1, panner1.handle((0, 1, 0))) + out2 = np.einsum("ijk,j->ik", dec2, panner2.handle((0, 1, 0))) * conv_2_1 + + np.testing.assert_allclose(out1, out2) + + +def test_OverlapSaveConvolverMatrix(): + n_in, n_out = 3, 5 + + filters = np.random.uniform(size=(1, n_in, n_out)) + conv = OverlapSaveConvolverMatrix(1, filters) + + samples_in = np.random.uniform(size=(1, n_in)) + samples_out = conv.filter_block(samples_in) + + np.testing.assert_allclose(samples_out[0], np.dot(samples_in[0], filters[0]))