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/cmdline/render_file.py b/ear/cmdline/render_file.py index 628ba9b9..49e07e65 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 @@ -12,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 @@ -37,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") @@ -83,6 +107,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: @@ -215,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", @@ -245,5 +273,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() 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/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.py b/ear/core/direct_speakers/panner.py index a6ef2c73..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,8 +7,8 @@ from ..geom import inside_angle_range from .. import point_source from .. import allocentric +from ..layout import Layout 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 @@ -76,7 +77,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 @@ -217,41 +218,14 @@ def opposite_name(channel_name): } -class DirectSpeakersPanner(object): +class SpeakerLabelHandler(object): + """utilities for things related to speakerLabels - 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) - - 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(layout) - self.allo_psp = point_source.configure_allocentric(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", @@ -276,6 +250,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. @@ -312,8 +342,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): @@ -333,8 +363,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] @@ -348,45 +378,6 @@ def channels_within_bounds(self, position, tol): 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 - - @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 @@ -396,27 +387,23 @@ 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: 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) @@ -431,14 +418,14 @@ 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]: 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 @@ -478,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/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/direct_speakers/renderer.py b/ear/core/direct_speakers/renderer.py index 0defa9f4..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 @@ -36,13 +36,16 @@ 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) + self._panner = build_direct_speakers_panner(layout, **options) + 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/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) diff --git a/ear/core/hoa.py b/ear/core/hoa.py index 9afe10a2..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(ax, 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 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/layout.py b/ear/core/layout.py index 7e41f84e..91824dad 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,18 @@ 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 num_channels(self): + """the number of channels in this layout""" + return len(self.channels) @property def positions(self): 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 803309a6..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 @@ -131,9 +130,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)) @@ -339,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) @@ -358,7 +357,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 +373,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/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.py b/ear/core/objectbased/renderer.py index e94b6928..842a31fc 100644 --- a/ear/core/objectbased/renderer.py +++ b/ear/core/objectbased/renderer.py @@ -1,11 +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 ...options import Option, SubOptions, OptionsHandler 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 @@ -77,24 +78,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) @@ -157,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/objectbased/renderer_hoa.py b/ear/core/objectbased/renderer_hoa.py new file mode 100644 index 00000000..5c242095 --- /dev/null +++ b/ear/core/objectbased/renderer_hoa.py @@ -0,0 +1,121 @@ +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, 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) + + 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])) + + n, m = layout.orders_degrees + encoder = hoa.sph_harm( + n[:, np.newaxis], + m[:, np.newaxis], + az[np.newaxis], + el[np.newaxis], + norm=hoa.norm_N3D, + ) + + decoder = encoder.T / len(points) + + # decode to t-design, decorrelate, then re-encode + # 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]) + + # 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 *= 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) + self._nchannels = 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[0] - 1) // 2 + + decorrelators = OverlapSaveConvolverMatrix(block_size, decorrlation_filters) + + self.decorrelators_vbs = VariableBlockSizeAdapter( + block_size, self._nchannels, decorrelators.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/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/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], + ) 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])) diff --git a/ear/core/point_source.py b/ear/core/point_source.py index 5cb7e7fe..a1ef2a36 100644 --- a/ear/core/point_source.py +++ b/ear/core/point_source.py @@ -3,8 +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 ..options import OptionsHandler +from .layout import Channel, LayoutStyle from . import bs2051 @@ -462,9 +461,9 @@ 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): layout = _set_screen_speaker_nominal_positions(layout) @@ -525,6 +524,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,7 +730,13 @@ def configure_allocentric(layout): return AllocentricPanner(positions) -configure_options = OptionsHandler() +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) def configure(layout): @@ -727,7 +754,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}") 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/renderer.py b/ear/core/renderer.py index 93a22e63..36413b8e 100644 --- a/ear/core/renderer.py +++ b/ear/core/renderer.py @@ -1,8 +1,7 @@ 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 ..options import SubOptions, OptionsHandler from .metadata_input import ObjectRenderingItem, DirectSpeakersRenderingItem, HOARenderingItem from .block_aligner import BlockAligner @@ -13,28 +12,15 @@ 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)) + 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) diff --git a/ear/core/scenebased/design.py b/ear/core/scenebased/design.py index b739216a..1a4efcef 100644 --- a/ear/core/scenebased/design.py +++ b/ear/core/scenebased/design.py @@ -1,8 +1,9 @@ +from functools import singledispatch import numpy as np import warnings from .. import hoa +from ..layout import Layout from .. import point_source -from ...options import OptionsHandler, Option, SubOptions class HOADecoderDesign(object): @@ -10,25 +11,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 @@ -96,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/design_hoa.py b/ear/core/scenebased/design_hoa.py new file mode 100644 index 00000000..dbc05224 --- /dev/null +++ b/ear/core/scenebased/design_hoa.py @@ -0,0 +1,72 @@ +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 + """ + 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 + ) + 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) diff --git a/ear/core/scenebased/renderer.py b/ear/core/scenebased/renderer.py index 99903e5b..d6ef69bb 100644 --- a/ear/core/scenebased/renderer.py +++ b/ear/core/scenebased/renderer.py @@ -1,9 +1,8 @@ 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 -from ...options import OptionsHandler, SubOptions @attrs(slots=True, frozen=True) @@ -58,15 +57,15 @@ 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): - self._decoder_design = HOADecoderDesign(layout.without_lfe, **design_opts) + def __init__(self, layout, design_opts={}): + self._decoder_design = build_hoa_decoder_design(layout.without_lfe, **design_opts) self._output_channels = ~layout.is_lfe self.block_processing_channels = [] 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) diff --git a/ear/core/screen_edge_lock.py b/ear/core/screen_edge_lock.py index 8896f5b1..227c6549 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 multipledispatch import dispatch import numpy as np @@ -48,3 +51,24 @@ 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 + + @dispatch(DirectSpeakerPolarPosition) + def handle_ds_position(self, position): # noqa: F811 + 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)) + + @dispatch(DirectSpeakerCartesianPosition) + def handle_ds_position(self, position): # noqa: F811 + 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)) 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 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, 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 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) 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