Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Miscelaneous small fixes #72

Merged
merged 12 commits into from
Apr 9, 2024
2 changes: 1 addition & 1 deletion doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]
# html_static_path = ["_static"]

intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
Expand Down
34 changes: 30 additions & 4 deletions doc/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ To render an ADM file, the following three parameters must be given:

For example, ``ear-render -s 0+5+0 input.wav output_surround.wav`` will render
the BW64/ADM file ``input.wav`` to a ``0+5+0`` target speaker layout and store
the result in ``output_surround.wav``.
the result in ``output_surround.wav``. See :ref:`output_format` for details of
the output file format.

.. argparse::
:module: ear.cmdline.render_file
Expand All @@ -37,7 +38,7 @@ the result in ``output_surround.wav``.
:nodescription:

-l, --layout
See speakers_file_.
See :ref:`speakers_file`.

.. _ear-utils:

Expand All @@ -53,7 +54,31 @@ ADM files as sub-commands.
:prog: ear-utils

--screen
See speakers_file_.
See :ref:`speakers_file`.

.. _output_format:

Output Format
-------------

The output of ``ear-render`` is a BW64 file containing one channel for each
loudspeaker in the specified layout.

The channel order is the same as in the "Loudspeaker configuration" tables in
BS.2051-2 (e.g. table 4 for 0+5+0).

The output may need further processing before being played back on loudspeakers.

In particular, the renderer does not do any bass management -- LFE channels in
the output must be treated according to section 3 or 4 of attachment 1 to annex
7 of BS.775-4. This includes the addition of a 10dB gain, and routing to
loudspeakers or a subwoofer.

The renderer also does not apply any kind of loudspeaker distance compensation
(beyond the gain which may be specified in the speakers file), or EQ.

To illustrate this, if the input to the renderer exactly matches the output
loudspeaker layout, then the output will be identical to the input.

.. _speakers_file:

Expand Down Expand Up @@ -100,7 +125,8 @@ The possible keys are as follows:
A mapping containing the real loudspeaker position, with keys ``az``,
``el`` and ``r`` specifying the azimuth, elevation and distance of the
loudspeaker in ADM angle format (anticlockwise azimuth, degrees) and
metres.
metres. Note that the radius specified is not used to apply distance
compensation.

``gain_linear`` (optional)
A linear gain to apply to this output channel; this is useful for LFE
Expand Down
5 changes: 5 additions & 0 deletions ear/cmdline/render_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ def run(self, input_file, output_file):
output_monitor = PeakMonitor(n_channels)

with openBw64Adm(input_file) as infile:
if infile.adm is None:
raise Exception(
f"error: {input_file!r} does not have ADM metadata (missing 'chna' chunk)"
)

