diff --git a/ear/core/direct_speakers/panner.py b/ear/core/direct_speakers/panner.py index a6ef2c73..d088b83f 100644 --- a/ear/core/direct_speakers/panner.py +++ b/ear/core/direct_speakers/panner.py @@ -251,6 +251,7 @@ def __init__(self, layout, point_source_opts={}, additional_substitutions={}): self._screen_edge_lock_handler = ScreenEdgeLockHandler(self.layout.screen, layout) self.pvs = np.eye(self.n_channels) + self.pvs.flags.writeable = False self.substitutions = { "LFE": "LFE1", diff --git a/ear/core/objectbased/gain_calc.py b/ear/core/objectbased/gain_calc.py index 803309a6..d583a864 100644 --- a/ear/core/objectbased/gain_calc.py +++ b/ear/core/objectbased/gain_calc.py @@ -126,7 +126,7 @@ def get_weighted_distances(self, channel_positions, position): class AlloChannelLockHandler(ChannelLockHandlerBase): """Channel lock specialised for allocentric; allocentric loudspeaker - positions are used, and the distance calculation is unweighted.""" + positions are used, and the distance calculation is weighted.""" def __init__(self, layout): super(AlloChannelLockHandler, self).__init__(layout) diff --git a/ear/core/objectbased/renderer.py b/ear/core/objectbased/renderer.py index e94b6928..86c8e7d3 100644 --- a/ear/core/objectbased/renderer.py +++ b/ear/core/objectbased/renderer.py @@ -102,11 +102,11 @@ def __init__(self, layout, gain_calc_opts, decorrelator_opts, block_size): # apply to the samples it produces. self.block_processing_channels = [] - decorrlation_filters = decorrelate.design_decorrelators(layout, **decorrelator_opts) - decorrelator_delay = (decorrlation_filters.shape[0] - 1) // 2 + decorrelation_filters = decorrelate.design_decorrelators(layout, **decorrelator_opts) + decorrelator_delay = (decorrelation_filters.shape[0] - 1) // 2 decorrelators = OverlapSaveConvolver( - block_size, self._nchannels, decorrlation_filters) + block_size, self._nchannels, decorrelation_filters) self.decorrelators_vbs = VariableBlockSizeAdapter( block_size, self._nchannels, decorrelators.filter_block) diff --git a/ear/core/objectbased/test/data/gain_calc_pvs/inputs.jsonl.xz b/ear/core/objectbased/test/data/gain_calc_pvs/inputs.jsonl.xz new file mode 100644 index 00000000..fdba495a Binary files /dev/null and b/ear/core/objectbased/test/data/gain_calc_pvs/inputs.jsonl.xz differ diff --git a/ear/core/objectbased/test/data/gain_calc_pvs/inputs.pickle b/ear/core/objectbased/test/data/gain_calc_pvs/inputs.pickle deleted file mode 100644 index db7129ff..00000000 Binary files a/ear/core/objectbased/test/data/gain_calc_pvs/inputs.pickle and /dev/null differ diff --git a/ear/core/objectbased/test/data/gain_calc_pvs/outputs.jsonl.xz b/ear/core/objectbased/test/data/gain_calc_pvs/outputs.jsonl.xz new file mode 100644 index 00000000..6cfd0a21 Binary files /dev/null and b/ear/core/objectbased/test/data/gain_calc_pvs/outputs.jsonl.xz differ diff --git a/ear/core/objectbased/test/test_gain_calc_changes.py b/ear/core/objectbased/test/test_gain_calc_changes.py index a3af49de..a3fa5df7 100644 --- a/ear/core/objectbased/test/test_gain_calc_changes.py +++ b/ear/core/objectbased/test/test_gain_calc_changes.py @@ -145,6 +145,36 @@ def generate_random_ObjectTypeMetadatas(): yield generate_random_ObjectTypeMetadata() +def load_jsonl_xz(file_name): + """load objects from a lzma-compressed newline-separated JSON (jsonl) file""" + from ....test.json import json_to_value + import json + import lzma + + objects = [] + with lzma.open(file_name, "rb") as f: + for line in f: + json_line = json.loads(line.decode("utf8")) + obj = json_to_value(json_line) + objects.append(obj) + + return objects + + +def dump_jsonl_xz(file_name, objects): + """dump objects to a lzma-compressed newline-separated JSON (jsonl) file""" + from ....test.json import value_to_json + import json + import lzma + + with lzma.open(file_name, "wb") as f: + for obj in objects: + json_obj = value_to_json(obj, include_defaults=False) + json_line = json.dumps(json_obj, sort_keys=True, separators=(",", ":")) + f.write(json_line.encode("utf8")) + f.write(b"\n") + + @pytest.mark.no_cover def test_changes_random(layout, gain_calc): """Check that the result of the gain calculator with a selection of @@ -153,33 +183,33 @@ def test_changes_random(layout, gain_calc): import py.path files_dir = py.path.local(__file__).dirpath() / "data" / "gain_calc_pvs" - import pickle - inputs_f = files_dir / "inputs.pickle" - outputs_f = files_dir / "outputs.npz" + inputs_f = files_dir / "inputs.jsonl.xz" + outputs_f = files_dir / "outputs.jsonl.xz" if inputs_f.check(): - with open(str(inputs_f), 'rb') as f: - inputs = pickle.load(f) + inputs = load_jsonl_xz(str(inputs_f)) else: inputs = list(generate_random_ObjectTypeMetadatas()) inputs_f.dirpath().ensure_dir() - with open(str(inputs_f), 'wb') as f: - pickle.dump(inputs, f, protocol=2) + dump_jsonl_xz(str(inputs_f), inputs) pvs = [gain_calc.render(input) for input in inputs] - direct = np.array([pv.direct for pv in pvs]) - diffuse = np.array([pv.diffuse for pv in pvs]) if outputs_f.check(): - loaded = np.load(str(outputs_f)) - loaded_directs = loaded["direct"] - loaded_diffuses = loaded["diffuse"] - - for input, pv, loaded_direct, loaded_diffuse in zip(inputs, pvs, loaded_directs, loaded_diffuses): - npt.assert_allclose(pv.direct, loaded_direct, atol=1e-10, err_msg=repr(input)) - npt.assert_allclose(pv.diffuse, loaded_diffuse, atol=1e-10, err_msg=repr(input)) + loaded = load_jsonl_xz(str(outputs_f)) + + for input, pv, expected in zip(inputs, pvs, loaded): + npt.assert_allclose( + pv.direct, expected["direct"], atol=1e-10, err_msg=repr(input) + ) + npt.assert_allclose( + pv.diffuse, expected["diffuse"], atol=1e-10, err_msg=repr(input) + ) else: outputs_f.dirpath().ensure_dir() - np.savez_compressed(str(outputs_f), direct=direct, diffuse=diffuse) + pvs_json = [ + dict(direct=pv.direct.tolist(), diffuse=pv.diffuse.tolist()) for pv in pvs + ] + dump_jsonl_xz(str(outputs_f), pvs_json) pytest.skip("generated pv file for gain calc") diff --git a/ear/core/plot_point_source.py b/ear/core/plot_point_source.py index bbcfd77a..27a72d6f 100644 --- a/ear/core/plot_point_source.py +++ b/ear/core/plot_point_source.py @@ -58,7 +58,7 @@ def plot_triangulation(point_source_panner): import mpl_toolkits.mplot3d.art3d # noqa fig = plt.figure() - ax = fig.add_subplot(111, projection='3d', aspect=1) + ax = fig.add_subplot(111, projection='3d', aspect='equal') if isinstance(point_source_panner, point_source.PointSourcePannerDownmix): point_source_panner = point_source_panner.psp diff --git a/ear/core/scenebased/test/__init__.py b/ear/core/scenebased/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ear/core/scenebased/test/test_design.py b/ear/core/scenebased/test/test_design.py new file mode 100644 index 00000000..0839c757 --- /dev/null +++ b/ear/core/scenebased/test/test_design.py @@ -0,0 +1,52 @@ +import numpy as np +import pytest +from ... import hoa, point_source +from ...bs2051 import get_layout +from ...metadata_input import HOATypeMetadata +from ..design import HOADecoderDesign + +# compare against a reference, as otherwise we'd end up reimplementing the +# whole thing, and still would not spot changes in allrad_design + +ref_decoder = np.array( + [ + [1.71634590e-01, 1.42431019e-01, -1.17545274e-01, 1.30305331e-01], + [1.71642551e-01, -1.42433751e-01, -1.17551622e-01, 1.30321095e-01], + [1.13881860e-01, 7.52101654e-06, -8.55161879e-02, 1.23448338e-01], + [3.68460920e-01, 2.13546281e-01, -1.68472356e-01, -2.38326574e-01], + [3.68450573e-01, -2.13530753e-01, -1.68465249e-01, -2.38333209e-01], + [1.30447818e-01, 6.67387033e-02, 1.45593901e-01, 9.44637862e-02], + [1.30433098e-01, -6.67320618e-02, 1.45594222e-01, 9.44437810e-02], + [1.76070328e-01, 9.23726786e-02, 2.01041658e-01, -6.00913147e-02], + [1.76077278e-01, -9.23760367e-02, 2.01043852e-01, -6.00980940e-02], + ] +) + + +@pytest.fixture(scope="module") +def layout(): + return get_layout("4+5+0").without_lfe + + +@pytest.fixture(scope="module") +def panner(layout): + return HOADecoderDesign(layout) + + +@pytest.fixture +def type_metadata(): + order = 1 + acn = np.arange((order + 1) ** 2) + orders, degrees = hoa.from_acn(acn) + + return HOATypeMetadata( + orders=orders.tolist(), + degrees=degrees.tolist(), + normalization="N3D", + ) + + +def test_basic(panner, type_metadata): + decoder = panner.design(type_metadata) + # print(repr(decoder)) + np.testing.assert_allclose(decoder, ref_decoder, atol=1e-6) 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/test/test_hoa.py b/ear/core/select_items/test/test_hoa.py index d56e8c0f..e7736da8 100644 --- a/ear/core/select_items/test/test_hoa.py +++ b/ear/core/select_items/test/test_hoa.py @@ -6,8 +6,7 @@ class HOABuilder(ADMBuilder): - """ADMBuilder with pre-defined encode and decode matrix, with references to - the various components""" + """ADMBuilder with pre-defined first and second order HOA packs""" def __init__(self): super(HOABuilder, self).__init__() diff --git a/ear/fileio/adm/elements/main_elements.py b/ear/fileio/adm/elements/main_elements.py index a769d559..5ccb51a3 100644 --- a/ear/fileio/adm/elements/main_elements.py +++ b/ear/fileio/adm/elements/main_elements.py @@ -134,7 +134,7 @@ class AudioContent(ADMElement): audioContentLanguage (Optional[str]) loudnessMetadata (list[LoudnessMetadata]) dialogue (Optional[int]) - audioObject (list[AudioObject]) + audioObjects (list[AudioObject]) """ audioContentName = attrib(default=None, validator=instance_of(string_types)) diff --git a/ear/test/json.py b/ear/test/json.py new file mode 100644 index 00000000..341f264c --- /dev/null +++ b/ear/test/json.py @@ -0,0 +1,111 @@ +import attrs +import ear.common +import ear.core.metadata_input +import ear.fileio.adm.elements +from fractions import Fraction + + +def value_to_json(value, include_defaults=True): + """turn EAR types into JSON + + This is not complete but will be extended as needed. + + The result can be turned back into objects using json_to_value + + Parameters: + include_defaults (bool): add default values (True), or skip them (False) + Returns: + a json-serialisable object, composed of dict, list, bool, int, float, + str or None + + objects are represented as a dictionary containing the keyword + arguments for the constructor, and a _type key naming the type + """ + + def recurse(v): + return value_to_json(v, include_defaults=include_defaults) + + if value is None: + return None + elif isinstance(value, (bool, int, float, str)): + return value + elif isinstance(value, list): + return [recurse(v) for v in value] + elif isinstance(value, dict): + return {k: recurse(v) for k, v in value.items()} + elif attrs.has(type(value)): + d = dict(_type=type(value).__name__) + + for field in attrs.fields(type(value)): + v = getattr(value, field.name) + + if include_defaults or not _field_is_default(field, v): + d[field.name] = recurse(v) + return d + # types that need special handling; make this generic if we need to add more + elif isinstance(value, Fraction): + return dict( + _type="Fraction", numerator=value.numerator, denominator=value.denominator + ) + else: + assert False, "unknown type" + + +def json_to_value(value): + """turn the results of value_to_json back into objects""" + if isinstance(value, dict): + converted = {k: json_to_value(v) for k, v in value.items()} + if "_type" in value: + t = _known_types[value["_type"]] + del converted["_type"] + return t(**converted) + else: + return converted + if isinstance(value, list): + return [json_to_value(v) for v in value] + else: + return value + + +def _field_is_default(field: attrs.Attribute, value): + """does an attrs field have the default value? + + this depends on values being properly equality-comparable + """ + default = field.default + if default is attrs.NOTHING: + return False + + if isinstance(default, attrs.Factory): + assert not default.takes_self, "not implemented" + + default = default.factory() + + return type(value) is type(default) and value == default + + +def _get_known_types(): + known_types = [ + ear.common.CartesianPosition, + ear.common.CartesianScreen, + ear.common.PolarPosition, + ear.common.PolarScreen, + ear.core.metadata_input.ExtraData, + ear.core.metadata_input.ObjectTypeMetadata, + ear.fileio.adm.elements.AudioBlockFormatObjects, + ear.fileio.adm.elements.CartesianZone, + ear.fileio.adm.elements.ChannelLock, + ear.fileio.adm.elements.Frequency, + ear.fileio.adm.elements.JumpPosition, + ear.fileio.adm.elements.ObjectCartesianPosition, + ear.fileio.adm.elements.ObjectDivergence, + ear.fileio.adm.elements.ObjectPolarPosition, + ear.fileio.adm.elements.PolarZone, + ear.fileio.adm.elements.ScreenEdgeLock, + Fraction, + ] + + return {t.__name__: t for t in known_types} + + +_known_types = _get_known_types() diff --git a/ear/test/test_json.py b/ear/test/test_json.py new file mode 100644 index 00000000..eff764c0 --- /dev/null +++ b/ear/test/test_json.py @@ -0,0 +1,55 @@ +import pytest +from .json import json_to_value, value_to_json +from ear.core.metadata_input import AudioBlockFormatObjects, ObjectTypeMetadata +from fractions import Fraction + + +def _check_json_subset(d1, d2): + """check that dictionaries in d1 are a subset of d2, and that all values + are representable in json""" + + def is_type(t): + return isinstance(d1, t) and isinstance(d2, t) + + if is_type(dict): + for key in d1: + assert key in d2 + _check_json_subset(d1[key], d2[key]) + elif is_type(list): + assert len(d1) == len(d2) + for v1, v2 in zip(d1, d2): + _check_json_subset(v1, v2) + elif is_type(bool) or is_type(int) or is_type(float) or is_type(str): + assert d1 == d2 + elif d1 is None and d2 is None: + pass + else: + assert False, "mismatching or unknown types" + + +@pytest.mark.parametrize("include_defaults", [False, True]) +def test_json_OTM(include_defaults): + otm = ObjectTypeMetadata( + block_format=AudioBlockFormatObjects( + rtime=Fraction(1, 2), position=dict(azimuth=0, elevation=0) + ) + ) + otm_j = value_to_json(otm, include_defaults=include_defaults) + expected = { + "_type": "ObjectTypeMetadata", + "block_format": { + "_type": "AudioBlockFormatObjects", + "position": { + "_type": "ObjectPolarPosition", + "azimuth": 0.0, + "elevation": 0.0, + }, + "rtime": {"_type": "Fraction", "denominator": 2, "numerator": 1}, + }, + } + + _check_json_subset(expected, otm_j) + + otm_j_otm = json_to_value(otm_j) + + assert otm_j_otm == otm diff --git a/flake.lock b/flake.lock index 370f9fd6..a28f70ce 100644 --- a/flake.lock +++ b/flake.lock @@ -17,15 +17,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1651804312, - "narHash": "sha256-DJxOGlxwQccuuwXUS0oRRkcNJbW5UP4fpsL5ga9ZwYw=", + "lastModified": 1693341273, + "narHash": "sha256-wrsPjsIx2767909MPGhSIOmkpGELM9eufqLQOPxmZQg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d59dd43e49f24b58fe8d5ded38cbdf00c3da4dc2", + "rev": "2ab91c8d65c00fd22a441c69bbf1bc9b420d5ea1", "type": "github" }, "original": { "id": "nixpkgs", + "ref": "nixos-23.05", "type": "indirect" } }, diff --git a/flake.nix b/flake.nix index d4457f9e..7cfcaca8 100644 --- a/flake.nix +++ b/flake.nix @@ -1,4 +1,5 @@ { + inputs.nixpkgs.url = "nixpkgs/nixos-23.05"; inputs.flake-utils.url = "github:numtide/flake-utils"; outputs = { self, nixpkgs, flake-utils }: @@ -13,15 +14,26 @@ name = "ear"; src = ./.; propagatedBuildInputs = with python3.pkgs; [ numpy scipy enum34 six attrs multipledispatch lxml pyyaml setuptools ]; + doCheck = true; - checkInputs = with python3.pkgs; [ pytest pytest-cov pytest-datafiles soundfile ]; - postPatch = '' - # latest attrs should be fine... - sed -i "s/'attrs.*'/'attrs'/" setup.py + nativeCheckInputs = with python3.pkgs; [ pytest pytestCheckHook pytest-cov pytest-datafiles soundfile ]; + pytestFlagsArray = [ "ear" ]; + preCheck = '' + export PATH="$PATH:$out/bin" ''; }; defaultPackage = packages.ear; + packages.darker = python3.pkgs.buildPythonPackage rec { + pname = "darker"; + version = "1.7.1"; + src = python3.pkgs.fetchPypi { + inherit pname version; + hash = "sha256-z0FzvkrSmC5bLrq34IvQ0nFz8kWewbHPZq7JKQ2oDM4="; + }; + propagatedBuildInputs = with python3.pkgs; [ black toml isort ]; + }; + devShells.ear = packages.ear.overridePythonAttrs (attrs: { propagatedBuildInputs = attrs.propagatedBuildInputs ++ [ python3.pkgs.matplotlib @@ -33,11 +45,11 @@ python3.pkgs.flake8 python3.pkgs.ipython python3.pkgs.black + packages.darker ]; postShellHook = '' export PYTHONPATH=$(pwd):$PYTHONPATH ''; - # dontUseSetuptoolsShellHook = true; }); devShell = devShells.ear; } diff --git a/setup.py b/setup.py index 4249e44d..4c5ca0b9 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ install_requires=[ 'numpy~=1.14', 'scipy~=1.0', - 'attrs>=19.3,<22', + 'attrs>=22.2', 'PyYAML~=6.0', 'lxml~=4.4', 'six~=1.11', diff --git a/tox.ini b/tox.ini index 73eeefa1..dbca8d72 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ commands = extras = test [pytest] -python_files = *.py +python_files = ear/*.py testpaths = ear addopts = --doctest-modules --pyargs markers =