Ruffification and fixed bug

This commit is contained in:
2026-09-17 10:19:11 -07:00
parent 13d9cd4914
commit e6320e84c6
25 changed files with 787 additions and 659 deletions
+28 -28
View File
@@ -15,15 +15,16 @@
# import importlib # import importlib
import os import os
import sys import sys
sys.path.insert(0, os.path.abspath('../../src'))
sys.path.insert(0, os.path.abspath("../../src"))
sys.path.insert(0, os.path.abspath("../../../src")) sys.path.insert(0, os.path.abspath("../../../src"))
print(sys.path) print(sys.path)
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
project = u'wavinfo' project = "wavinfo"
copyright = u'2018-2025, Jamie Hardt' copyright = "2018-2025, Jamie Hardt"
author = u'Jamie Hardt' author = "Jamie Hardt"
# The short X.Y version # The short X.Y version
version = "4.0" version = "4.0"
@@ -42,34 +43,34 @@ release = "4.0.0"
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = [ extensions = [
'sphinx.ext.autodoc', "sphinx.ext.autodoc",
'sphinx.ext.todo', "sphinx.ext.todo",
'sphinx.ext.coverage', "sphinx.ext.coverage",
] ]
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates'] templates_path = ["_templates"]
# The suffix(es) of source filenames. # The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string: # You can specify multiple suffix as a list of string:
# #
# source_suffix = ['.rst', '.md'] # source_suffix = ['.rst', '.md']
source_suffix = '.rst' source_suffix = ".rst"
# The master toctree document. # The master toctree document.
master_doc = 'index' master_doc = "index"
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.
# #
# This is also used if you do content translation via gettext catalogs. # This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases. # Usually you set "language" from the command line for these cases.
language = 'en' language = "en"
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path. # This pattern also affects html_static_path and html_extra_path.
exclude_patterns = [u'_build', 'Thumbs.db', '.DS_Store'] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The name of the Pygments (syntax highlighting) style to use. # The name of the Pygments (syntax highlighting) style to use.
pygments_style = None pygments_style = None
@@ -80,7 +81,7 @@ pygments_style = None
# The theme to use for HTML and HTML Help pages. See the documentation for # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # a list of builtin themes.
# #
html_theme = 'sphinx_rtd_theme' html_theme = "sphinx_rtd_theme"
# Theme options are theme-specific and customize the look and feel of a theme # Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the # further. For a list of options available for each theme, see the
@@ -91,7 +92,7 @@ html_theme = 'sphinx_rtd_theme'
# Add any paths that contain custom static files (such as style sheets) here, # 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, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static'] html_static_path = ["_static"]
# Custom sidebar templates, must be a dictionary that maps document names # Custom sidebar templates, must be a dictionary that maps document names
# to template names. # to template names.
@@ -107,7 +108,7 @@ html_static_path = ['_static']
# -- Options for HTMLHelp output --------------------------------------------- # -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder. # Output file base name for HTML help builder.
htmlhelp_basename = 'wavinfodoc' htmlhelp_basename = "wavinfodoc"
# -- Options for LaTeX output ------------------------------------------------ # -- Options for LaTeX output ------------------------------------------------
@@ -116,15 +117,12 @@ latex_elements = {
# The paper size ('letterpaper' or 'a4paper'). # The paper size ('letterpaper' or 'a4paper').
# #
# 'papersize': 'letterpaper', # 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt'). # The font size ('10pt', '11pt' or '12pt').
# #
# 'pointsize': '10pt', # 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble. # Additional stuff for the LaTeX preamble.
# #
# 'preamble': '', # 'preamble': '',
# Latex figure (float) alignment # Latex figure (float) alignment
# #
# 'figure_align': 'htbp', # 'figure_align': 'htbp',
@@ -134,8 +132,7 @@ latex_elements = {
# (source start file, target name, title, # (source start file, target name, title,
# author, documentclass [howto, manual, or own class]). # author, documentclass [howto, manual, or own class]).
latex_documents = [ latex_documents = [
(master_doc, 'wavinfo.tex', u'wavinfo Documentation', (master_doc, "wavinfo.tex", "wavinfo Documentation", "Jamie Hardt", "manual"),
u'Jamie Hardt', 'manual'),
] ]
@@ -143,10 +140,7 @@ latex_documents = [
# One entry per manual page. List of tuples # One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section). # (source start file, name, description, authors, manual section).
man_pages = [ man_pages = [(master_doc, "wavinfo", "wavinfo Documentation", [author], 1)]
(master_doc, 'wavinfo', u'wavinfo Documentation',
[author], 1)
]
# -- Options for Texinfo output ---------------------------------------------- # -- Options for Texinfo output ----------------------------------------------
@@ -155,9 +149,15 @@ man_pages = [
# (source start file, target name, title, author, # (source start file, target name, title, author,
# dir menu entry, description, category) # dir menu entry, description, category)
texinfo_documents = [ texinfo_documents = [
(master_doc, 'wavinfo', u'wavinfo Documentation', (
author, 'wavinfo', 'One line description of project.', master_doc,
'Miscellaneous'), "wavinfo",
"wavinfo Documentation",
author,
"wavinfo",
"One line description of project.",
"Miscellaneous",
),
] ]
@@ -176,7 +176,7 @@ epub_title = project
# epub_uid = '' # epub_uid = ''
# A list of files that should not be packed into the epub file. # A list of files that should not be packed into the epub file.
epub_exclude_files = ['search.html'] epub_exclude_files = ["search.html"]
# -- Extension configuration ------------------------------------------------- # -- Extension configuration -------------------------------------------------
+10 -3
View File
@@ -26,7 +26,7 @@
"source": [ "source": [
"from wavinfo import WavInfoReader\n", "from wavinfo import WavInfoReader\n",
"\n", "\n",
"path = '../tests/test_files/sounddevices/A101_1.WAV'\n", "path = \"../tests/test_files/sounddevices/A101_1.WAV\"\n",
"\n", "\n",
"info = WavInfoReader(path)" "info = WavInfoReader(path)"
] ]
@@ -113,7 +113,12 @@
} }
], ],
"source": [ "source": [
"(info.fmt.sample_rate, info.fmt.channel_count, info.fmt.block_align, info.fmt.bits_per_sample)" "(\n",
" info.fmt.sample_rate,\n",
" info.fmt.channel_count,\n",
" info.fmt.block_align,\n",
" info.fmt.bits_per_sample,\n",
")"
] ]
}, },
{ {
@@ -271,7 +276,9 @@
], ],
"source": [ "source": [
"path = \"../tests/test_files/cue_chunks/izotoperx_cues_test.wav\"\n", "path = \"../tests/test_files/cue_chunks/izotoperx_cues_test.wav\"\n",
"info = WavInfoReader(path, info_encoding=\"utf-8\") # iZotope RX seems to encode marker text as UTF-8\n", "info = WavInfoReader(\n",
" path, info_encoding=\"utf-8\"\n",
") # iZotope RX seems to encode marker text as UTF-8\n",
"\n", "\n",
"for cue in info.cues.each_cue():\n", "for cue in info.cues.each_cue():\n",
" print(f\"Cue ID: {cue[0]}\")\n", " print(f\"Cue ID: {cue[0]}\")\n",
+1 -2
View File
@@ -2,8 +2,7 @@
Probe WAVE Files for iXML, Broadcast-WAVE and other metadata. Probe WAVE Files for iXML, Broadcast-WAVE and other metadata.
""" """
__all__ = ['WavInfoReader', 'WavInfoEOFError'] __all__ = ["WavInfoReader", "WavInfoEOFError"]
from .wave_reader import WavInfoReader from .wave_reader import WavInfoReader
from .riff_parser import WavInfoEOFError from .riff_parser import WavInfoEOFError
+35 -34
View File
@@ -18,7 +18,7 @@ class MyJSONEncoder(json.JSONEncoder):
if isinstance(o, Enum): if isinstance(o, Enum):
return o._name_ return o._name_
elif isinstance(o, bytes): elif isinstance(o, bytes):
return 'base64:' + b64encode(o).decode('ascii') return "base64:" + b64encode(o).decode("ascii")
else: else:
return super().default(o) return super().default(o)
@@ -50,7 +50,7 @@ class MetaBrowser(Cmd):
if isinstance(val, int): if isinstance(val, int):
print(f" - {key}: {val}") print(f" - {key}: {val}")
elif isinstance(val, str): elif isinstance(val, str):
print(f" - {key}: \"{val}\"") print(f' - {key}: "{val}"')
elif isinstance(val, dict): elif isinstance(val, dict):
print(f" - {key}: Dict ({len(val)} keys)") print(f" - {key}: Dict ({len(val)} keys)")
elif isinstance(val, list): elif isinstance(val, list):
@@ -63,7 +63,7 @@ class MetaBrowser(Cmd):
print(f" - {key}: Unknown") print(f" - {key}: Unknown")
def do_ls(self, _): def do_ls(self, _):
'List items at the current node: LS' "List items at the current node: LS"
root = self.cwd root = self.cwd
if isinstance(root, list): if isinstance(root, list):
@@ -94,7 +94,7 @@ class MetaBrowser(Cmd):
if argv[0] in self.cwd.keys(): if argv[0] in self.cwd.keys():
self.path = self.path + [argv[0]] self.path = self.path + [argv[0]]
else: else:
print(f"Key \"{argv[0]}\" does not exist") print(f'Key "{argv[0]}" does not exist')
if len(self.path) > 0: if len(self.path) > 0:
self.prompt = "(" + "/".join(self.path) + ") " self.prompt = "(" + "/".join(self.path) + ") "
@@ -102,41 +102,40 @@ class MetaBrowser(Cmd):
self.prompt = "(wavinfo) " self.prompt = "(wavinfo) "
def do_bye(self, _): def do_bye(self, _):
'Exit the interactive browser: BYE' "Exit the interactive browser: BYE"
return True return True
def main(): def main():
version = importlib.metadata.version('wavinfo') version = importlib.metadata.version("wavinfo")
manpath = os.path.dirname(__file__) + "/man" manpath = os.path.dirname(__file__) + "/man"
parser = OptionParser() parser = OptionParser()
parser.usage = 'wavinfo (--adm | --ixml) <FILE> +' parser.usage = "wavinfo (--adm | --ixml) <FILE> +"
# parser.add_option('--install-manpages', # parser.add_option('--install-manpages',
# help="Install manual pages for wavinfo", # help="Install manual pages for wavinfo",
# default=False, # default=False,
# action='store_true') # action='store_true')
parser.add_option('--man', parser.add_option(
help="Read the manual and exit.", "--man", help="Read the manual and exit.", default=False, action="store_true"
default=False, )
action='store_true')
parser.add_option('--adm', dest='adm', parser.add_option(
help='Output ADM XML', "--adm", dest="adm", help="Output ADM XML", default=False, action="store_true"
default=False, )
action='store_true')
parser.add_option('--ixml', dest='ixml', parser.add_option(
help='Output iXML', "--ixml", dest="ixml", help="Output iXML", default=False, action="store_true"
default=False, )
action='store_true')
parser.add_option('-i', parser.add_option(
help='Read metadata with an interactive prompt', "-i",
default=False, help="Read metadata with an interactive prompt",
action='store_true') default=False,
action="store_true",
)
(options, args) = parser.parse_args(sys.argv) (options, args) = parser.parse_args(sys.argv)
@@ -149,6 +148,7 @@ def main():
if options.man: if options.man:
import shlex import shlex
print("Which man page?") print("Which man page?")
print("1) wavinfo usage") print("1) wavinfo usage")
print("7) General info on Wave file metadata") print("7) General info on Wave file metadata")
@@ -176,26 +176,27 @@ def main():
raise MissingDataError("ixml") raise MissingDataError("ixml")
else: else:
ret_dict = { ret_dict = {
'filename': arg, "filename": arg,
'run_date': datetime.datetime.now().isoformat(), "run_date": datetime.datetime.now().isoformat(),
'application': f"wavinfo {version}", "application": f"wavinfo {version}",
'scopes': {} "scopes": {},
} }
for scope, name, value in this_file.walk(): for scope, name, value in this_file.walk():
if scope not in ret_dict['scopes'].keys(): if scope not in ret_dict["scopes"].keys():
ret_dict['scopes'][scope] = {} ret_dict["scopes"][scope] = {}
ret_dict['scopes'][scope][name] = value ret_dict["scopes"][scope][name] = value
if options.i: if options.i:
interactive_dict.append(ret_dict) interactive_dict.append(ret_dict)
else: else:
json.dump(ret_dict, cls=MyJSONEncoder, fp=sys.stdout, json.dump(ret_dict, cls=MyJSONEncoder, fp=sys.stdout, indent=2)
indent=2)
except MissingDataError as e: except MissingDataError as e:
print("MissingDataError: Missing metadata (%s) in file %s" % print(
(e, arg), file=sys.stderr) "MissingDataError: Missing metadata (%s) in file %s" % (e, arg),
file=sys.stderr,
)
continue continue
except Exception as e: except Exception as e:
raise e raise e
+12 -11
View File
@@ -1,4 +1,5 @@
import struct import struct
# from collections import namedtuple # from collections import namedtuple
from typing import NamedTuple, Dict from typing import NamedTuple, Dict
@@ -10,38 +11,38 @@ class RF64Context(NamedTuple):
bigchunk_table: Dict[str, int] bigchunk_table: Dict[str, int]
def parse_rf64(stream, signature=b'RF64') -> RF64Context: def parse_rf64(stream, signature=b"RF64") -> RF64Context:
start = stream.tell() start = stream.tell()
assert stream.read(4) == b'WAVE' assert stream.read(4) == b"WAVE"
ds64_chunk = riff_parser.parse_chunk(stream) ds64_chunk = riff_parser.parse_chunk(stream)
assert type(ds64_chunk) is riff_parser.ChunkDescriptor, \ assert type(ds64_chunk) is riff_parser.ChunkDescriptor, (
f"Expected ds64 chunk here, found {type(ds64_chunk)}" f"Expected ds64 chunk here, found {type(ds64_chunk)}"
)
ds64_field_spec = "<QQQI" ds64_field_spec = "<QQQI"
ds64_fields_size = struct.calcsize(ds64_field_spec) ds64_fields_size = struct.calcsize(ds64_field_spec)
assert ds64_chunk.ident == b'ds64' assert ds64_chunk.ident == b"ds64"
ds64_data = ds64_chunk.read_data(stream) ds64_data = ds64_chunk.read_data(stream)
assert len(ds64_data) >= ds64_fields_size assert len(ds64_data) >= ds64_fields_size
riff_size, data_size, sample_count, length_lookup_table = struct.unpack( riff_size, data_size, sample_count, length_lookup_table = struct.unpack(
ds64_field_spec, ds64_data[0:ds64_fields_size] ds64_field_spec, ds64_data[0:ds64_fields_size]
) )
bigchunk_table = {} bigchunk_table = {}
chunksize64format = "<4sL" chunksize64format = "<4sL"
# chunksize64size = struct.calcsize(chunksize64format) # chunksize64size = struct.calcsize(chunksize64format)
for _ in range(length_lookup_table): for _ in range(length_lookup_table):
bigname, bigsize = struct.unpack_from(chunksize64format, bigname, bigsize = struct.unpack_from(
ds64_data, chunksize64format, ds64_data, offset=ds64_fields_size
offset=ds64_fields_size) )
bigchunk_table[bigname] = bigsize bigchunk_table[bigname] = bigsize
bigchunk_table[b'data'] = data_size bigchunk_table[b"data"] = data_size
bigchunk_table[signature] = riff_size bigchunk_table[signature] = riff_size
stream.seek(start, 0) stream.seek(start, 0)
return RF64Context(sample_count=sample_count, return RF64Context(sample_count=sample_count, bigchunk_table=bigchunk_table)
bigchunk_table=bigchunk_table)
+12 -11
View File
@@ -12,7 +12,7 @@ class WavInfoEOFError(EOFError):
class ListChunkDescriptor(NamedTuple): class ListChunkDescriptor(NamedTuple):
signature: bytes signature: bytes
children: List[Union['ChunkDescriptor', 'ListChunkDescriptor']] children: List[Union["ChunkDescriptor", "ListChunkDescriptor"]]
class ChunkDescriptor(NamedTuple): class ChunkDescriptor(NamedTuple):
@@ -48,14 +48,15 @@ def parse_chunk(stream, rf64_context=None):
if len(ident) != 4 or len(size_bytes) != 4: if len(ident) != 4 or len(size_bytes) != 4:
raise WavInfoEOFError(identifier=ident, chunk_start=header_start) raise WavInfoEOFError(identifier=ident, chunk_start=header_start)
data_size = struct.unpack('<I', size_bytes)[0] data_size = struct.unpack("<I", size_bytes)[0]
if data_size == 0xFFFFFFFF: if data_size == 0xFFFFFFFF:
if rf64_context is None and ident in {b'RF64', b'BW64'}: if rf64_context is None and ident in {b"RF64", b"BW64"}:
rf64_context = parse_rf64(stream=stream, signature=ident) rf64_context = parse_rf64(stream=stream, signature=ident)
assert rf64_context is not None, \ assert rf64_context is not None, (
"Sentinel data size 0xFFFFFFFF found outside of RF64 context" "Sentinel data size 0xFFFFFFFF found outside of RF64 context"
)
data_size = rf64_context.bigchunk_table[ident] data_size = rf64_context.bigchunk_table[ident]
@@ -63,14 +64,14 @@ def parse_chunk(stream, rf64_context=None):
if displacement % 2: if displacement % 2:
displacement += 1 displacement += 1
if ident in {b'RIFF', b'LIST', b'RF64', b'BW64', b'list'}: if ident in {b"RIFF", b"LIST", b"RF64", b"BW64", b"list"}:
return parse_list_chunk(stream=stream, length=data_size, return parse_list_chunk(
rf64_context=rf64_context) stream=stream, length=data_size, rf64_context=rf64_context
)
else: else:
data_start = stream.tell() data_start = stream.tell()
stream.seek(displacement, 1) stream.seek(displacement, 1)
return ChunkDescriptor(ident=ident, return ChunkDescriptor(
start=data_start, ident=ident, start=data_start, length=data_size, rf64_context=rf64_context
length=data_size, )
rf64_context=rf64_context)
+97 -78
View File
@@ -2,15 +2,14 @@
ADM Reader ADM Reader
""" """
from struct import unpack, unpack_from, calcsize
from io import BytesIO
from collections import namedtuple from collections import namedtuple
from io import BytesIO
from struct import calcsize, unpack, unpack_from
from typing import Optional from typing import Optional
from lxml import etree as ET from lxml import etree as ET
ChannelEntry = namedtuple("ChannelEntry", "track_index uid track_ref pack_ref")
ChannelEntry = namedtuple('ChannelEntry', "track_index uid track_ref pack_ref")
class WavADMReader: class WavADMReader:
@@ -32,19 +31,19 @@ class WavADMReader:
offset = calcsize(header_fmt) offset = calcsize(header_fmt)
for _ in range(uid_count): for _ in range(uid_count):
track_index, uid, track_ref, pack_ref = unpack_from(
track_index, uid, track_ref, pack_ref = unpack_from(uid_fmt, uid_fmt, chna_data, offset
chna_data, )
offset)
# these values are either ascii or all null # these values are either ascii or all null
self.channel_uids.append( self.channel_uids.append(
ChannelEntry(track_index - 1, ChannelEntry(
uid.decode('ascii'), track_index - 1,
track_ref.decode('ascii'), uid.decode("ascii"),
pack_ref.decode('ascii') track_ref.decode("ascii"),
) pack_ref.decode("ascii"),
)
) )
offset += calcsize(uid_fmt) offset += calcsize(uid_fmt)
@@ -65,40 +64,40 @@ class WavADMReader:
afext = self.axml.find(".//audioFormatExtended", namespaces=nsmap) afext = self.axml.find(".//audioFormatExtended", namespaces=nsmap)
program = afext.find("audioProgramme", namespaces=nsmap) program = afext.find("audioProgramme", namespaces=nsmap)
ret_dict['programme_id'] = program.get("audioProgrammeID") ret_dict["programme_id"] = program.get("audioProgrammeID")
ret_dict['programme_name'] = program.get("audioProgrammeName") ret_dict["programme_name"] = program.get("audioProgrammeName")
ret_dict['programme_start'] = program.get("start") ret_dict["programme_start"] = program.get("start")
ret_dict['programme_end'] = program.get("end") ret_dict["programme_end"] = program.get("end")
ret_dict['contents'] = [] ret_dict["contents"] = []
for content_ref in program.findall("audioContentIDRef", for content_ref in program.findall("audioContentIDRef", namespaces=nsmap):
namespaces=nsmap):
content_dict = dict() content_dict = dict()
content_dict['content_id'] = cid = content_ref.text content_dict["content_id"] = cid = content_ref.text
content = afext.find("audioContent[@audioContentID='%s']" % cid, content = afext.find(
namespaces=nsmap) "audioContent[@audioContentID='%s']" % cid, namespaces=nsmap
content_dict['content_name'] = content.get("audioContentName") )
content_dict['objects'] = [] content_dict["content_name"] = content.get("audioContentName")
content_dict["objects"] = []
for object_ref in content.findall("audioObjectIDRef", for object_ref in content.findall("audioObjectIDRef", namespaces=nsmap):
namespaces=nsmap):
object_dict = dict() object_dict = dict()
object_dict['object_id'] = oid = object_ref.text object_dict["object_id"] = oid = object_ref.text
object = afext.find("audioObject[@audioObjectID='%s']" % oid, object = afext.find(
namespaces=nsmap) "audioObject[@audioObjectID='%s']" % oid, namespaces=nsmap
)
pack = object.find("audioPackFormatIDRef", namespaces=nsmap) pack = object.find("audioPackFormatIDRef", namespaces=nsmap)
object_dict['object_name'] = object.get("audioObjectName") object_dict["object_name"] = object.get("audioObjectName")
object_dict['object_start'] = object.get("start") object_dict["object_start"] = object.get("start")
object_dict['object_duration'] = object.get("duration") object_dict["object_duration"] = object.get("duration")
object_dict['pack_id'] = pack.text object_dict["pack_id"] = pack.text
track_uid_list = [] track_uid_list = []
for t in object.findall("audioTrackUIDRef", namespaces=nsmap): for t in object.findall("audioTrackUIDRef", namespaces=nsmap):
track_uid_list.append(t.text) track_uid_list.append(t.text)
object_dict['track_uids'] = track_uid_list object_dict["track_uids"] = track_uid_list
content_dict['objects'].append(object_dict) content_dict["objects"].append(object_dict)
ret_dict['contents'].append(content_dict) ret_dict["contents"].append(content_dict)
return ret_dict return ret_dict
@@ -111,8 +110,9 @@ class WavADMReader:
*object_name*, *object_id*, *object_name*, *object_id*,
*pack_format_name*, *pack_type*, *channel_format_name* *pack_format_name*, *pack_type*, *channel_format_name*
""" """
channel_info = next((x for x in self.channel_uids channel_info = next(
if x.track_index == index), None) (x for x in self.channel_uids if x.track_index == index), None
)
if channel_info is None: if channel_info is None:
return None return None
@@ -121,60 +121,78 @@ class WavADMReader:
nsmap = self.axml.getroot().nsmap nsmap = self.axml.getroot().nsmap
afext = self.axml.find(".//audioFormatExtended", afext = self.axml.find(".//audioFormatExtended", namespaces=nsmap)
namespaces=nsmap)
trackformat_elem = afext.find( trackformat_elem = afext.find(
"audioTrackFormat[@audioTrackFormatID='%s']" "audioTrackFormat[@audioTrackFormatID='%s']" % channel_info.track_ref,
% channel_info.track_ref, namespaces=nsmap) namespaces=nsmap,
)
stream_id = trackformat_elem[0].text stream_id = trackformat_elem[0].text
channelformatref_elem = afext.find( channelformatref_elem = afext.find(
("audioStreamFormat[@audioStreamFormatID='%s']" ("audioStreamFormat[@audioStreamFormatID='%s']/audioChannelFormatIDRef")
"/audioChannelFormatIDRef") % stream_id, % stream_id,
namespaces=nsmap) namespaces=nsmap,
)
if channelformatref_elem: if channelformatref_elem is not None:
channelformat_id = channelformatref_elem.text channelformat_id = channelformatref_elem.text
else:
channelformat_id = None
packformatref_elem = afext.find( packformatref_elem = afext.find(
("audioStreamFormat[@audioStreamFormatID='%s']" ("audioStreamFormat[@audioStreamFormatID='%s']/audioPackFormatIDRef")
"/audioPackFormatIDRef") % stream_id, % stream_id,
namespaces=nsmap) namespaces=nsmap,
)
if packformatref_elem: if packformatref_elem is not None:
packformat_id = packformatref_elem.text packformat_id = packformatref_elem.text
else:
packformat_id = None
channelformat_elem = afext\ if channelformat_id:
.find("audioChannelFormat[@audioChannelFormatID='%s']" channelformat_elem = afext.find(
% channelformat_id, "audioChannelFormat[@audioChannelFormatID='%s']" % channelformat_id,
namespaces=nsmap) namespaces=nsmap,
ret_dict['channel_format_name'] = channelformat_elem.get( )
"audioChannelFormatName") ret_dict["channel_format_name"] = channelformat_elem.get(
"audioChannelFormatName"
)
else:
ret_dict["channel_format_name"] = None
packformat_elem = afext.find( packformat_elem = afext.find(
"audioPackFormat[@audioPackFormatID='%s']" % packformat_id, "audioPackFormat[@audioPackFormatID='%s']" % packformat_id, namespaces=nsmap
namespaces=nsmap) )
ret_dict['pack_type'] = packformat_elem.get( if packformat_elem is not None:
"typeDefinition") ret_dict["pack_type"] = packformat_elem.get("typeDefinition")
ret_dict['pack_format_name'] = packformat_elem.get( ret_dict["pack_format_name"] = packformat_elem.get("audioPackFormatName")
"audioPackFormatName") else:
ret_dict["pack_type"] = None
ret_dict["pack_format_name"] = None
object_elem = afext.find("audioObject[audioPackFormatIDRef = '%s']" object_elem = afext.find(
% packformat_id, "audioObject[audioPackFormatIDRef = '%s']" % packformat_id, namespaces=nsmap
namespaces=nsmap) )
ret_dict['audio_object_name'] = object_elem.get("audioObjectName") if object_elem is not None:
object_id = object_elem.get("audioObjectID") ret_dict["audio_object_name"] = object_elem.get("audioObjectName")
ret_dict['object_id'] = object_id object_id = object_elem.get("audioObjectID")
ret_dict["object_id"] = object_id
content_elem = afext.find("audioContent/[audioObjectIDRef = '%s']" content_elem = afext.find(
% object_id, "audioContent/[audioObjectIDRef = '%s']" % object_id, namespaces=nsmap
namespaces=nsmap) )
ret_dict['content_name'] = content_elem.get("audioContentName") ret_dict["content_name"] = content_elem.get("audioContentName")
ret_dict['content_id'] = content_elem.get("audioContentID") ret_dict["content_id"] = content_elem.get("audioContentID")
else:
ret_dict["audio_object_name"] = None
ret_dict["object_id"] = None
ret_dict["content_name"] = None
ret_dict["content_id"] = None
return ret_dict return ret_dict
@@ -188,6 +206,7 @@ class WavADMReader:
rd.update(self.track_info(channel_uid_rec.track_index)) rd.update(self.track_info(channel_uid_rec.track_index))
return rd return rd
return dict(channel_entries=list(map(lambda z: make_entry(z), return dict(
self.channel_uids)), channel_entries=list(map(lambda z: make_entry(z), self.channel_uids)),
programme=self.programme()) programme=self.programme(),
)
+22 -19
View File
@@ -13,16 +13,18 @@ class WavBextReader:
the BEXT metadata scope. According to EBU Rec 3285 this shall be the BEXT metadata scope. According to EBU Rec 3285 this shall be
ASCII. ASCII.
""" """
packstring = "<256s" + "32s" + "32s" + "10s" + "8s" + "QH" + "64s" + \ packstring = (
"hhhhh" + "180s" "<256s" + "32s" + "32s" + "10s" + "8s" + "QH" + "64s" + "hhhhh" + "180s"
)
rest_starts = struct.calcsize(packstring) rest_starts = struct.calcsize(packstring)
unpacked = struct.unpack(packstring, bext_data[:rest_starts]) unpacked = struct.unpack(packstring, bext_data[:rest_starts])
def sanitize_bytes(b: bytes) -> str: def sanitize_bytes(b: bytes) -> str:
# honestly can't remember why I'm stripping nulls this way # honestly can't remember why I'm stripping nulls this way
first_null = next((index for index, byte in enumerate(b) first_null = next(
if byte == 0), None) (index for index, byte in enumerate(b) if byte == 0), None
)
trimmed = b if first_null is None else b[:first_null] trimmed = b if first_null is None else b[:first_null]
decoded = trimmed.decode(encoding) decoded = trimmed.decode(encoding)
return decoded return decoded
@@ -91,18 +93,19 @@ class WavBextReader:
# umid_str = None # umid_str = None
return {'description': self.description, return {
'originator': self.originator, "description": self.description,
'originator_ref': self.originator_ref, "originator": self.originator,
'originator_date': self.originator_date, "originator_ref": self.originator_ref,
'originator_time': self.originator_time, "originator_date": self.originator_date,
'time_reference': self.time_reference, "originator_time": self.originator_time,
'version': self.version, "time_reference": self.time_reference,
'umid': self.umid, "version": self.version,
'coding_history': self.coding_history, "umid": self.umid,
'loudness_value': self.loudness_value, "coding_history": self.coding_history,
'loudness_range': self.loudness_range, "loudness_value": self.loudness_value,
'max_true_peak': self.max_true_peak, "loudness_range": self.loudness_range,
'max_momentary_loudness': self.max_momentary_loudness, "max_true_peak": self.max_true_peak,
'max_shortterm_loudness': self.max_shortterm_loudness "max_momentary_loudness": self.max_momentary_loudness,
} "max_shortterm_loudness": self.max_shortterm_loudness,
}
+59 -45
View File
@@ -7,11 +7,12 @@ IBM Corporation and Microsoft Corporation
https://www.aelius.com/njh/wavemetatools/doc/riffmci.pdf https://www.aelius.com/njh/wavemetatools/doc/riffmci.pdf
""" """
from dataclasses import dataclass from dataclasses import dataclass
from .riff_parser import ChunkDescriptor from .riff_parser import ChunkDescriptor
from struct import unpack, calcsize from struct import unpack, calcsize
from typing import Optional, Tuple, NamedTuple, List, Dict, Any, Generator from typing import Optional, Tuple, NamedTuple, List, Dict, Any, Generator
#: Country Codes used in the RIFF standard to resolve locale. These codes #: Country Codes used in the RIFF standard to resolve locale. These codes
#: appear in CSET and LTXT metadata. #: appear in CSET and LTXT metadata.
@@ -100,6 +101,7 @@ class CueEntry(NamedTuple):
""" """
A ``cue`` element structure. A ``cue`` element structure.
""" """
#: Cue "name" or id number #: Cue "name" or id number
name: int name: int
#: Cue position, as a frame count in the play order of the WAVE file. In #: Cue position, as a frame count in the play order of the WAVE file. In
@@ -118,29 +120,37 @@ class CueEntry(NamedTuple):
return calcsize(cls.Format) return calcsize(cls.Format)
@classmethod @classmethod
def read(cls, data: bytes) -> 'CueEntry': def read(cls, data: bytes) -> "CueEntry":
assert len(data) == cls.format_size(), \ assert len(data) == cls.format_size(), (
(f"cue data size incorrect, expected {calcsize(cls.Format)} " f"cue data size incorrect, expected {calcsize(cls.Format)} "
"found {len(data)}") "found {len(data)}"
)
parsed = unpack(cls.Format, data) parsed = unpack(cls.Format, data)
return cls(name=parsed[0], position=parsed[1], chunk_id=parsed[2], return cls(
chunk_start=parsed[3], block_start=parsed[4], name=parsed[0],
sample_offset=parsed[5]) position=parsed[1],
chunk_id=parsed[2],
chunk_start=parsed[3],
block_start=parsed[4],
sample_offset=parsed[5],
)
class LabelEntry(NamedTuple): class LabelEntry(NamedTuple):
""" """
A ``labl`` structure. A ``labl`` structure.
""" """
name: int name: int
text: str text: str
@classmethod @classmethod
def read(cls, data: bytes, encoding: str): def read(cls, data: bytes, encoding: str):
return cls(name=unpack("<I", data[0:4])[0], return cls(
text=data[4:].decode(encoding).rstrip("\0")) name=unpack("<I", data[0:4])[0], text=data[4:].decode(encoding).rstrip("\0")
)
NoteEntry = LabelEntry NoteEntry = LabelEntry
@@ -150,6 +160,7 @@ class RangeLabel(NamedTuple):
""" """
A ``ltxt`` structure. A ``ltxt`` structure.
""" """
name: int name: int
length: int length: int
purpose: str purpose: str
@@ -162,21 +173,26 @@ class RangeLabel(NamedTuple):
@classmethod @classmethod
def read(cls, data: bytes, fallback_encoding: str): def read(cls, data: bytes, fallback_encoding: str):
leader_struct_fmt = "<II4sHHHH" leader_struct_fmt = "<II4sHHHH"
parsed = unpack(leader_struct_fmt, data[0:calcsize(leader_struct_fmt)]) parsed = unpack(leader_struct_fmt, data[0 : calcsize(leader_struct_fmt)])
text_data = data[calcsize(leader_struct_fmt):] text_data = data[calcsize(leader_struct_fmt) :]
if data[6] != 0: if data[6] != 0:
fallback_encoding = f"cp{data[6]}" fallback_encoding = f"cp{data[6]}"
return cls(name=parsed[0], length=parsed[1], purpose=parsed[2], return cls(
country=parsed[3], language=parsed[4], name=parsed[0],
dialect=parsed[5], codepage=parsed[6], length=parsed[1],
text=text_data.decode(fallback_encoding)) purpose=parsed[2],
country=parsed[3],
language=parsed[4],
dialect=parsed[5],
codepage=parsed[6],
text=text_data.decode(fallback_encoding),
)
@dataclass @dataclass
class WavCuesReader: class WavCuesReader:
#: Every ``cue`` entry in the file #: Every ``cue`` entry in the file
cues: List[CueEntry] cues: List[CueEntry]
@@ -190,13 +206,15 @@ class WavCuesReader:
notes: List[NoteEntry] notes: List[NoteEntry]
@classmethod @classmethod
def read_all(cls, f, def read_all(
cues: Optional[ChunkDescriptor], cls,
labls: List[ChunkDescriptor], f,
ltxts: List[ChunkDescriptor], cues: Optional[ChunkDescriptor],
notes: List[ChunkDescriptor], labls: List[ChunkDescriptor],
fallback_encoding: str) -> 'WavCuesReader': ltxts: List[ChunkDescriptor],
notes: List[ChunkDescriptor],
fallback_encoding: str,
) -> "WavCuesReader":
cue_list = [] cue_list = []
if cues is not None: if cues is not None:
cues_data = cues.read_data(f) cues_data = cues.read_data(f)
@@ -205,33 +223,31 @@ class WavCuesReader:
cues_count = unpack("<I", cues_data[0:offset]) cues_count = unpack("<I", cues_data[0:offset])
for _ in range(cues_count[0]): for _ in range(cues_count[0]):
cue_bytes = cues_data[offset: offset + CueEntry.format_size()] cue_bytes = cues_data[offset : offset + CueEntry.format_size()]
cue_list.append(CueEntry.read(cue_bytes)) cue_list.append(CueEntry.read(cue_bytes))
offset += CueEntry.format_size() offset += CueEntry.format_size()
label_list = [] label_list = []
for labl in labls: for labl in labls:
label_list.append( label_list.append(
LabelEntry.read(labl.read_data(f), LabelEntry.read(labl.read_data(f), encoding=fallback_encoding)
encoding=fallback_encoding)
) )
range_list = [] range_list = []
for r in ltxts: for r in ltxts:
range_list.append( range_list.append(
RangeLabel.read(r.read_data(f), RangeLabel.read(r.read_data(f), fallback_encoding=fallback_encoding)
fallback_encoding=fallback_encoding)
) )
note_list = [] note_list = []
for note in notes: for note in notes:
note_list.append( note_list.append(
NoteEntry.read(note.read_data(f), NoteEntry.read(note.read_data(f), encoding=fallback_encoding)
encoding=fallback_encoding)
) )
return WavCuesReader(cues=cue_list, labels=label_list, return WavCuesReader(
ranges=range_list, notes=note_list) cues=cue_list, labels=label_list, ranges=range_list, notes=note_list
)
def each_cue(self) -> Generator[Tuple[int, int], None, None]: def each_cue(self) -> Generator[Tuple[int, int], None, None]:
""" """
@@ -242,8 +258,7 @@ class WavCuesReader:
for cue in self.cues: for cue in self.cues:
yield (cue.name, cue.sample_offset) yield (cue.name, cue.sample_offset)
def label_and_note(self, cue_ident: int) -> Tuple[Optional[str], def label_and_note(self, cue_ident: int) -> Tuple[Optional[str], Optional[str]]:
Optional[str]]:
""" """
Get the label and note (extended comment) for a cue. Get the label and note (extended comment) for a cue.
@@ -251,10 +266,10 @@ class WavCuesReader:
:returns: a tuple of the the cue's label (if present) and note (if :returns: a tuple of the the cue's label (if present) and note (if
present) present)
""" """
label = next((label.text for label in self.labels label = next(
if label.name == cue_ident), None) (label.text for label in self.labels if label.name == cue_ident), None
note = next((n.text for n in self.notes )
if n.name == cue_ident), None) note = next((n.text for n in self.notes if n.name == cue_ident), None)
return (label, note) return (label, note)
def range(self, cue_ident: int) -> Optional[int]: def range(self, cue_ident: int) -> Optional[int]:
@@ -264,23 +279,22 @@ class WavCuesReader:
:param cue_ident: the cue's name, its unique identifying number :param cue_ident: the cue's name, its unique identifying number
:returns: the length of the marker's range, or `None` :returns: the length of the marker's range, or `None`
""" """
return next((r.length for r in self.ranges return next((r.length for r in self.ranges if r.name == cue_ident), None)
if r.name == cue_ident), None)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
retval = dict() retval = dict()
for n, t in self.each_cue(): for n, t in self.each_cue():
retval[n] = dict() retval[n] = dict()
retval[n]['frame'] = t retval[n]["frame"] = t
label, note = self.label_and_note(n) label, note = self.label_and_note(n)
r = self.range(n) r = self.range(n)
if label is not None: if label is not None:
retval[n]['label'] = label retval[n]["label"] = label
if note is not None: if note is not None:
retval[n]['note'] = note retval[n]["note"] = note
if r is not None: if r is not None:
retval[n]['length'] = r retval[n]["length"] = r
return retval return retval
+122 -85
View File
@@ -19,6 +19,7 @@ class SegmentType(IntEnum):
""" """
Metadata segment type. Metadata segment type.
""" """
EndMarker = 0x0 EndMarker = 0x0
DolbyE = 0x1 DolbyE = 0x1
# Reserved2 = 0x2 # Reserved2 = 0x2
@@ -29,7 +30,7 @@ class SegmentType(IntEnum):
DolbyDigitalPlus = 0x7 DolbyDigitalPlus = 0x7
AudioInfo = 0x8 AudioInfo = 0x8
DolbyAtmos = 0x9 DolbyAtmos = 0x9
DolbyAtmosSupplemental = 0xa DolbyAtmosSupplemental = 0xA
@classmethod @classmethod
def _missing_(cls, val): def _missing_(cls, val):
@@ -82,6 +83,7 @@ class DolbyDigitalPlusMetadata:
""" """
Dolby surround endcoding mode. Dolby surround endcoding mode.
""" """
RESERVED = 0b11 RESERVED = 0b11
IN_USE = 0b10 IN_USE = 0b10
NOT_IN_USE = 0b01 NOT_IN_USE = 0b01
@@ -126,6 +128,7 @@ class DolbyDigitalPlusMetadata:
Dolby Digital Plus `acmod` field Dolby Digital Plus `acmod` field
§ 4.3.2.3 § 4.3.2.3
""" """
RESERVED = 0b000 RESERVED = 0b000
CH_ORD_1_0 = 0b001 CH_ORD_1_0 = 0b001
"Mono" "Mono"
@@ -163,6 +166,7 @@ class DolbyDigitalPlusMetadata:
Dolby Digital Plus `surmixlev` field Dolby Digital Plus `surmixlev` field
§ 4.3.3.2 § 4.3.3.2
""" """
DOWN_3DB = 0b00 DOWN_3DB = 0b00
DOWN_6DB = 0b01 DOWN_6DB = 0b01
MUTE = 0b10 MUTE = 0b10
@@ -174,24 +178,28 @@ class DolbyDigitalPlusMetadata:
Per ATSC/A52 § 5.4.2.12, this is not in use and always 0xFF. Per ATSC/A52 § 5.4.2.12, this is not in use and always 0xFF.
""" """
pass pass
class MixLevel(int): class MixLevel(int):
""" """
§ 4.3.6.2 § 4.3.6.2
""" """
pass pass
class DialnormLevel(int): class DialnormLevel(int):
""" """
§ 4.3.4.4 § 4.3.4.4
""" """
pass pass
class RoomType(Enum): class RoomType(Enum):
""" """
`roomtyp` 4.3.6.3 `roomtyp` 4.3.6.3
""" """
NOT_INDICATED = 0b00 NOT_INDICATED = 0b00
LARGE_ROOM_X_CURVE = 0b01 LARGE_ROOM_X_CURVE = 0b01
SMALL_ROOM_FLAT_CURVE = 0b10 SMALL_ROOM_FLAT_CURVE = 0b10
@@ -203,6 +211,7 @@ class DolbyDigitalPlusMetadata:
should downmix. should downmix.
§ 4.3.8.1 § 4.3.8.1
""" """
NOT_INDICATED = 0b00 NOT_INDICATED = 0b00
PRO_LOGIC = 0b01 PRO_LOGIC = 0b01
STEREO = 0b10 STEREO = 0b10
@@ -213,6 +222,7 @@ class DolbyDigitalPlusMetadata:
Dolby Surround-EX mode. Dolby Surround-EX mode.
`dsurexmod` § 4.3.9.1 `dsurexmod` § 4.3.9.1
""" """
NOT_INDICATED = 0b00 NOT_INDICATED = 0b00
NOT_SEX = 0b01 NOT_SEX = 0b01
SEX = 0b10 SEX = 0b10
@@ -222,6 +232,7 @@ class DolbyDigitalPlusMetadata:
""" """
`dheadphonmod` § 4.3.9.2 `dheadphonmod` § 4.3.9.2
""" """
NOT_INDICATED = 0b00 NOT_INDICATED = 0b00
NOT_DOLBY_HEADPHONE = 0b01 NOT_DOLBY_HEADPHONE = 0b01
DOLBY_HEADPHONE = 0b10 DOLBY_HEADPHONE = 0b10
@@ -246,6 +257,7 @@ class DolbyDigitalPlusMetadata:
`compr1` RF compression profile `compr1` RF compression profile
§ 4.3.10 (fig 42) § 4.3.10 (fig 42)
""" """
NONE = 0 NONE = 0
FILM_STANDARD = 1 FILM_STANDARD = 1
FILM_LIGHT = 2 FILM_LIGHT = 2
@@ -341,9 +353,11 @@ class DolbyDigitalPlusMetadata:
return b return b
def program_info(b): def program_info(b):
return (b & 0x40) > 0, \ return (
DolbyDigitalPlusMetadata.BitStreamMode(b & 0x38 >> 3), \ (b & 0x40) > 0,
DolbyDigitalPlusMetadata.AudioCodingMode(b & 0x7) DolbyDigitalPlusMetadata.BitStreamMode(b & 0x38 >> 3),
DolbyDigitalPlusMetadata.AudioCodingMode(b & 0x7),
)
def ddplus_reserved1(_): def ddplus_reserved1(_):
pass pass
@@ -351,39 +365,49 @@ class DolbyDigitalPlusMetadata:
def surround_config(b): def surround_config(b):
return ( return (
DolbyDigitalPlusMetadata.CenterDownMixLevel(b & 0x30 >> 4), DolbyDigitalPlusMetadata.CenterDownMixLevel(b & 0x30 >> 4),
DolbyDigitalPlusMetadata.SurroundDownMixLevel(b & 0xc >> 2), DolbyDigitalPlusMetadata.SurroundDownMixLevel(b & 0xC >> 2),
DolbyDigitalPlusMetadata.DolbySurroundEncodingMode(b & 0x3) DolbyDigitalPlusMetadata.DolbySurroundEncodingMode(b & 0x3),
) )
def dialnorm_info(b): def dialnorm_info(b):
return (b & 0x80) > 0, b & 0x40 > 0, b & 0x20 > 0, \ return (
DolbyDigitalPlusMetadata.DialnormLevel(b & 0x1f) (b & 0x80) > 0,
b & 0x40 > 0,
b & 0x20 > 0,
DolbyDigitalPlusMetadata.DialnormLevel(b & 0x1F),
)
def langcod(b) -> int: def langcod(b) -> int:
return b return b
def audio_prod_info(b): def audio_prod_info(b):
return (b & 0x80) > 0, \ return (
DolbyDigitalPlusMetadata.MixLevel(b & 0x7c >> 2), \ (b & 0x80) > 0,
DolbyDigitalPlusMetadata.RoomType(b & 0x3) DolbyDigitalPlusMetadata.MixLevel(b & 0x7C >> 2),
DolbyDigitalPlusMetadata.RoomType(b & 0x3),
)
# loro_center_downmix_level, loro_surround_downmix_level # loro_center_downmix_level, loro_surround_downmix_level
def ext_bsi1_word1(b): def ext_bsi1_word1(b):
return DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x38 >> 3), \ return DolbyDigitalPlusMetadata.DownMixLevelToken(
DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x7) b & 0x38 >> 3
), DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x7)
# downmix_mode, ltrt_center_downmix_level, ltrt_surround_downmix_level # downmix_mode, ltrt_center_downmix_level, ltrt_surround_downmix_level
def ext_bsi1_word2(b): def ext_bsi1_word2(b):
return DolbyDigitalPlusMetadata\ return (
.PreferredDownMixMode(b & 0xC0 >> 6), \ DolbyDigitalPlusMetadata.PreferredDownMixMode(b & 0xC0 >> 6),
DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x38 >> 3), \ DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x38 >> 3),
DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x7) DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x7),
)
# surround_ex_mode, dolby_headphone_encoded, ad_converter_type # surround_ex_mode, dolby_headphone_encoded, ad_converter_type
def ext_bsi2_word1(b): def ext_bsi2_word1(b):
return DolbyDigitalPlusMetadata.SurroundEXMode(b & 0x60 >> 5), \ return (
DolbyDigitalPlusMetadata.HeadphoneMode(b & 0x18 >> 3), \ DolbyDigitalPlusMetadata.SurroundEXMode(b & 0x60 >> 5),
DolbyDigitalPlusMetadata.ADConverterType(b & 0x4 >> 2) DolbyDigitalPlusMetadata.HeadphoneMode(b & 0x18 >> 3),
DolbyDigitalPlusMetadata.ADConverterType(b & 0x4 >> 2),
)
def ddplus_reserved2(_): def ddplus_reserved2(_):
pass pass
@@ -398,7 +422,7 @@ class DolbyDigitalPlusMetadata:
pass pass
def ddplus_info1(b): def ddplus_info1(b):
return DolbyDigitalPlusMetadata.StreamDependency(b & 0xc >> 2) return DolbyDigitalPlusMetadata.StreamDependency(b & 0xC >> 2)
def ddplus_reserved4(_): def ddplus_reserved4(_):
pass pass
@@ -412,19 +436,24 @@ class DolbyDigitalPlusMetadata:
pid = program_id(buffer[0]) pid = program_id(buffer[0])
lfe_on, bitstream_mode, audio_coding_mode = program_info(buffer[1]) lfe_on, bitstream_mode, audio_coding_mode = program_info(buffer[1])
ddplus_reserved1(buffer[2:2]) ddplus_reserved1(buffer[2:2])
center_downmix_level, surround_downmix_level, \ center_downmix_level, surround_downmix_level, dolby_surround_encoded = (
dolby_surround_encoded = surround_config(buffer[4]) surround_config(buffer[4])
langcode_present, copyright_bitstream, original_bitstream, \ )
dialnorm = dialnorm_info(buffer[5]) langcode_present, copyright_bitstream, original_bitstream, dialnorm = (
dialnorm_info(buffer[5])
)
langcode = langcod(buffer[6]) langcode = langcod(buffer[6])
prod_info_exists, mixlevel, roomtype = audio_prod_info(buffer[7]) prod_info_exists, mixlevel, roomtype = audio_prod_info(buffer[7])
loro_center_downmix_level, \ loro_center_downmix_level, loro_surround_downmix_level = ext_bsi1_word1(
loro_surround_downmix_level = ext_bsi1_word1(buffer[8]) buffer[8]
downmix_mode, ltrt_center_downmix_level, \ )
ltrt_surround_downmix_level = ext_bsi1_word2(buffer[9]) downmix_mode, ltrt_center_downmix_level, ltrt_surround_downmix_level = (
surround_ex_mode, dolby_headphone_encoded, \ ext_bsi1_word2(buffer[9])
ad_converter_type = ext_bsi2_word1(buffer[10]) )
surround_ex_mode, dolby_headphone_encoded, ad_converter_type = ext_bsi2_word1(
buffer[10]
)
ddplus_reserved2(buffer[11:14]) ddplus_reserved2(buffer[11:14])
compression = compr1(buffer[14]) compression = compr1(buffer[14])
@@ -436,32 +465,34 @@ class DolbyDigitalPlusMetadata:
reserved(buffer[27:69]) reserved(buffer[27:69])
return DolbyDigitalPlusMetadata( return DolbyDigitalPlusMetadata(
program_id=pid, lfe_on=lfe_on, program_id=pid,
bitstream_mode=bitstream_mode, lfe_on=lfe_on,
audio_coding_mode=audio_coding_mode, bitstream_mode=bitstream_mode,
center_downmix_level=center_downmix_level, audio_coding_mode=audio_coding_mode,
surround_downmix_level=surround_downmix_level, center_downmix_level=center_downmix_level,
dolby_surround_encoded=dolby_surround_encoded, surround_downmix_level=surround_downmix_level,
langcode_present=langcode_present, dolby_surround_encoded=dolby_surround_encoded,
copyright_bitstream=copyright_bitstream, langcode_present=langcode_present,
original_bitstream=original_bitstream, copyright_bitstream=copyright_bitstream,
dialnorm=dialnorm, original_bitstream=original_bitstream,
langcode=langcode, dialnorm=dialnorm,
prod_info_exists=prod_info_exists, langcode=langcode,
mixlevel=mixlevel, prod_info_exists=prod_info_exists,
roomtype=roomtype, mixlevel=mixlevel,
loro_center_downmix_level=loro_center_downmix_level, roomtype=roomtype,
loro_surround_downmix_level=loro_surround_downmix_level, loro_center_downmix_level=loro_center_downmix_level,
downmix_mode=downmix_mode, loro_surround_downmix_level=loro_surround_downmix_level,
ltrt_center_downmix_level=ltrt_center_downmix_level, downmix_mode=downmix_mode,
ltrt_surround_downmix_level=ltrt_surround_downmix_level, ltrt_center_downmix_level=ltrt_center_downmix_level,
surround_ex_mode=surround_ex_mode, ltrt_surround_downmix_level=ltrt_surround_downmix_level,
dolby_headphone_encoded=dolby_headphone_encoded, surround_ex_mode=surround_ex_mode,
ad_converter_type=ad_converter_type, dolby_headphone_encoded=dolby_headphone_encoded,
compression_profile=compression, ad_converter_type=ad_converter_type,
dynamic_range=dynamic_range, compression_profile=compression,
stream_dependency=stream_info, dynamic_range=dynamic_range,
datarate_kbps=data_rate) stream_dependency=stream_info,
datarate_kbps=data_rate,
)
@dataclass @dataclass
@@ -488,7 +519,6 @@ class DolbyAtmosMetadata:
@classmethod @classmethod
def load(cls, data: bytes): def load(cls, data: bytes):
assert len(data) == cls.SEGMENT_LENGTH assert len(data) == cls.SEGMENT_LENGTH
# (f"DolbyAtmosMetadata segment is incorrect length, " # (f"DolbyAtmosMetadata segment is incorrect length, "
# f"expected {cls.SEGMENT_LENGTH} actual was {len(data)}") # f"expected {cls.SEGMENT_LENGTH} actual was {len(data)}")
@@ -498,7 +528,7 @@ class DolbyAtmosMetadata:
h.seek(32, 1) h.seek(32, 1)
toolname = h.read(cls.TOOL_NAME_LENGTH) toolname = h.read(cls.TOOL_NAME_LENGTH)
toolname = unpack("%is" % cls.TOOL_NAME_LENGTH, toolname)[0] toolname = unpack("%is" % cls.TOOL_NAME_LENGTH, toolname)[0]
toolname = toolname.decode('utf-8').strip('\0') toolname = toolname.decode("utf-8").strip("\0")
vers = h.read(3) vers = h.read(3)
major, minor, fix = unpack("BBB", vers) major, minor, fix = unpack("BBB", vers)
@@ -508,10 +538,11 @@ class DolbyAtmosMetadata:
a_val = unpack("B", h.read(1))[0] a_val = unpack("B", h.read(1))[0]
warp_mode = a_val & 0x7 warp_mode = a_val & 0x7
return DolbyAtmosMetadata(tool_name=toolname, return DolbyAtmosMetadata(
tool_version=(major, minor, fix), tool_name=toolname,
warp_mode=DolbyAtmosMetadata tool_version=(major, minor, fix),
.WarpMode(warp_mode)) warp_mode=DolbyAtmosMetadata.WarpMode(warp_mode),
)
@dataclass @dataclass
@@ -531,15 +562,14 @@ class DolbyAtmosSupplementalMetadata:
NOT_INDICATED = 0x04 NOT_INDICATED = 0x04
object_count: int object_count: int
render_modes: List['DolbyAtmosSupplementalMetadata.BinauralRenderMode'] render_modes: List["DolbyAtmosSupplementalMetadata.BinauralRenderMode"]
trim_modes: List[int] trim_modes: List[int]
MAGIC = 0xf8726fbd MAGIC = 0xF8726FBD
TRIM_CONFIG_COUNT = 9 TRIM_CONFIG_COUNT = 9
@classmethod @classmethod
def load(cls, data: bytes): def load(cls, data: bytes):
trim_modes = [] trim_modes = []
render_modes = [] render_modes = []
@@ -564,9 +594,9 @@ class DolbyAtmosSupplementalMetadata:
binaural_mode &= 0x7 binaural_mode &= 0x7
render_modes.append(binaural_mode) render_modes.append(binaural_mode)
return DolbyAtmosSupplementalMetadata(object_count=object_count, return DolbyAtmosSupplementalMetadata(
render_modes=render_modes, object_count=object_count, render_modes=render_modes, trim_modes=trim_modes
trim_modes=trim_modes) )
class WavDolbyMetadataReader: class WavDolbyMetadataReader:
@@ -589,9 +619,9 @@ class WavDolbyMetadataReader:
retval = size retval = size
for b in bs: for b in bs:
retval += int(b) retval += int(b)
retval &= 0xff retval &= 0xFF
retval = ((~retval) + 1) & 0xff retval = ((~retval) + 1) & 0xFF
return retval return retval
@@ -614,8 +644,9 @@ class WavDolbyMetadataReader:
else: else:
seg_size = unpack("<H", h.read(2))[0] seg_size = unpack("<H", h.read(2))[0]
seg_payload = h.read(seg_size) seg_payload = h.read(seg_size)
expected_checksum = WavDolbyMetadataReader\ expected_checksum = WavDolbyMetadataReader.segment_checksum(
.segment_checksum(seg_payload, seg_size) seg_payload, seg_size
)
checksum = unpack("B", h.read(1))[0] checksum = unpack("B", h.read(1))[0]
segment = seg_payload segment = seg_payload
@@ -626,35 +657,41 @@ class WavDolbyMetadataReader:
elif stype == SegmentType.DolbyAtmosSupplemental: elif stype == SegmentType.DolbyAtmosSupplemental:
segment = DolbyAtmosSupplementalMetadata.load(segment) segment = DolbyAtmosSupplementalMetadata.load(segment)
self.segment_list\ self.segment_list.append(
.append((stype, checksum == expected_checksum, segment)) (stype, checksum == expected_checksum, segment)
)
def dolby_digital_plus(self) -> List[DolbyDigitalPlusMetadata]: def dolby_digital_plus(self) -> List[DolbyDigitalPlusMetadata]:
""" """
Every valid Dolby Digital Plus metadata segment in the file. Every valid Dolby Digital Plus metadata segment in the file.
""" """
return [x[2] for x in self.segment_list return [
if x[0] == SegmentType.DolbyDigitalPlus and x[1]] x[2]
for x in self.segment_list
if x[0] == SegmentType.DolbyDigitalPlus and x[1]
]
def dolby_atmos(self) -> List[DolbyAtmosMetadata]: def dolby_atmos(self) -> List[DolbyAtmosMetadata]:
""" """
Every valid Dolby Atmos metadata segment in the file. Every valid Dolby Atmos metadata segment in the file.
""" """
return [x[2] for x in self.segment_list return [
if x[0] == SegmentType.DolbyAtmos and x[1]] x[2] for x in self.segment_list if x[0] == SegmentType.DolbyAtmos and x[1]
]
def dolby_atmos_supplemental(self) -> List[DolbyAtmosSupplementalMetadata]: def dolby_atmos_supplemental(self) -> List[DolbyAtmosSupplementalMetadata]:
""" """
Every valid Dolby Atmos Supplemental metadata segment in the file. Every valid Dolby Atmos Supplemental metadata segment in the file.
""" """
return [x[2] for x in self.segment_list return [
if x[0] == SegmentType.DolbyAtmosSupplemental and x[1]] x[2]
for x in self.segment_list
if x[0] == SegmentType.DolbyAtmosSupplemental and x[1]
]
def to_dict(self) -> dict: def to_dict(self) -> dict:
ddp = map(lambda x: asdict(x), self.dolby_digital_plus()) ddp = map(lambda x: asdict(x), self.dolby_digital_plus())
atmos = map(lambda x: asdict(x), self.dolby_atmos()) atmos = map(lambda x: asdict(x), self.dolby_atmos())
# atmos_sup = map(lambda x: asdict(x), self.dolby_atmos_supplemental()) # atmos_sup = map(lambda x: asdict(x), self.dolby_atmos_supplemental())
return dict(dolby_digital_plus=list(ddp), return dict(dolby_digital_plus=list(ddp), dolby_atmos=list(atmos))
dolby_atmos=list(atmos))
+53 -45
View File
@@ -4,7 +4,6 @@ from typing import Optional
class WavInfoChunkReader: class WavInfoChunkReader:
def __init__(self, f, encoding): def __init__(self, f, encoding):
self.encoding = encoding self.encoding = encoding
@@ -12,56 +11,64 @@ class WavInfoChunkReader:
parsed_chunks = parse_chunk(f) parsed_chunks = parse_chunk(f)
assert type(parsed_chunks) is ListChunkDescriptor assert type(parsed_chunks) is ListChunkDescriptor
list_chunks = [chunk for chunk in parsed_chunks.children list_chunks = [
if type(chunk) is ListChunkDescriptor] chunk
for chunk in parsed_chunks.children
if type(chunk) is ListChunkDescriptor
]
self.info_chunk = next((chunk for chunk in list_chunks self.info_chunk = next(
if chunk.signature == b'INFO'), None) (chunk for chunk in list_chunks if chunk.signature == b"INFO"), None
)
#: 'ICOP' Copyright #: 'ICOP' Copyright
self.copyright: Optional[str] = self._get_field(f, b'ICOP') self.copyright: Optional[str] = self._get_field(f, b"ICOP")
#: 'IPRD' Product #: 'IPRD' Product
self.product: Optional[str] = self._get_field(f, b'IPRD') self.product: Optional[str] = self._get_field(f, b"IPRD")
self.album: Optional[str] = self.product self.album: Optional[str] = self.product
#: 'IGNR' Genre #: 'IGNR' Genre
self.genre: Optional[str] = self._get_field(f, b'IGNR') self.genre: Optional[str] = self._get_field(f, b"IGNR")
#: 'ISBJ' Subject #: 'ISBJ' Subject
self.subject: Optional[str] = self._get_field(f, b'ISBJ') self.subject: Optional[str] = self._get_field(f, b"ISBJ")
#: 'IART' Artist, composer, author #: 'IART' Artist, composer, author
self.artist: Optional[str] = self._get_field(f, b'IART') self.artist: Optional[str] = self._get_field(f, b"IART")
#: 'ICMT' Comment #: 'ICMT' Comment
self.comment: Optional[str] = self._get_field(f, b'ICMT') self.comment: Optional[str] = self._get_field(f, b"ICMT")
#: 'ISFT' Software, encoding application #: 'ISFT' Software, encoding application
self.software: Optional[str] = self._get_field(f, b'ISFT') self.software: Optional[str] = self._get_field(f, b"ISFT")
#: 'ICRD' Created date #: 'ICRD' Created date
self.created_date: Optional[str] = self._get_field(f, b'ICRD') self.created_date: Optional[str] = self._get_field(f, b"ICRD")
#: 'IENG' Engineer #: 'IENG' Engineer
self.engineer: Optional[str] = self._get_field(f, b'IENG') self.engineer: Optional[str] = self._get_field(f, b"IENG")
#: 'ITCH' Technician #: 'ITCH' Technician
self.technician: Optional[str] = self._get_field(f, b'ITCH') self.technician: Optional[str] = self._get_field(f, b"ITCH")
#: 'IKEY' Keywords, keyword list #: 'IKEY' Keywords, keyword list
self.keywords: Optional[str] = self._get_field(f, b'IKEY') self.keywords: Optional[str] = self._get_field(f, b"IKEY")
#: 'INAM' Name, title #: 'INAM' Name, title
self.title: Optional[str] = self._get_field(f, b'INAM') self.title: Optional[str] = self._get_field(f, b"INAM")
#: 'ISRC' Source #: 'ISRC' Source
self.source: Optional[str] = self._get_field(f, b'ISRC') self.source: Optional[str] = self._get_field(f, b"ISRC")
#: 'TAPE' Tape #: 'TAPE' Tape
self.tape: Optional[str] = self._get_field(f, b'TAPE') self.tape: Optional[str] = self._get_field(f, b"TAPE")
#: 'IARL' Archival Location #: 'IARL' Archival Location
self.archival_location: Optional[str] = self._get_field(f, b'IARL') self.archival_location: Optional[str] = self._get_field(f, b"IARL")
#: 'ICSM' Commissioned #: 'ICSM' Commissioned
self.commissioned: Optional[str] = self._get_field(f, b'ICMS') self.commissioned: Optional[str] = self._get_field(f, b"ICMS")
def _get_field(self, f, field_ident) -> Optional[str]: def _get_field(self, f, field_ident) -> Optional[str]:
search = next(((chunk.start, chunk.length) search = next(
for chunk in self.info_chunk.children (
if chunk.ident == field_ident), (chunk.start, chunk.length)
None) for chunk in self.info_chunk.children
if chunk.ident == field_ident
),
None,
)
if search is not None: if search is not None:
f.seek(search[0]) f.seek(search[0])
data = f.read(search[1]) data = f.read(search[1])
return data.decode(self.encoding).rstrip('\0') return data.decode(self.encoding).rstrip("\0")
else: else:
return None return None
@@ -69,26 +76,27 @@ class WavInfoChunkReader:
""" """
A dictionary with all of the key/values read from the INFO scope. A dictionary with all of the key/values read from the INFO scope.
""" """
return {'copyright': self.copyright, return {
'product': self.product, "copyright": self.copyright,
'album': self.album, "product": self.product,
'genre': self.genre, "album": self.album,
'artist': self.artist, "genre": self.genre,
'comment': self.comment, "artist": self.artist,
'software': self.software, "comment": self.comment,
'created_date': self.created_date, "software": self.software,
'engineer': self.engineer, "created_date": self.created_date,
'keywords': self.keywords, "engineer": self.engineer,
'title': self.title, "keywords": self.keywords,
'source': self.source, "title": self.title,
'tape': self.tape, "source": self.source,
'commissioned': self.commissioned, "tape": self.tape,
'archival_location': self.archival_location, "commissioned": self.commissioned,
'subject': self.subject, "archival_location": self.archival_location,
'technician': self.technician "subject": self.subject,
} "technician": self.technician,
}
def __repr__(self): def __repr__(self):
return_val = self.to_dict() return_val = self.to_dict()
return_val.update({'encoding': self.encoding}) return_val.update({"encoding": self.encoding})
return str(return_val) return str(return_val)
+20 -18
View File
@@ -1,5 +1,6 @@
from lxml import etree as ET from lxml import etree as ET
import io import io
# from collections import namedtuple # from collections import namedtuple
from typing import Optional from typing import Optional
from enum import IntEnum from enum import IntEnum
@@ -22,6 +23,7 @@ class SteinbergMetadata:
""" """
Steinberg speaker format enumeration. Steinberg speaker format enumeration.
""" """
MONO = 0 MONO = 0
STEREO = 1 STEREO = 1
LRC = 10 LRC = 10
@@ -79,7 +81,8 @@ class SteinbergMetadata:
`AudioSpeakerArrangement` property `AudioSpeakerArrangement` property
""" """
val = self.parsed.find( val = self.parsed.find(
"./ATTR_LIST/ATTR[NAME = 'AudioSpeakerArrangement']/VALUE") "./ATTR_LIST/ATTR[NAME = 'AudioSpeakerArrangement']/VALUE"
)
if val is not None: if val is not None:
return type(self).AudioSpeakerArrangement(int(val.text)) return type(self).AudioSpeakerArrangement(int(val.text))
@@ -88,8 +91,7 @@ class SteinbergMetadata:
""" """
AudioSampleFormatSize AudioSampleFormatSize
""" """
val = self.parsed.find( val = self.parsed.find("./ATTR_LIST/ATTR[NAME = 'AudioSampleFormatSize']/VALUE")
"./ATTR_LIST/ATTR[NAME = 'AudioSampleFormatSize']/VALUE")
if val is not None: if val is not None:
return int(val.text) return int(val.text)
@@ -98,8 +100,7 @@ class SteinbergMetadata:
""" """
MediaCompany MediaCompany
""" """
val = self.parsed.find( val = self.parsed.find("./ATTR_LIST/ATTR[NAME = 'MediaCompany']/VALUE")
"./ATTR_LIST/ATTR[NAME = 'MediaCompany']/VALUE")
if val is not None: if val is not None:
return val.text return val.text
@@ -108,8 +109,7 @@ class SteinbergMetadata:
""" """
MediaDropFrames MediaDropFrames
""" """
val = self.parsed.find( val = self.parsed.find("./ATTR_LIST/ATTR[NAME = 'MediaDropFrames']/VALUE")
"./ATTR_LIST/ATTR[NAME = 'MediaDropFrames']/VALUE")
if val is not None: if val is not None:
return val.text == "1" return val.text == "1"
@@ -118,8 +118,7 @@ class SteinbergMetadata:
""" """
MediaDuration MediaDuration
""" """
val = self.parsed.find( val = self.parsed.find("./ATTR_LIST/ATTR[NAME = 'MediaDuration']/VALUE")
"./ATTR_LIST/ATTR[NAME = 'MediaDuration']/VALUE")
if val is not None: if val is not None:
return float(val.text) return float(val.text)
@@ -192,13 +191,12 @@ class WavIXMLFormat:
:yields: `IXMLTrack` for each track. :yields: `IXMLTrack` for each track.
""" """
for track in self.parsed.find("./TRACK_LIST").iter(): for track in self.parsed.find("./TRACK_LIST").iter():
if track.tag == 'TRACK': if track.tag == "TRACK":
yield IXMLTrack( yield IXMLTrack(
channel_index=track.xpath('string(CHANNEL_INDEX/text())'), channel_index=track.xpath("string(CHANNEL_INDEX/text())"),
interleave_index=track.xpath( interleave_index=track.xpath("string(INTERLEAVE_INDEX/text())"),
'string(INTERLEAVE_INDEX/text())'), name=track.xpath("string(NAME/text())"),
name=track.xpath('string(NAME/text())'), function=track.xpath("string(FUNCTION/text())"),
function=track.xpath('string(FUNCTION/text())')
) )
@property @property
@@ -258,6 +256,10 @@ class WavIXMLFormat:
def to_dict(self): def to_dict(self):
return dict( return dict(
track_list=list(map(lambda x: x._asdict(), self.track_list)), track_list=list(map(lambda x: x._asdict(), self.track_list)),
project=self.project, scene=self.scene, take=self.take, project=self.project,
tape=self.tape, family_uid=self.family_uid, scene=self.scene,
family_name=self.family_name) take=self.take,
tape=self.tape,
family_uid=self.family_uid,
family_name=self.family_name,
)
+87 -61
View File
@@ -38,7 +38,7 @@ class WavInfoReader:
Parse a WAV audio file for metadata. Parse a WAV audio file for metadata.
""" """
def __init__(self, path, info_encoding='latin_1', bext_encoding='ascii'): def __init__(self, path, info_encoding="latin_1", bext_encoding="ascii"):
""" """
Create a new reader object. Create a new reader object.
@@ -86,9 +86,9 @@ class WavInfoReader:
#: Sampler `smpl` metadata #: Sampler `smpl` metadata
self.smpl: Optional[WavSmplReader] = None self.smpl: Optional[WavSmplReader] = None
if hasattr(path, 'read'): if hasattr(path, "read"):
self.get_wav_info(path) self.get_wav_info(path)
self.url = 'about:blank' self.url = "about:blank"
self.path = repr(path) self.path = repr(path)
else: else:
@@ -99,7 +99,7 @@ class WavInfoReader:
self.path = absolute_path self.path = absolute_path
with open(path, 'rb') as path: with open(path, "rb") as path:
self.get_wav_info(path) self.get_wav_info(path)
def get_wav_info(self, wavfile): def get_wav_info(self, wavfile):
@@ -119,36 +119,43 @@ class WavInfoReader:
self.smpl = self._get_sampler_loops(wavfile) self.smpl = self._get_sampler_loops(wavfile)
self.data = self._describe_data() self.data = self._describe_data()
def _find_chunk_data(self, ident, from_stream, def _find_chunk_data(
default_none=False) -> Optional[bytes]: self, ident, from_stream, default_none=False
top_chunks = (chunk for chunk in self.main_list ) -> Optional[bytes]:
if type(chunk) is ChunkDescriptor and top_chunks = (
chunk.ident == ident) chunk
for chunk in self.main_list
if type(chunk) is ChunkDescriptor and chunk.ident == ident
)
chunk_descriptor = next(top_chunks, None) \ chunk_descriptor = next(top_chunks, None) if default_none else next(top_chunks)
if default_none else next(top_chunks)
return chunk_descriptor.read_data(from_stream) \ return chunk_descriptor.read_data(from_stream) if chunk_descriptor else None
if chunk_descriptor else None
def _find_list_chunk(self, signature) -> Optional[ListChunkDescriptor]: def _find_list_chunk(self, signature) -> Optional[ListChunkDescriptor]:
top_chunks = (chunk for chunk in self.main_list top_chunks = (
if type(chunk) is ListChunkDescriptor and chunk
chunk.signature == signature) for chunk in self.main_list
if type(chunk) is ListChunkDescriptor and chunk.signature == signature
)
return next(top_chunks, None) return next(top_chunks, None)
def _describe_data(self): def _describe_data(self):
data_chunk = next(c for c in self.main_list data_chunk = next(
if type(c) is ChunkDescriptor and c.ident == b'data') c
for c in self.main_list
if type(c) is ChunkDescriptor and c.ident == b"data"
)
assert isinstance(self.fmt, WavAudioFormat) assert isinstance(self.fmt, WavAudioFormat)
return WavDataDescriptor( return WavDataDescriptor(
byte_count=data_chunk.length, byte_count=data_chunk.length,
frame_count=int(data_chunk.length / self.fmt.block_align)) frame_count=int(data_chunk.length / self.fmt.block_align),
)
def _get_format(self, f): def _get_format(self, f):
fmt_data = self._find_chunk_data(b'fmt ', f) fmt_data = self._find_chunk_data(b"fmt ", f)
assert fmt_data is not None, "Fmt data not found, not a valid wav file" assert fmt_data is not None, "Fmt data not found, not a valid wav file"
packstring = "<HHIIHH" packstring = "<HHIIHH"
@@ -156,62 +163,79 @@ class WavInfoReader:
unpacked = struct.unpack(packstring, fmt_data[:rest_starts]) unpacked = struct.unpack(packstring, fmt_data[:rest_starts])
return WavAudioFormat(audio_format=unpacked[0], return WavAudioFormat(
channel_count=unpacked[1], audio_format=unpacked[0],
sample_rate=unpacked[2], channel_count=unpacked[1],
byte_rate=unpacked[3], sample_rate=unpacked[2],
block_align=unpacked[4], byte_rate=unpacked[3],
bits_per_sample=unpacked[5] block_align=unpacked[4],
) bits_per_sample=unpacked[5],
)
def _get_info(self, f, encoding): def _get_info(self, f, encoding):
finder = (chunk.signature for chunk in self.main_list finder = (
if type(chunk) is ListChunkDescriptor) chunk.signature
for chunk in self.main_list
if type(chunk) is ListChunkDescriptor
)
if b'INFO' in finder: if b"INFO" in finder:
return WavInfoChunkReader(f, encoding) return WavInfoChunkReader(f, encoding)
def _get_bext(self, f, encoding): def _get_bext(self, f, encoding):
bext_data = self._find_chunk_data(b'bext', f, default_none=True) bext_data = self._find_chunk_data(b"bext", f, default_none=True)
return WavBextReader(bext_data, encoding) if bext_data else None return WavBextReader(bext_data, encoding) if bext_data else None
def _get_adm(self, f): def _get_adm(self, f):
axml = self._find_chunk_data(b'axml', f, default_none=True) axml = self._find_chunk_data(b"axml", f, default_none=True)
chna = self._find_chunk_data(b'chna', f, default_none=True) chna = self._find_chunk_data(b"chna", f, default_none=True)
return WavADMReader(axml_data=axml, chna_data=chna) \ return WavADMReader(axml_data=axml, chna_data=chna) if axml and chna else None
if axml and chna else None
def _get_dbmd(self, f): def _get_dbmd(self, f):
dbmd_data = self._find_chunk_data(b'dbmd', f, default_none=True) dbmd_data = self._find_chunk_data(b"dbmd", f, default_none=True)
return WavDolbyMetadataReader(dbmd_data=dbmd_data) \ return WavDolbyMetadataReader(dbmd_data=dbmd_data) if dbmd_data else None
if dbmd_data else None
def _get_ixml(self, f): def _get_ixml(self, f):
ixml_data = self._find_chunk_data(b'iXML', f, default_none=True) ixml_data = self._find_chunk_data(b"iXML", f, default_none=True)
return WavIXMLFormat(ixml_data.rstrip(b'\0')) if ixml_data else None return WavIXMLFormat(ixml_data.rstrip(b"\0")) if ixml_data else None
def _get_cue(self, f): def _get_cue(self, f):
cue = next((cue_chunk for cue_chunk in self.main_list if cue = next(
type(cue_chunk) is ChunkDescriptor and (
cue_chunk.ident == b'cue '), None) cue_chunk
for cue_chunk in self.main_list
if type(cue_chunk) is ChunkDescriptor and cue_chunk.ident == b"cue "
),
None,
)
adtl = self._find_list_chunk(b'adtl') adtl = self._find_list_chunk(b"adtl")
labls = [] labls = []
ltxts = [] ltxts = []
notes = [] notes = []
if adtl is not None: if adtl is not None:
labls = [c for c in adtl.children labls = [
if type(c) is ChunkDescriptor and c.ident == b'labl'] c
ltxts = [c for c in adtl.children for c in adtl.children
if type(c) is ChunkDescriptor and c.ident == b'ltxt'] if type(c) is ChunkDescriptor and c.ident == b"labl"
notes = [c for c in adtl.children ]
if type(c) is ChunkDescriptor and c.ident == b'note'] ltxts = [
c
for c in adtl.children
if type(c) is ChunkDescriptor and c.ident == b"ltxt"
]
notes = [
c
for c in adtl.children
if type(c) is ChunkDescriptor and c.ident == b"note"
]
return WavCuesReader.read_all(f, cue, labls, ltxts, notes, return WavCuesReader.read_all(
fallback_encoding=self.info_encoding) f, cue, labls, ltxts, notes, fallback_encoding=self.info_encoding
)
def _get_sampler_loops(self, f): def _get_sampler_loops(self, f):
sampler_data = self._find_chunk_data(b'smpl', f, default_none=True) sampler_data = self._find_chunk_data(b"smpl", f, default_none=True)
return WavSmplReader(sampler_data) if sampler_data else None return WavSmplReader(sampler_data) if sampler_data else None
# FIXME: this should probably be named "iter()" # FIXME: this should probably be named "iter()"
@@ -225,22 +249,24 @@ class WavInfoReader:
"smpl". "smpl".
""" """
scopes = ('fmt', 'data', 'ixml', 'bext', 'info', 'adm', 'cues', scopes = ("fmt", "data", "ixml", "bext", "info", "adm", "cues", "dolby", "smpl")
'dolby', 'smpl')
for scope in scopes: for scope in scopes:
if scope in ['fmt', 'data']: if scope in ["fmt", "data"]:
attr = self.__getattribute__(scope) attr = self.__getattribute__(scope)
for field in attr._fields: for field in attr._fields:
yield scope, field, attr.__getattribute__(field) yield scope, field, attr.__getattribute__(field)
else: else:
mdict = self.__getattribute__(scope).to_dict( mdict = (
) if self.__getattribute__(scope) else {} self.__getattribute__(scope).to_dict()
if self.__getattribute__(scope)
else {}
)
for key in mdict.keys(): for key in mdict.keys():
yield scope, key, mdict[key] yield scope, key, mdict[key]
def __repr__(self): def __repr__(self):
return 'WavInfoReader({}, {}, {})'.format(self.path, return "WavInfoReader({}, {}, {})".format(
self.info_encoding, self.path, self.info_encoding, self.bext_encoding
self.bext_encoding) )
+37 -35
View File
@@ -13,30 +13,29 @@ class WaveSmplLoop(NamedTuple):
def loop_type_desc(self): def loop_type_desc(self):
if self.loop_type == 0: if self.loop_type == 0:
return 'FORWARD' return "FORWARD"
elif self.loop_type == 1: elif self.loop_type == 1:
return 'FORWARD_BACKWARD' return "FORWARD_BACKWARD"
elif self.loop_type == 2: elif self.loop_type == 2:
return 'BACKWARD' return "BACKWARD"
elif 3 <= self.loop_type <= 31: elif 3 <= self.loop_type <= 31:
return 'RESERVED' return "RESERVED"
else: else:
return 'VENDOR' return "VENDOR"
def to_dict(self): def to_dict(self):
return { return {
'ident': self.ident, "ident": self.ident,
'loop_type': self.loop_type, "loop_type": self.loop_type,
'loop_type_description': self.loop_type_desc(), "loop_type_description": self.loop_type_desc(),
'start_samples': self.start, "start_samples": self.start,
'end_samples': self.end, "end_samples": self.end,
'detune_cents': self.detune_cents, "detune_cents": self.detune_cents,
'repetition_count': self.repetition_count, "repetition_count": self.repetition_count,
} }
class WavSmplReader: class WavSmplReader:
def __init__(self, smpl_data: bytes): def __init__(self, smpl_data: bytes):
""" """
Read sampler metadata from smpl chunk. Read sampler metadata from smpl chunk.
@@ -47,8 +46,7 @@ class WavSmplReader:
header_size = struct.calcsize(header_field_fmt) header_size = struct.calcsize(header_field_fmt)
loop_size = struct.calcsize(loop_field_fmt) loop_size = struct.calcsize(loop_field_fmt)
unpacked_data = struct.unpack(header_field_fmt, unpacked_data = struct.unpack(header_field_fmt, smpl_data[0:header_size])
smpl_data[0:header_size])
#: The MIDI Manufacturer's Association code for the sampler #: The MIDI Manufacturer's Association code for the sampler
#: manufactuer, or 0 if not specific. #: manufactuer, or 0 if not specific.
@@ -80,35 +78,39 @@ class WavSmplReader:
#: List of loops in the file. #: List of loops in the file.
self.sample_loops: List[WaveSmplLoop] = [] self.sample_loops: List[WaveSmplLoop] = []
loop_buffer = smpl_data[header_size: loop_buffer = smpl_data[header_size : header_size + loop_size * loop_count]
header_size + loop_size * loop_count]
for unpacked_loop in struct.iter_unpack(loop_field_fmt, loop_buffer): for unpacked_loop in struct.iter_unpack(loop_field_fmt, loop_buffer):
self.sample_loops.append(WaveSmplLoop( self.sample_loops.append(
ident=unpacked_loop[0], WaveSmplLoop(
loop_type=unpacked_loop[1], ident=unpacked_loop[0],
start=unpacked_loop[2], loop_type=unpacked_loop[1],
end=unpacked_loop[3], start=unpacked_loop[2],
detune_cents=unpacked_loop[4], end=unpacked_loop[3],
repetition_count=unpacked_loop[5])) detune_cents=unpacked_loop[4],
repetition_count=unpacked_loop[5],
)
)
#: Sampler-specific user data. #: Sampler-specific user data.
self.sampler_udata: bytes | None = None self.sampler_udata: bytes | None = None
if sampler_udata_length > 0: if sampler_udata_length > 0:
self.sampler_udata = smpl_data[ self.sampler_udata = smpl_data[
header_size + loop_size * loop_count: header_size + loop_size * loop_count : header_size
header_size + loop_size * loop_count + sampler_udata_length] + loop_size * loop_count
+ sampler_udata_length
]
def to_dict(self): def to_dict(self):
return { return {
'manufactuer': self.manufacturer, "manufactuer": self.manufacturer,
'product': self.product, "product": self.product,
'sample_period_ns': self.sample_period_ns, "sample_period_ns": self.sample_period_ns,
'midi_note': self.midi_note, "midi_note": self.midi_note,
'midi_pitch_detune_cents': self.midi_pitch_detune_cents, "midi_pitch_detune_cents": self.midi_pitch_detune_cents,
'smpte_format': self.smpte_format, "smpte_format": self.smpte_format,
'smpte_offset': "%02i:%02i:%02i:%02i" % self.smpte_offset, "smpte_offset": "%02i:%02i:%02i:%02i" % self.smpte_offset,
'loops': [x.to_dict() for x in self.sample_loops], "loops": [x.to_dict() for x in self.sample_loops],
'sampler_user_data': self.sampler_udata, "sampler_user_data": self.sampler_udata,
} }
+10 -17
View File
@@ -1,6 +1,4 @@
""" """ """
"""
from optparse import OptionParser, OptionGroup from optparse import OptionParser, OptionGroup
import sys import sys
@@ -9,24 +7,19 @@ import sys
def main(): def main():
parser = OptionParser() parser = OptionParser()
parser.usage = ("wavfind [--scene=SCENE] [--take=TAKE] [--desc=DESC] " parser.usage = "wavfind [--scene=SCENE] [--take=TAKE] [--desc=DESC] <PATH> +"
"<PATH> +")
primaries = OptionGroup(parser, title="Search Predicates", primaries = OptionGroup(
description="Argument values can be globs, " parser,
"and are logically-AND'ed.") title="Search Predicates",
description="Argument values can be globs, and are logically-AND'ed.",
)
primaries.add_option("--scene", primaries.add_option("--scene", help="Search for this scene", metavar="SCENE")
help='Search for this scene',
metavar='SCENE')
primaries.add_option("--take", primaries.add_option("--take", help="Search for this take", metavar="TAKE")
help='Search for this take',
metavar='TAKE')
primaries.add_option("--desc", primaries.add_option("--desc", help="Search descriptions", metavar="DESC")
help='Search descriptions',
metavar='DESC')
(options, args) = parser.parse_args(sys.argv) (options, args) = parser.parse_args(sys.argv)
+1 -3
View File
@@ -19,7 +19,7 @@ from contextlib import contextmanager
# @property # @property
# def arch_path(self): # def arch_path(self):
# return os.path.join(self.base_path, self.test_file_arch_name) # return os.path.join(self.base_path, self.test_file_arch_name)
# @contextmanager # @contextmanager
# def open(self, name) -> Generator[BytesIO]: # def open(self, name) -> Generator[BytesIO]:
@@ -40,5 +40,3 @@ from contextlib import contextmanager
# p = os.path.join(root, name) # p = os.path.join(root, name)
# zip.write(p) # zip.write(p)
# os.unlink(p) # os.unlink(p)
+4 -5
View File
@@ -2,8 +2,8 @@ from unittest import TestCase
import wavinfo import wavinfo
class TestADMWave(TestCase):
class TestADMWave(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.protools_adm_wav = "tests/test_files/protools/Test_ADM_ProTools.wav" self.protools_adm_wav = "tests/test_files/protools/Test_ADM_ProTools.wav"
return super().setUp() return super().setUp()
@@ -24,7 +24,7 @@ class TestADMWave(TestCase):
assert adm is not None assert adm is not None
dict = adm.to_dict() dict = adm.to_dict()
self.assertIsNotNone(dict) self.assertIsNotNone(dict)
def test_programme(self): def test_programme(self):
info = wavinfo.WavInfoReader(self.protools_adm_wav) info = wavinfo.WavInfoReader(self.protools_adm_wav)
adm = info.adm adm = info.adm
@@ -32,8 +32,8 @@ class TestADMWave(TestCase):
pdict = adm.programme() pdict = adm.programme()
self.assertIn("programme_id", pdict.keys()) self.assertIn("programme_id", pdict.keys())
self.assertIn("programme_name", pdict.keys()) self.assertIn("programme_name", pdict.keys())
self.assertEqual(pdict['programme_id'], 'APR_1001') self.assertEqual(pdict["programme_id"], "APR_1001")
self.assertEqual(pdict['programme_name'], 'Atmos_Master') self.assertEqual(pdict["programme_name"], "Atmos_Master")
self.assertIn("contents", pdict.keys()) self.assertIn("contents", pdict.keys())
self.assertEqual(len(pdict["contents"]), 3) self.assertEqual(len(pdict["contents"]), 3)
@@ -51,4 +51,3 @@ class TestADMWave(TestCase):
t10 = adm.track_info(10) t10 = adm.track_info(10)
self.assertTrue("content_name" in t10.keys()) self.assertTrue("content_name" in t10.keys())
self.assertEqual("Dialog", t10["content_name"]) self.assertEqual("Dialog", t10["content_name"])
+14 -15
View File
@@ -3,9 +3,10 @@ from glob import glob
import wavinfo import wavinfo
class TestCue(TestCase): class TestCue(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.test_files = glob("tests/test_files/cue_chunks/*.wav") self.test_files = glob("tests/test_files/cue_chunks/*.wav")
return super().setUp() return super().setUp()
def test_enumerate(self): def test_enumerate(self):
@@ -14,7 +15,7 @@ class TestCue(TestCase):
self.assertIsNotNone(w1.cues) self.assertIsNotNone(w1.cues)
assert w1.cues is not None assert w1.cues is not None
vals = list(w1.cues.each_cue()) vals = list(w1.cues.each_cue())
self.assertEqual(vals, [(1,29616),(2,74592),(3,121200)]) self.assertEqual(vals, [(1, 29616), (2, 74592), (3, 121200)])
def test_labels_notes(self): def test_labels_notes(self):
file = "tests/test_files/cue_chunks/izotoperx_cues_test.wav" file = "tests/test_files/cue_chunks/izotoperx_cues_test.wav"
@@ -23,7 +24,7 @@ class TestCue(TestCase):
assert w1.cues is not None assert w1.cues is not None
for name, _ in w1.cues.each_cue(): for name, _ in w1.cues.each_cue():
self.assertIn(name,[1,2,3]) self.assertIn(name, [1, 2, 3])
label, note = w1.cues.label_and_note(name) label, note = w1.cues.label_and_note(name)
if name == 1: if name == 1:
self.assertEqual("Marker 1", label) self.assertEqual("Marker 1", label)
@@ -35,18 +36,20 @@ class TestCue(TestCase):
self.assertIsNotNone(w1.cues) self.assertIsNotNone(w1.cues)
assert w1.cues is not None assert w1.cues is not None
self.assertEqual(w1.cues.range(3), 10000) self.assertEqual(w1.cues.range(3), 10000)
def test_encoding_fallback(self): def test_encoding_fallback(self):
""" """
Added this after I noticed that iZotope RX seems to just encode "notes" Added this after I noticed that iZotope RX seems to just encode "notes"
as utf-8 without bothering to dump this info into the ltxt or as utf-8 without bothering to dump this info into the ltxt or
specifying an encoding by some other means. specifying an encoding by some other means.
""" """
file = "tests/test_files/cue_chunks/izotoperx_cues_test.wav" file = "tests/test_files/cue_chunks/izotoperx_cues_test.wav"
w = wavinfo.WavInfoReader(file, info_encoding='utf-8') w = wavinfo.WavInfoReader(file, info_encoding="utf-8")
expected = ("Лорем ипсум долор сит амет, тимеам вивендум хас ет, " expected = (
"цу адолесценс дефинитионес еам.") "Лорем ипсум долор сит амет, тимеам вивендум хас ет, "
"цу адолесценс дефинитионес еам."
)
assert w.cues is not None assert w.cues is not None
note = [n for n in w.cues.notes if n.name == 3] note = [n for n in w.cues.notes if n.name == 3]
@@ -56,20 +59,16 @@ class TestCue(TestCase):
def test_label(self): def test_label(self):
file = "tests/test_files/cue_chunks/izotoperx_cues_test.wav" file = "tests/test_files/cue_chunks/izotoperx_cues_test.wav"
w = wavinfo.WavInfoReader(file) w = wavinfo.WavInfoReader(file)
self.assertIsNotNone(w.cues) self.assertIsNotNone(w.cues)
assert w.cues is not None assert w.cues is not None
self.assertEqual(len(w.cues.labels), 3) self.assertEqual(len(w.cues.labels), 3)
for label in w.cues.labels: for label in w.cues.labels:
self.assertIn(label.name, [1,2,3]) self.assertIn(label.name, [1, 2, 3])
if label.name == 1: if label.name == 1:
self.assertEqual(label.text, "Marker 1") self.assertEqual(label.text, "Marker 1")
elif label.name == 2: elif label.name == 2:
self.assertEqual(label.text, "Marker 2") self.assertEqual(label.text, "Marker 2")
elif label.name == 3: elif label.name == 3:
self.assertEqual(label.text, "Marker 3") self.assertEqual(label.text, "Marker 3")
+19 -18
View File
@@ -3,6 +3,7 @@ from unittest import TestCase
import wavinfo import wavinfo
from wavinfo.wave_dbmd_reader import SegmentType, DolbyDigitalPlusMetadata from wavinfo.wave_dbmd_reader import SegmentType, DolbyDigitalPlusMetadata
class TestDolby(TestCase): class TestDolby(TestCase):
def setUp(self): def setUp(self):
self.test_file = "tests/test_files/protools/Test_ADM_ProTools.wav" self.test_file = "tests/test_files/protools/Test_ADM_ProTools.wav"
@@ -10,19 +11,17 @@ class TestDolby(TestCase):
def test_version(self): def test_version(self):
t1 = wavinfo.WavInfoReader(self.test_file) t1 = wavinfo.WavInfoReader(self.test_file)
d = t1.dolby d = t1.dolby
assert d is not None assert d is not None
self.assertEqual((1,0,0,6), d.version) self.assertEqual((1, 0, 0, 6), d.version)
def test_segments(self): def test_segments(self):
t1 = wavinfo.WavInfoReader(self.test_file) t1 = wavinfo.WavInfoReader(self.test_file)
d = t1.dolby d = t1.dolby
assert d is not None assert d is not None
ddp = [x for x in d.segment_list \ ddp = [x for x in d.segment_list if x[0] == SegmentType.DolbyDigitalPlus]
if x[0] == SegmentType.DolbyDigitalPlus] atmos = [x for x in d.segment_list if x[0] == SegmentType.DolbyAtmos]
atmos = [x for x in d.segment_list \
if x[0] == SegmentType.DolbyAtmos]
self.assertEqual(len(ddp), 1) self.assertEqual(len(ddp), 1)
self.assertEqual(len(atmos), 1) self.assertEqual(len(atmos), 1)
@@ -40,21 +39,23 @@ class TestDolby(TestCase):
d = t1.dolby d = t1.dolby
assert d is not None assert d is not None
ddp = d.dolby_digital_plus() ddp = d.dolby_digital_plus()
self.assertEqual(len(ddp), 1, self.assertEqual(
("Failed to find exactly one Dolby Digital Plus " len(ddp),
"metadata segment") 1,
) ("Failed to find exactly one Dolby Digital Plus metadata segment"),
)
self.assertTrue(
ddp[0].audio_coding_mode,
DolbyDigitalPlusMetadata.AudioCodingMode.CH_ORD_3_2,
)
self.assertTrue(ddp[0].lfe_on)
self.assertTrue( ddp[0].audio_coding_mode,
DolbyDigitalPlusMetadata.AudioCodingMode.CH_ORD_3_2 )
self.assertTrue( ddp[0].lfe_on)
def test_atmos(self): def test_atmos(self):
t1 = wavinfo.WavInfoReader(self.test_file) t1 = wavinfo.WavInfoReader(self.test_file)
d = t1.dolby d = t1.dolby
assert d is not None assert d is not None
atmos = d.dolby_atmos() atmos = d.dolby_atmos()
self.assertEqual(len(atmos), 1, self.assertEqual(
"Failed to find exactly one Atmos metadata segment") len(atmos), 1, "Failed to find exactly one Atmos metadata segment"
)
+9 -9
View File
@@ -7,10 +7,10 @@ from wavinfo.__main__ import main
import sys import sys
import glob import glob
class MainTest(unittest.TestCase): class MainTest(unittest.TestCase):
def test_empty_argv(self):
def test_empty_argv(self): with patch.object(sys, "argv", []):
with patch.object(sys, 'argv', []):
try: try:
main() main()
except: except:
@@ -18,17 +18,17 @@ class MainTest(unittest.TestCase):
def test_a_file(self): def test_a_file(self):
for path in glob.glob("tests/test_files/**/*.wav"): for path in glob.glob("tests/test_files/**/*.wav"):
with patch.object(sys, 'argv', ["TEST", path]): with patch.object(sys, "argv", ["TEST", path]):
try: try:
main() main()
except: except:
self.fail("main() throwing an exception") self.fail("main() throwing an exception")
def test_ixml(self): def test_ixml(self):
with patch.object(sys, 'argv', with patch.object(
['TEST', '--ixml', sys, "argv", ["TEST", "--ixml", "tests/test_files/sounddevices/A101_1.WAV"]
'tests/test_files/sounddevices/A101_1.WAV']): ):
try: try:
main() main()
except: except:
self.fail("main() throwing an exception") self.fail("main() throwing an exception")
+1 -3
View File
@@ -9,17 +9,15 @@ from unittest import TestCase
import wavinfo import wavinfo
class TestRf64(TestCase):
class TestRf64(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
return super().setUp() return super().setUp()
def test_open(self): def test_open(self):
for path in glob("tests/test_files/rf64/*.wav.gz"): for path in glob("tests/test_files/rf64/*.wav.gz"):
gz = gzip.open(path) gz = gzip.open(path)
wav_info = wavinfo.WavInfoReader(gz) wav_info = wavinfo.WavInfoReader(gz)
self.assertIsNotNone(wav_info) self.assertIsNotNone(wav_info)
# self.assertIsNotNone(wav_info.bext) # self.assertIsNotNone(wav_info.bext)
+1
View File
@@ -3,6 +3,7 @@ from glob import glob
import wavinfo import wavinfo
class TestSmpl(TestCase): class TestSmpl(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.test_files = glob("tests/test_files/smpl/*.wav") self.test_files = glob("tests/test_files/smpl/*.wav")
+11 -11
View File
@@ -3,35 +3,35 @@ import wavinfo
import glob import glob
class TestWalk(unittest.TestCase): class TestWalk(unittest.TestCase):
def test_walk_metadata(self): def test_walk_metadata(self):
test_file = 'tests/test_files/protools/PT A101_4.A1.wav' test_file = "tests/test_files/protools/PT A101_4.A1.wav"
info = wavinfo.WavInfoReader(test_file) info = wavinfo.WavInfoReader(test_file)
tested_data , tested_format = False, False tested_data, tested_format = False, False
for scope, key, value in info.walk(): for scope, key, value in info.walk():
if scope == 'fmt': if scope == "fmt":
if key == 'channel_count': if key == "channel_count":
tested_format = True tested_format = True
self.assertEqual(value, 2) self.assertEqual(value, 2)
if scope == 'data': if scope == "data":
if key == 'frame_count': if key == "frame_count":
tested_data = True tested_data = True
self.assertEqual(value, 144140) self.assertEqual(value, 144140)
self.assertTrue(tested_data and tested_format) self.assertTrue(tested_data and tested_format)
def test_walk_all(self): def test_walk_all(self):
for file in glob.glob('tests/test_files/**/*.wav'): for file in glob.glob("tests/test_files/**/*.wav"):
info = wavinfo.WavInfoReader(file) info = wavinfo.WavInfoReader(file)
try: try:
for _, _, _ in info.walk(): for _, _, _ in info.walk():
pass pass
except: except:
self.fail(f"Failed to walk metadata in file {file}") self.fail(f"Failed to walk metadata in file {file}")
if __name__ == '__main__': if __name__ == "__main__":
unittest.main() unittest.main()
+113 -92
View File
@@ -13,9 +13,10 @@ class TestWaveInfo(TestCase):
def test_sanity(self): def test_sanity(self):
for wav_file in all_files(): for wav_file in all_files():
info = wavinfo.WavInfoReader(wav_file) info = wavinfo.WavInfoReader(wav_file)
self.assertEqual(info.__repr__(), self.assertEqual(
'WavInfoReader({}, latin_1, ascii)' info.__repr__(),
.format(os.path.abspath(wav_file))) "WavInfoReader({}, latin_1, ascii)".format(os.path.abspath(wav_file)),
)
self.assertIsNotNone(info) self.assertIsNotNone(info)
def test_fmt_against_ffprobe(self): def test_fmt_against_ffprobe(self):
@@ -26,31 +27,39 @@ class TestWaveInfo(TestCase):
assert info.fmt is not None assert info.fmt is not None
assert ffprobe_info is not None assert ffprobe_info is not None
self.assertEqual(info.fmt.channel_count, self.assertEqual(
ffprobe_info['streams'][0]['channels']) info.fmt.channel_count, ffprobe_info["streams"][0]["channels"]
self.assertEqual(info.fmt.sample_rate, )
int(ffprobe_info['streams'][0]['sample_rate'])) self.assertEqual(
self.assertEqual(info.fmt.bits_per_sample, info.fmt.sample_rate, int(ffprobe_info["streams"][0]["sample_rate"])
int(ffprobe_info['streams'][0]['bits_per_sample'] )
)) self.assertEqual(
info.fmt.bits_per_sample,
int(ffprobe_info["streams"][0]["bits_per_sample"]),
)
if info.fmt.audio_format == 1: if info.fmt.audio_format == 1:
self.assertTrue(ffprobe_info['streams'][0]['codec_name']\ self.assertTrue(
.startswith('pcm')) ffprobe_info["streams"][0]["codec_name"].startswith("pcm")
streams = ffprobe_info['streams'][0] )
byte_rate = int(streams['sample_rate']) * \ streams = ffprobe_info["streams"][0]
streams['channels'] * \ byte_rate = (
int(streams['bits_per_sample']) / 8 int(streams["sample_rate"])
* streams["channels"]
* int(streams["bits_per_sample"])
/ 8
)
self.assertEqual(info.fmt.byte_rate, byte_rate) self.assertEqual(info.fmt.byte_rate, byte_rate)
def test_data_against_ffprobe(self): def test_data_against_ffprobe(self):
for wav_file in all_files(): for wav_file in all_files():
info = wavinfo.WavInfoReader(wav_file) info = wavinfo.WavInfoReader(wav_file)
ffprobe_info = cast(Dict[str,Any], ffprobe(wav_file)) ffprobe_info = cast(Dict[str, Any], ffprobe(wav_file))
assert ffprobe_info is not None assert ffprobe_info is not None
assert info.data is not None assert info.data is not None
self.assertEqual(info.data.frame_count, self.assertEqual(
int(ffprobe_info['streams'][0]['duration_ts'])) info.data.frame_count, int(ffprobe_info["streams"][0]["duration_ts"])
)
def test_bext_against_ffprobe(self): def test_bext_against_ffprobe(self):
for wav_file in all_files(): for wav_file in all_files():
@@ -59,65 +68,78 @@ class TestWaveInfo(TestCase):
assert ffprobe_info is not None assert ffprobe_info is not None
if info.bext: if info.bext:
if 'comment' in ffprobe_info['format']['tags']: if "comment" in ffprobe_info["format"]["tags"]:
self.assertEqual(info.bext.description, self.assertEqual(
ffprobe_info['format']['tags']\ info.bext.description, ffprobe_info["format"]["tags"]["comment"]
['comment']) )
else: else:
self.assertEqual(info.bext.description, '') self.assertEqual(info.bext.description, "")
if 'encoded_by' in ffprobe_info['format']['tags']: if "encoded_by" in ffprobe_info["format"]["tags"]:
self.assertEqual(info.bext.originator, self.assertEqual(
ffprobe_info['format']['tags']\ info.bext.originator,
['encoded_by']) ffprobe_info["format"]["tags"]["encoded_by"],
)
else: else:
self.assertEqual(info.bext.originator, '') self.assertEqual(info.bext.originator, "")
if 'originator_reference' in ffprobe_info['format']['tags']: if "originator_reference" in ffprobe_info["format"]["tags"]:
self.assertEqual(info.bext.originator_ref, self.assertEqual(
ffprobe_info['format']['tags']\ info.bext.originator_ref,
['originator_reference']) ffprobe_info["format"]["tags"]["originator_reference"],
)
else: else:
self.assertEqual(info.bext.originator_ref, '') self.assertEqual(info.bext.originator_ref, "")
# these don't always reflect the bext info # these don't always reflect the bext info
# self.assertEqual(info.bext.originator_date, # self.assertEqual(info.bext.originator_date,
# ffprobe_info['format']['tags']['date']) # ffprobe_info['format']['tags']['date'])
# self.assertEqual(info.bext.originator_time, # self.assertEqual(info.bext.originator_time,
# ffprobe_info['format']['tags']['creation_time']) # ffprobe_info['format']['tags']['creation_time'])
self.assertEqual(info.bext.time_reference, self.assertEqual(
int(ffprobe_info['format']['tags']\ info.bext.time_reference,
['time_reference'])) int(ffprobe_info["format"]["tags"]["time_reference"]),
)
if 'coding_history' in ffprobe_info['format']['tags']: if "coding_history" in ffprobe_info["format"]["tags"]:
self.assertEqual(info.bext.coding_history, self.assertEqual(
ffprobe_info['format']['tags']\ info.bext.coding_history,
['coding_history']) ffprobe_info["format"]["tags"]["coding_history"],
)
else: else:
self.assertEqual(info.bext.coding_history, '') self.assertEqual(info.bext.coding_history, "")
def test_ixml(self): def test_ixml(self):
expected = {'A101_4.WAV': {'project': 'BMH', expected = {
'scene': 'A101', 'take': '4', "A101_4.WAV": {
'tape': '18Y12M31', "project": "BMH",
'family_uid': "scene": "A101",
'USSDVGR1112089007124015008231000'}, "take": "4",
'A101_3.WAV': {'project': 'BMH', "tape": "18Y12M31",
'scene': 'A101', 'take': '3', "family_uid": "USSDVGR1112089007124015008231000",
'tape': '18Y12M31', },
'family_uid': "A101_3.WAV": {
'USSDVGR1112089007124014008228300'}, "project": "BMH",
'A101_2.WAV': {'project': 'BMH', "scene": "A101",
'scene': 'A101', 'take': '2', "take": "3",
'tape': '18Y12M31', "tape": "18Y12M31",
'family_uid': "family_uid": "USSDVGR1112089007124014008228300",
'USSDVGR1112089007124004008218600'}, },
'A101_1.WAV': {'project': 'BMH', "A101_2.WAV": {
'scene': 'A101', 'take': '1', "project": "BMH",
'tape': '18Y12M31', "scene": "A101",
'family_uid': "take": "2",
'USSDVGR1112089007124001008206300'}, "tape": "18Y12M31",
} "family_uid": "USSDVGR1112089007124004008218600",
},
"A101_1.WAV": {
"project": "BMH",
"scene": "A101",
"take": "1",
"tape": "18Y12M31",
"family_uid": "USSDVGR1112089007124001008206300",
},
}
for wav_file in all_files(): for wav_file in all_files():
basename = os.path.basename(wav_file) basename = os.path.basename(wav_file)
@@ -125,29 +147,31 @@ class TestWaveInfo(TestCase):
info = wavinfo.WavInfoReader(wav_file) info = wavinfo.WavInfoReader(wav_file)
e = expected[basename] e = expected[basename]
self.assertIsNotNone(info.ixml) self.assertIsNotNone(info.ixml)
assert info.ixml is not None assert info.ixml is not None
self.assertEqual(e['project'], info.ixml.project) self.assertEqual(e["project"], info.ixml.project)
self.assertEqual(e['scene'], info.ixml.scene) self.assertEqual(e["scene"], info.ixml.scene)
self.assertEqual(e['take'], info.ixml.take) self.assertEqual(e["take"], info.ixml.take)
self.assertEqual(e['tape'], info.ixml.tape) self.assertEqual(e["tape"], info.ixml.tape)
self.assertEqual(e['family_uid'], info.ixml.family_uid) self.assertEqual(e["family_uid"], info.ixml.family_uid)
for track in info.ixml.track_list: for track in info.ixml.track_list:
self.assertIsNotNone(track.channel_index) self.assertIsNotNone(track.channel_index)
if basename == 'A101_4.WAV' and track.channel_index == '1': if basename == "A101_4.WAV" and track.channel_index == "1":
self.assertEqual(track.name, 'MKH516 A') self.assertEqual(track.name, "MKH516 A")
def test_steinberg_ixml(self): def test_steinberg_ixml(self):
nuendo_files = 'tests/test_files/nuendo/*.wav' nuendo_files = "tests/test_files/nuendo/*.wav"
for file in glob(nuendo_files): for file in glob(nuendo_files):
info = wavinfo.WavInfoReader(file) info = wavinfo.WavInfoReader(file)
assert info.ixml is not None assert info.ixml is not None
self.assertIsNotNone(info.ixml.steinberg) self.assertIsNotNone(info.ixml.steinberg)
assert info.ixml.steinberg is not None assert info.ixml.steinberg is not None
self.assertIsNotNone(info.ixml.steinberg.audio_speaker_arrangement) self.assertIsNotNone(info.ixml.steinberg.audio_speaker_arrangement)
self.assertEqual(info.ixml.steinberg.sample_format_size, 3) self.assertEqual(info.ixml.steinberg.sample_format_size, 3)
self.assertEqual(info.ixml.steinberg.media_company, self.assertEqual(
"https://github.com/iluvcapra/wavinfo") info.ixml.steinberg.media_company,
"https://github.com/iluvcapra/wavinfo",
)
self.assertFalse(info.ixml.steinberg.media_drop_frames) self.assertFalse(info.ixml.steinberg.media_drop_frames)
self.assertEqual(info.ixml.steinberg.media_duration, 1200.0) self.assertEqual(info.ixml.steinberg.media_duration, 1200.0)
@@ -159,24 +183,21 @@ class TestWaveInfo(TestCase):
self.assertIsNone(info.ixml.steinberg) self.assertIsNone(info.ixml.steinberg)
def test_info_metadata(self): def test_info_metadata(self):
file_with_metadata = \ file_with_metadata = "tests/test_files/sound_grinder_pro/new_camera bumb 1.wav"
'tests/test_files/sound_grinder_pro/new_camera bumb 1.wav'
self.assertTrue(os.path.exists(file_with_metadata)) self.assertTrue(os.path.exists(file_with_metadata))
info = wavinfo.WavInfoReader(file_with_metadata).info info = wavinfo.WavInfoReader(file_with_metadata).info
assert info is not None assert info is not None
self.assertEqual(info.title, 'camera bumb 1') self.assertEqual(info.title, "camera bumb 1")
self.assertEqual(info.artist, 'Jamie Hardt') self.assertEqual(info.artist, "Jamie Hardt")
self.assertEqual(info.copyright, '© 2010 Jamie Hardt') self.assertEqual(info.copyright, "© 2010 Jamie Hardt")
self.assertEqual(info.product, 'Test Sounds') # album self.assertEqual(info.product, "Test Sounds") # album
self.assertEqual(info.album, info.product) self.assertEqual(info.album, info.product)
self.assertEqual(info.comment, 'Comments') self.assertEqual(info.comment, "Comments")
self.assertEqual(info.software, 'Sound Grinder Pro') self.assertEqual(info.software, "Sound Grinder Pro")
self.assertEqual(info.created_date, '2010-12-28') self.assertEqual(info.created_date, "2010-12-28")
self.assertEqual(info.engineer, 'JPH') self.assertEqual(info.engineer, "JPH")
self.assertEqual(info.keywords, self.assertEqual(info.keywords, "Sound Effect, movement, microphone, bump")
'Sound Effect, movement, microphone, bump') self.assertEqual(info.title, "camera bumb 1")
self.assertEqual(info.title, 'camera bumb 1')
self.assertEqual(type(info.to_dict()), dict) self.assertEqual(type(info.to_dict()), dict)
self.assertEqual(type(info.__repr__()), str) self.assertEqual(type(info.__repr__()), str)
+9 -11
View File
@@ -4,35 +4,33 @@ import subprocess
from subprocess import PIPE from subprocess import PIPE
import json import json
FFPROBE = 'ffprobe' FFPROBE = "ffprobe"
def ffprobe(path): def ffprobe(path):
arguments = [FFPROBE, "-of", "json", arguments = [FFPROBE, "-of", "json", "-show_format", "-show_streams", path]
"-show_format", "-show_streams", path] if int(sys.version[0]) < 3:
if int(sys.version[0]) < 3:
process = subprocess.Popen(arguments, stdout=PIPE) process = subprocess.Popen(arguments, stdout=PIPE)
process.wait() process.wait()
if process.returncode == 0: if process.returncode == 0:
output = process.communicate()[0] output = process.communicate()[0]
if output: if output:
output_str = output.decode('utf-8') output_str = output.decode("utf-8")
return json.loads(output_str) return json.loads(output_str)
else: else:
return None return None
else: else:
process = subprocess.run(arguments, stdin=None, process = subprocess.run(arguments, stdin=None, stdout=PIPE, stderr=PIPE)
stdout=PIPE, stderr=PIPE)
if process.returncode == 0: if process.returncode == 0:
output_str = process.stdout.decode('utf-8') output_str = process.stdout.decode("utf-8")
return json.loads(output_str) return json.loads(output_str)
else: else:
return None return None
def all_files(): def all_files():
for dirpath, _, filenames in os.walk('tests/test_files'): for dirpath, _, filenames in os.walk("tests/test_files"):
for filename in filenames: for filename in filenames:
_, ext = os.path.splitext(filename) _, ext = os.path.splitext(filename)
if ext in ['.wav', '.WAV']: if ext in [".wav", ".WAV"]:
yield os.path.join(dirpath, filename) yield os.path.join(dirpath, filename)