if version_at_least(infile.adm.version, 2):
warnings.warn(
f"rendering of files with version {infile.adm.version} is not standardised"
Expand Down
2 changes: 1 addition & 1 deletion ear/core/select_items/test/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ def test_hoa_pack_format_mismatch():

builder.first_pack.absoluteDistance = 2.0

for i, track in enumerate(builder.first_tracks + builder.second_tracks):
for i, track in enumerate(builder.first_tracks + builder.second_tracks, 1):
builder.create_track_uid(
audioPackFormat=builder.second_pack, audioTrackFormat=track, trackIndex=i
)
Expand Down
19 changes: 18 additions & 1 deletion ear/fileio/adm/chna.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def populate_chna_chunk(chna, adm):
generated before calling this.

Parameters:
adm (ADM): adm structure to get information to
adm (ADM): adm structure to get information from
chna (ChnaChunk): chna chunk to populate
"""
chna.audioIDs = list(_get_chna_entries(adm))
Expand All @@ -157,3 +157,20 @@ def guess_track_indices(adm):
raise Exception("Invalid track UID {}.".format(track_uid.id))

track_uid.trackIndex = int(match.group(1), 16)


def validate_trackIndex(adm, num_channels):
"""Check that all audioTrackUIDs in adm have a trackIndex that is valid in
a file with num_channels.

Parameters:
adm (ADM): adm structure containing audioTrackUIDs to check
num_channels (int): number of channels in the BW64 file
"""
for track_uid in adm.audioTrackUIDs:
if track_uid.trackIndex is not None and track_uid.trackIndex > num_channels:
tracks = "track" if num_channels == 1 else "tracks"
raise Exception(
f"audioTrackUID {track_uid.id} has track index {track_uid.trackIndex} "
f"(1-based) in a file with {num_channels} {tracks}"
)
4 changes: 2 additions & 2 deletions ear/fileio/adm/elements/main_elements.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from attr import attrs, attrib, Factory, validate
from attr.validators import instance_of, optional
from attr.validators import and_, gt, instance_of, optional
from enum import Enum
from fractions import Fraction
from six import string_types
Expand Down Expand Up @@ -511,7 +511,7 @@ class AudioTrackUID(ADMElement):
audioPackFormat (Optional[AudioPackFormat])
"""

trackIndex = attrib(default=None)
trackIndex = attrib(default=None, validator=optional(and_(instance_of(int), gt(0))))
sampleRate = attrib(default=None)
bitDepth = attrib(default=None)
audioTrackFormat = attrib(default=None, repr=False,
Expand Down
21 changes: 20 additions & 1 deletion ear/fileio/adm/test/test_chna.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest
from ...bw64.chunks import AudioID, ChnaChunk
from ..adm import ADM
from ..chna import load_chna_chunk, populate_chna_chunk
from ..chna import load_chna_chunk, populate_chna_chunk, validate_trackIndex
from ..common_definitions import load_common_definitions
from ..elements import AudioTrackUID

Expand Down Expand Up @@ -245,3 +245,22 @@ def test_both_track_channel(self, adm, chna):
expected = "Track UID ATU_00000001 has both track and channel formats."
with pytest.raises(Exception, match=expected):
populate_chna_chunk(chna, adm)


def test_validate_trackIndex(adm):
atu = AudioTrackUID(id="ATU_00000001", trackIndex=1)
adm.addAudioTrackUID(atu)

# no error
validate_trackIndex(adm, 1)

# plural
expected = r"audioTrackUID ATU_00000001 has track index 1 \(1-based\) in a file with 0 tracks"
with pytest.raises(Exception, match=expected):
validate_trackIndex(adm, 0)

# singular
atu.trackIndex = 2
expected = r"audioTrackUID ATU_00000001 has track index 2 \(1-based\) in a file with 1 track"
with pytest.raises(Exception, match=expected):
validate_trackIndex(adm, 1)
12 changes: 8 additions & 4 deletions ear/fileio/adm/test/test_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
from ..generate_ids import generate_ids


pytestmark = pytest.mark.filterwarnings("ignore:use of gainUnit .*")


ns = "urn:ebu:metadata-schema:ebuCore_2015"
nsmap = dict(adm=ns)
E = ElementMaker(namespace=ns, nsmap=nsmap)
Expand Down Expand Up @@ -223,10 +226,11 @@ def test_gain(base):
)

# db
assert (
base.bf_after_mods(set_version(2), add_children(bf_path, E.gain("20", gainUnit="dB"))).gain
== 10.0
)
with pytest.warns(UserWarning, match="gainUnit"):
assert (
base.bf_after_mods(set_version(2), add_children(bf_path, E.gain("20", gainUnit="dB"))).gain
== 10.0
)
expected = "gainUnit must be linear or dB, not 'DB'"
with pytest.raises(ParseError, match=expected):
base.bf_after_mods(set_version(2), add_children(bf_path, E.gain("20", gainUnit="DB")))
Expand Down
1 change: 1 addition & 0 deletions ear/fileio/adm/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ def parse_gain(gain_str, gainUnit):
if gainUnit == "linear":
return gain_num
elif gainUnit == "dB":
warnings.warn("use of gainUnit may not be compatible with older software")
return math.pow(10, gain_num / 20.0)
else:
raise ValueError(f"gainUnit must be linear or dB, not {gainUnit!r}")
Expand Down
26 changes: 26 additions & 0 deletions ear/fileio/bw64/test/test_wav.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,29 @@ def test_openBw64Adm():
assert infile.adm is not None
assert len(infile.adm.audioProgrammes) == 1 # loaded axml
assert infile.adm.audioTrackUIDs[0].trackIndex == 1 # loaded chna


def test_openBw64Adm_plain_wav(tmpdir):
"""check that reading a plain wav results in .adm being None"""
fname = tmpdir / "plain_wav.wav"

with openBw64(fname, "w"):
pass

with openBw64Adm(fname) as f:
assert f.adm is None


def test_openBw64Adm_no_chna(tmpdir):
"""check that parsing a BW64 with AXML but no CHNA raises an error"""
fname = tmpdir / "no_chna.wav"

with openBw64(fname, "w", axml=b"axml"):
pass

with pytest.raises(
ValueError,
match="if 'axml' chunk is present, 'chna' must be too",
):
with openBw64Adm(fname):
pass
28 changes: 23 additions & 5 deletions ear/fileio/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from .adm.adm import ADM
from .adm.xml import load_axml_string
from .adm.common_definitions import load_common_definitions
from .adm.chna import load_chna_chunk
from .adm.chna import load_chna_chunk, validate_trackIndex


def openBw64(filename, mode='r', **kwargs):
Expand Down Expand Up @@ -63,7 +63,11 @@ class Bw64AdmReader(object):
to create these.

Attributes:
adm (ADM): ADM data
adm (Optional[ADM]): ADM data, or None if CHNA is not present

Note:
This throws if 'axml' is present but 'chna' is not, as this is not
valid according to BS.2088-1.
"""

def __init__(self, bw64FileHandle, fix_block_format_durations=False):
Expand Down Expand Up @@ -124,11 +128,25 @@ def iter_sample_blocks(self, blockSize):
return self._bw64.iter_sample_blocks(blockSize)

def _parse_adm(self):
axml = self._bw64.axml
chna = self._bw64.chna

if axml is not None and chna is None:
raise ValueError("if 'axml' chunk is present, 'chna' must be too")

if chna is None:
return None

adm = ADM()
load_common_definitions(adm)
if self._bw64.axml is not None:
if axml is not None:
self.logger.info("Parsing")
load_axml_string(adm, self._bw64.axml, fix_block_format_durations=self._fix_block_format_durations)
load_axml_string(
adm, axml, fix_block_format_durations=self._fix_block_format_durations
)
self.logger.info("Parsing done!")
load_chna_chunk(adm, self._bw64.chna)

load_chna_chunk(adm, chna)
validate_trackIndex(adm, self.channels)

return adm
46 changes: 46 additions & 0 deletions ear/test/test_integrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,52 @@ def test_render_v2(tmpdir):
npt.assert_allclose(samples, expected, atol=1e-6)


def test_plain_wav(tmpdir):
rendered_file = str(tmpdir / "test_plain_wav_out.wav")
args = ["ear-render", "-s", "0+5+0", wav_file, rendered_file]
proc = subprocess.run(args, capture_output=True)

assert proc.returncode != 0

[err_no_adm] = [line for line in proc.stderr.split(b"\n") if line.strip()]
assert b"does not have ADM metadata" in err_no_adm


def test_bad_track_index(tmpdir):
from ..fileio import openBw64
from ..fileio.bw64.chunks import AudioID, ChnaChunk, FormatInfoChunk

bad_track_index = str(tmpdir / "bad_track_index.wav")
rendered_file = str(tmpdir / "bad_track_index_out.wav")

chna = ChnaChunk()
chna.audioIDs.append(
AudioID(
trackIndex=2,
audioTrackUID="ATU_00000001",
audioTrackFormatIDRef="AT_00010003_01",
audioPackFormatIDRef="AP_00010001",
)
)

samples = generate_samples()[:, :1]
fmtInfo = FormatInfoChunk(
formatTag=1, channelCount=samples.shape[1], sampleRate=sr, bitsPerSample=24
)

with openBw64(bad_track_index, "w", chna=chna, formatInfo=fmtInfo) as outfile:
outfile.write(samples)

args = ["ear-render", "-s", "0+5+0", bad_track_index, rendered_file]
proc = subprocess.run(args, capture_output=True)

assert proc.returncode != 0

[err_bad_track] = [line for line in proc.stderr.split(b"\n") if line.strip()]
assert b"ATU_00000001 has track index 2" in err_bad_track
assert b"in a file with 1 track" in err_bad_track


@pytest.mark.parametrize("order", [1, 2])
@pytest.mark.parametrize("chna_only", [False, True])
def test_hoa(tmpdir, order, chna_only):
Expand Down
5 changes: 5 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
python3.pkgs.pip
packages.darker
python3.pkgs.venvShellHook

# for building docs
python3.pkgs.sphinx
python3.pkgs.sphinx-rtd-theme
pkgs.graphviz
];
venvDir = "./venv";
postShellHook = ''
Expand Down
Loading