Compare commits

..

1 Commits

Author SHA1 Message Date
Jamie Hardt 5cc6a41238 Update contribution guidelines regarding AI systems 2026-02-15 15:47:45 -08:00
32 changed files with 916 additions and 1006 deletions
+3 -3
View File
@@ -19,9 +19,9 @@ jobs:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v2.5.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6.2.0 uses: actions/setup-python@v4.3.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies - name: Install dependencies
@@ -30,7 +30,7 @@ jobs:
python -m pip install --group dev python -m pip install --group dev
python -m pip install . python -m pip install .
- name: Setup FFmpeg - name: Setup FFmpeg
uses: federicocarboni/setup-ffmpeg@v3.1 uses: FedericoCarboni/setup-ffmpeg@v2
- name: Test with pytest - name: Test with pytest
run: | run: |
pytest pytest
+1 -1
View File
@@ -7,7 +7,7 @@ version: 2
# Set the version of Python and other tools you might need # Set the version of Python and other tools you might need
build: build:
os: ubuntu-lts-latest os: ubuntu-20.04
tools: tools:
python: "3.13" python: "3.13"
jobs: jobs:
+1 -32
View File
@@ -13,35 +13,4 @@ If you discover a bug or would like better support for a feature, please do the
review it as soon as I can. There's a `.devcontainer` available so you can creates commits review it as soon as I can. There's a `.devcontainer` available so you can creates commits
on this project in a GitHub codespace. on this project in a GitHub codespace.
Contributions made by Large Language Models are politely declined.
## Regarding use of Agents
`wavinfo` is an open-source project that is offered free for no commerical gain, and
is developed and maintained for educational and creative reasons.
If you use an agent or LLM to produce code for it you are missing out on the benefits
of contributing to an open-source project, particularly community, collaboration with
other developers and designers, and being able to learn and experiment without the
burden of deadlines or worrying about business cases or profits.
This project is supposed to be fun, do not let machines have fun for you.
We can't prevent you from using LLMs to contribute to this project but we ask you
abide by the following eitiquette when doing so:
* All communication with the maintainers must be written by a human in their own
voice. Never use an LLM to craft thread comments, discussion posts, issues, emails
or other correspondence with other developers or the maintainers.
* PRs must be submitted by a person. Do not allow an agent to submit its own PRs to
this project.
* Especially if you are a new contributor to this project, please submit only one PR
at a time and please restrict the subject matter of the PR to a specific unit,
module or tool. All submissions have to be reviewed and understood by the
maintainers before they can be merged.
Obviously we can't verify if you follow all of these rules but certain telltale
traits of LLM-predicted text or code will raise a flag: lack of brevity in
descriptions or code comments, large amounts of text describing your process or
steps that add little to understanding the changes you've made, use of an
obsequious tone or being excessively accomodating, immediately doing requests
without further discussion or clarifications.
+1
View File
@@ -3,6 +3,7 @@
[![Tests](https://github.com/iluvcapra/wavinfo/actions/workflows/python-package.yml/badge.svg)](https://github.com/iluvcapra/wavinfo/actions/workflows/python-package.yml) [![Tests](https://github.com/iluvcapra/wavinfo/actions/workflows/python-package.yml/badge.svg)](https://github.com/iluvcapra/wavinfo/actions/workflows/python-package.yml)
[![Ruff](https://github.com/iluvcapra/wavinfo/actions/workflows/python-ruff.yml/badge.svg)](https://github.com/iluvcapra/wavinfo/actions/workflows/python-ruff.yml) [![Ruff](https://github.com/iluvcapra/wavinfo/actions/workflows/python-ruff.yml/badge.svg)](https://github.com/iluvcapra/wavinfo/actions/workflows/python-ruff.yml)
[![codecov](https://codecov.io/gh/iluvcapra/wavinfo/branch/master/graph/badge.svg?token=9DZQfZENYv)](https://codecov.io/gh/iluvcapra/wavinfo)
# wavinfo # wavinfo
+29 -28
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# #
# Configuration file for the Sphinx documentation builder. # Configuration file for the Sphinx documentation builder.
# #
@@ -14,16 +15,15 @@
# 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 = "wavinfo" project = u'wavinfo'
copyright = "2018-2025, Jamie Hardt" copyright = u'2018-2025, Jamie Hardt'
author = "Jamie Hardt" author = u'Jamie Hardt'
# The short X.Y version # The short X.Y version
version = "4.0" version = "4.0"
@@ -42,34 +42,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 = ["_build", "Thumbs.db", ".DS_Store"] exclude_patterns = [u'_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 +80,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 +91,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 +107,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,12 +116,15 @@ 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',
@@ -131,7 +134,8 @@ 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", "wavinfo Documentation", "Jamie Hardt", "manual"), (master_doc, 'wavinfo.tex', u'wavinfo Documentation',
u'Jamie Hardt', 'manual'),
] ]
@@ -139,7 +143,10 @@ 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 = [(master_doc, "wavinfo", "wavinfo Documentation", [author], 1)] man_pages = [
(master_doc, 'wavinfo', u'wavinfo Documentation',
[author], 1)
]
# -- Options for Texinfo output ---------------------------------------------- # -- Options for Texinfo output ----------------------------------------------
@@ -148,15 +155,9 @@ man_pages = [(master_doc, "wavinfo", "wavinfo Documentation", [author], 1)]
# (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',
master_doc, author, 'wavinfo', 'One line description of project.',
"wavinfo", 'Miscellaneous'),
"wavinfo Documentation",
author,
"wavinfo",
"One line description of project.",
"Miscellaneous",
),
] ]
@@ -175,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 -------------------------------------------------
+3 -10
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,12 +113,7 @@
} }
], ],
"source": [ "source": [
"(\n", "(info.fmt.sample_rate, info.fmt.channel_count, info.fmt.block_align, info.fmt.bits_per_sample)"
" info.fmt.sample_rate,\n",
" info.fmt.channel_count,\n",
" info.fmt.block_align,\n",
" info.fmt.bits_per_sample,\n",
")"
] ]
}, },
{ {
@@ -276,9 +271,7 @@
], ],
"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(\n", "info = WavInfoReader(path, info_encoding=\"utf-8\") # iZotope RX seems to encode marker text as UTF-8\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",
+12 -11
View File
@@ -4,7 +4,7 @@ build-backend = "uv_build"
[project] [project]
name = "wavinfo" name = "wavinfo"
version = "4.0.1" version = "4.0.0"
description = "Probe WAVE files for all metadata" description = "Probe WAVE files for all metadata"
authors = [{ name = "Jamie Hardt", email = "jamiehardt@me.com"}] authors = [{ name = "Jamie Hardt", email = "jamiehardt@me.com"}]
license = "MIT" license = "MIT"
@@ -45,7 +45,7 @@ dependencies = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.3.5", "pytest>=8.3.5",
"ruff>=0.16.8", "ruff>=0.14.0",
] ]
doc = [ doc = [
"sphinx>=7.1.2", "sphinx>=7.1.2",
@@ -58,14 +58,15 @@ wavinfo = "wavinfo:__main__.main"
[tool.pyright] [tool.pyright]
typeCheckingMode = "basic" typeCheckingMode = "basic"
[tool.ruff] [tool.pylint]
line-length = 88 max-line-length = 88
indent-width = 4 disable = [
"C0103", # (invalid-name)
[tool.ruff.lint] "C0114", # (missing-module-docstring)
fixable = ['ALL'] "C0115", # (missing-class-docstring)
ignore = [ "C0116", # (missing-function-docstring)
'UP031', #Use format specifiers instead of percent format "R0903", # (too-few-public-methods)
"R0913", # (too-many-arguments)
"W0105", # (pointless-string-statement)
] ]
+3 -2
View File
@@ -2,7 +2,8 @@
Probe WAVE Files for iXML, Broadcast-WAVE and other metadata. Probe WAVE Files for iXML, Broadcast-WAVE and other metadata.
""" """
__all__ = ["WavInfoEOFError", "WavInfoReader"] __all__ = ['WavInfoReader', 'WavInfoEOFError']
from .riff_parser import WavInfoEOFError
from .wave_reader import WavInfoReader from .wave_reader import WavInfoReader
from .riff_parser import WavInfoEOFError
+47 -53
View File
@@ -1,17 +1,16 @@
from __future__ import annotations from . import WavInfoReader
import datetime import datetime
import importlib.metadata from optparse import OptionParser
import json
import os
import sys import sys
import os
import json
from enum import Enum
import importlib.metadata
from base64 import b64encode from base64 import b64encode
from cmd import Cmd from cmd import Cmd
from enum import Enum
from optparse import OptionParser
from shlex import split from shlex import split
from typing import List, Dict, Union
from . import WavInfoReader
class MyJSONEncoder(json.JSONEncoder): class MyJSONEncoder(json.JSONEncoder):
@@ -19,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)
@@ -31,16 +30,12 @@ class MissingDataError(RuntimeError):
class MetaBrowser(Cmd): class MetaBrowser(Cmd):
prompt = "(wavinfo) " prompt = "(wavinfo) "
metadata: list | dict metadata: Union[List, Dict]
path: list[str] path: List[str] = []
def preloop(self) -> None:
self.path = []
return super().preloop()
@property @property
def cwd(self): def cwd(self):
root: list | dict = self.metadata root: List | Dict = self.metadata
for key in self.path: for key in self.path:
if isinstance(root, list): if isinstance(root, list):
root = root[int(key)] root = root[int(key)]
@@ -55,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):
@@ -68,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):
@@ -96,10 +91,10 @@ class MetaBrowser(Cmd):
else: else:
print(f"Index {argv[0]} does not exist") print(f"Index {argv[0]} does not exist")
elif isinstance(self.cwd, dict): elif isinstance(self.cwd, dict):
if argv[0] in self.cwd: 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) + ") "
@@ -107,40 +102,41 @@ 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( parser.add_option('--man',
"--man", help="Read the manual and exit.", default=False, action="store_true" help="Read the manual and exit.",
) default=False,
action='store_true')
parser.add_option( parser.add_option('--adm', dest='adm',
"--adm", dest="adm", help="Output ADM XML", default=False, action="store_true" help='Output ADM XML',
) default=False,
action='store_true')
parser.add_option( parser.add_option('--ixml', dest='ixml',
"--ixml", dest="ixml", help="Output iXML", default=False, action="store_true" help='Output iXML',
) default=False,
action='store_true')
parser.add_option( parser.add_option('-i',
"-i", help='Read metadata with an interactive prompt',
help="Read metadata with an interactive prompt", default=False,
default=False, action='store_true')
action="store_true",
)
(options, args) = parser.parse_args(sys.argv) (options, args) = parser.parse_args(sys.argv)
@@ -153,7 +149,6 @@ 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")
@@ -181,30 +176,29 @@ def main():
raise MissingDataError("ixml") raise MissingDataError("ixml")
else: else:
ret_dict = { ret_dict = {
"filename": arg, 'filename': arg,
"run_date": datetime.datetime.now( 'run_date': datetime.datetime.now().isoformat(),
tz=datetime.timezone.utc 'application': f"wavinfo {version}",
).isoformat(), 'scopes': {}
"application": f"wavinfo {version}",
"scopes": {},
} }
for scope, name, value in this_file.walk(): for scope, name, value in this_file.walk():
if scope not in ret_dict["scopes"]: 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, indent=2) json.dump(ret_dict, cls=MyJSONEncoder, fp=sys.stdout,
indent=2)
except MissingDataError as e: except MissingDataError as e:
print( print("MissingDataError: Missing metadata (%s) in file %s" %
"MissingDataError: Missing metadata (%s) in file %s" % (e, arg), (e, arg), file=sys.stderr)
file=sys.stderr,
)
continue continue
except Exception as e:
raise e
if len(interactive_dict) > 0: if len(interactive_dict) > 0:
cli = MetaBrowser() cli = MetaBrowser()
+14 -15
View File
@@ -1,48 +1,47 @@
from __future__ import annotations
import struct import struct
from typing import NamedTuple # from collections import namedtuple
from typing import NamedTuple, Dict
from . import riff_parser from . import riff_parser
class RF64Context(NamedTuple): class RF64Context(NamedTuple):
sample_count: int sample_count: int
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( bigname, bigsize = struct.unpack_from(chunksize64format,
chunksize64format, ds64_data, offset=ds64_fields_size ds64_data,
) 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, bigchunk_table=bigchunk_table) return RF64Context(sample_count=sample_count,
bigchunk_table=bigchunk_table)
+15 -18
View File
@@ -1,9 +1,7 @@
from __future__ import annotations # from optparse import Option
import struct import struct
from typing import NamedTuple from .rf64_parser import parse_rf64, RF64Context
from typing import NamedTuple, Union, List, Optional
from .rf64_parser import RF64Context, parse_rf64
class WavInfoEOFError(EOFError): class WavInfoEOFError(EOFError):
@@ -14,14 +12,14 @@ class WavInfoEOFError(EOFError):
class ListChunkDescriptor(NamedTuple): class ListChunkDescriptor(NamedTuple):
signature: bytes signature: bytes
children: list[ChunkDescriptor | ListChunkDescriptor] children: List[Union['ChunkDescriptor', 'ListChunkDescriptor']]
class ChunkDescriptor(NamedTuple): class ChunkDescriptor(NamedTuple):
ident: bytes ident: bytes
start: int start: int
length: int length: int
rf64_context: RF64Context | None rf64_context: Optional[RF64Context]
def read_data(self, from_stream) -> bytes: def read_data(self, from_stream) -> bytes:
from_stream.seek(self.start) from_stream.seek(self.start)
@@ -50,15 +48,14 @@ 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]
@@ -66,14 +63,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( return parse_list_chunk(stream=stream, length=data_size,
stream=stream, length=data_size, rf64_context=rf64_context rf64_context=rf64_context)
)
else: else:
data_start = stream.tell() data_start = stream.tell()
stream.seek(displacement, 1) stream.seek(displacement, 1)
return ChunkDescriptor( return ChunkDescriptor(ident=ident,
ident=ident, start=data_start, length=data_size, rf64_context=rf64_context start=data_start,
) length=data_size,
rf64_context=rf64_context)
+82 -105
View File
@@ -1,15 +1,16 @@
""" """
ADM Reader ADM Reader
""" """
from __future__ import annotations
from collections import namedtuple from struct import unpack, unpack_from, calcsize
from io import BytesIO from io import BytesIO
from struct import calcsize, unpack, unpack_from from collections import namedtuple
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:
@@ -31,19 +32,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(
uid_fmt, chna_data, offset track_index, uid, track_ref, pack_ref = unpack_from(uid_fmt,
) 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( ChannelEntry(track_index - 1,
track_index - 1, uid.decode('ascii'),
uid.decode("ascii"), track_ref.decode('ascii'),
track_ref.decode("ascii"), pack_ref.decode('ascii')
pack_ref.decode("ascii"), )
)
) )
offset += calcsize(uid_fmt) offset += calcsize(uid_fmt)
@@ -57,51 +58,51 @@ class WavADMReader:
Read the ADM `audioProgramme` data structure and some of its reference Read the ADM `audioProgramme` data structure and some of its reference
properties. properties.
""" """
ret_dict = {} ret_dict = dict()
nsmap = self.axml.getroot().nsmap nsmap = self.axml.getroot().nsmap
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", namespaces=nsmap): for content_ref in program.findall("audioContentIDRef",
content_dict = {} namespaces=nsmap):
content_dict["content_id"] = cid = content_ref.text content_dict = dict()
content = afext.find( content_dict['content_id'] = cid = content_ref.text
"audioContent[@audioContentID='%s']" % cid, namespaces=nsmap content = afext.find("audioContent[@audioContentID='%s']" % cid,
) namespaces=nsmap)
content_dict["content_name"] = content.get("audioContentName") content_dict['content_name'] = content.get("audioContentName")
content_dict["objects"] = [] content_dict['objects'] = []
for object_ref in content.findall("audioObjectIDRef", namespaces=nsmap): for object_ref in content.findall("audioObjectIDRef",
object_dict = {} namespaces=nsmap):
object_dict["object_id"] = oid = object_ref.text object_dict = dict()
object = afext.find( object_dict['object_id'] = oid = object_ref.text
"audioObject[@audioObjectID='%s']" % oid, namespaces=nsmap object = afext.find("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
def track_info(self, index) -> dict | None: def track_info(self, index) -> Optional[dict]:
""" """
Information about a track in the WAV file. Information about a track in the WAV file.
@@ -110,9 +111,8 @@ 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( channel_info = next((x for x in self.channel_uids
(x for x in self.channel_uids if x.track_index == index), None if x.track_index == index), None)
)
if channel_info is None: if channel_info is None:
return None return None
@@ -121,78 +121,56 @@ class WavADMReader:
nsmap = self.axml.getroot().nsmap nsmap = self.axml.getroot().nsmap
afext = self.axml.find(".//audioFormatExtended", namespaces=nsmap) afext = self.axml.find(".//audioFormatExtended",
namespaces=nsmap)
trackformat_elem = afext.find( trackformat_elem = afext.find(
"audioTrackFormat[@audioTrackFormatID='%s']" % channel_info.track_ref, "audioTrackFormat[@audioTrackFormatID='%s']"
namespaces=nsmap, % channel_info.track_ref, 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']/audioChannelFormatIDRef") ("audioStreamFormat[@audioStreamFormatID='%s']"
% stream_id, "/audioChannelFormatIDRef") % stream_id,
namespaces=nsmap, namespaces=nsmap)
) channelformat_id = channelformatref_elem.text
if channelformatref_elem is not None:
channelformat_id = channelformatref_elem.text
else:
channelformat_id = None
packformatref_elem = afext.find( packformatref_elem = afext.find(
("audioStreamFormat[@audioStreamFormatID='%s']/audioPackFormatIDRef") ("audioStreamFormat[@audioStreamFormatID='%s']"
% stream_id, "/audioPackFormatIDRef") % stream_id,
namespaces=nsmap, namespaces=nsmap)
) packformat_id = packformatref_elem.text
if packformatref_elem is not None: channelformat_elem = afext\
packformat_id = packformatref_elem.text .find("audioChannelFormat[@audioChannelFormatID='%s']"
else: % channelformat_id,
packformat_id = None namespaces=nsmap)
ret_dict['channel_format_name'] = channelformat_elem.get(
if channelformat_id: "audioChannelFormatName")
channelformat_elem = afext.find(
"audioChannelFormat[@audioChannelFormatID='%s']" % channelformat_id,
namespaces=nsmap,
)
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, namespaces=nsmap "audioPackFormat[@audioPackFormatID='%s']" % packformat_id,
) namespaces=nsmap)
if packformat_elem is not None: ret_dict['pack_type'] = packformat_elem.get(
ret_dict["pack_type"] = packformat_elem.get("typeDefinition") "typeDefinition")
ret_dict["pack_format_name"] = packformat_elem.get("audioPackFormatName") ret_dict['pack_format_name'] = packformat_elem.get(
else: "audioPackFormatName")
ret_dict["pack_type"] = None
ret_dict["pack_format_name"] = None
object_elem = afext.find( object_elem = afext.find("audioObject[audioPackFormatIDRef = '%s']"
"audioObject[audioPackFormatIDRef = '%s']" % packformat_id, namespaces=nsmap % packformat_id,
) namespaces=nsmap)
if object_elem is not None: ret_dict['audio_object_name'] = object_elem.get("audioObjectName")
ret_dict["audio_object_name"] = object_elem.get("audioObjectName") object_id = object_elem.get("audioObjectID")
object_id = object_elem.get("audioObjectID") ret_dict['object_id'] = object_id
ret_dict["object_id"] = object_id
content_elem = afext.find( content_elem = afext.find("audioContent/[audioObjectIDRef = '%s']"
"audioContent/[audioObjectIDRef = '%s']" % object_id, namespaces=nsmap % object_id,
) 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
@@ -206,7 +184,6 @@ 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 { return dict(channel_entries=list(map(lambda z: make_entry(z),
'channel_entries': [make_entry(z) for z in self.channel_uids], self.channel_uids)),
'programme': self.programme(), programme=self.programme())
}
+27 -31
View File
@@ -1,9 +1,8 @@
from __future__ import annotations
import struct import struct
# from .umid_parser import UMIDParser # from .umid_parser import UMIDParser
from typing import Optional
class WavBextReader: class WavBextReader:
def __init__(self, bext_data, encoding): def __init__(self, bext_data, encoding):
@@ -14,18 +13,16 @@ 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 = ( packstring = "<256s" + "32s" + "32s" + "10s" + "8s" + "QH" + "64s" + \
"<256s" + "32s" + "32s" + "10s" + "8s" + "QH" + "64s" + "hhhhh" + "180s" "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( first_null = next((index for index, byte in enumerate(b)
(index for index, byte in enumerate(b) if byte == 0), None 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
@@ -59,22 +56,22 @@ class WavBextReader:
#: SMPTE 330M UMID of this audio file, 64 bytes are allocated though #: SMPTE 330M UMID of this audio file, 64 bytes are allocated though
#: the UMID may only be 32 bytes long. #: the UMID may only be 32 bytes long.
self.umid: bytes | None = None self.umid: Optional[bytes] = None
#: EBU R128 Integrated loudness, in LUFS. #: EBU R128 Integrated loudness, in LUFS.
self.loudness_value: float | None = None self.loudness_value: Optional[float] = None
#: EBU R128 Loudness range, in LUFS. #: EBU R128 Loudness range, in LUFS.
self.loudness_range: float | None = None self.loudness_range: Optional[float] = None
#: True peak level, in dBFS TP #: True peak level, in dBFS TP
self.max_true_peak: float | None = None self.max_true_peak: Optional[float] = None
#: EBU R128 Maximum momentary loudness, in LUFS #: EBU R128 Maximum momentary loudness, in LUFS
self.max_momentary_loudness: float | None = None self.max_momentary_loudness: Optional[float] = None
#: EBU R128 Maximum short-term loudness, in LUFS. #: EBU R128 Maximum short-term loudness, in LUFS.
self.max_shortterm_loudness: float | None = None self.max_shortterm_loudness: Optional[float] = None
if self.version > 0: if self.version > 0:
self.umid = unpacked[7] self.umid = unpacked[7]
@@ -94,19 +91,18 @@ class WavBextReader:
# umid_str = None # umid_str = None
return { return {'description': self.description,
"description": self.description, 'originator': self.originator,
"originator": self.originator, 'originator_ref': self.originator_ref,
"originator_ref": self.originator_ref, 'originator_date': self.originator_date,
"originator_date": self.originator_date, 'originator_time': self.originator_time,
"originator_time": self.originator_time, 'time_reference': self.time_reference,
"time_reference": self.time_reference, 'version': self.version,
"version": self.version, 'umid': self.umid,
"umid": self.umid, 'coding_history': self.coding_history,
"coding_history": self.coding_history, 'loudness_value': self.loudness_value,
"loudness_value": self.loudness_value, 'loudness_range': self.loudness_range,
"loudness_range": self.loudness_range, 'max_true_peak': self.max_true_peak,
"max_true_peak": self.max_true_peak, 'max_momentary_loudness': self.max_momentary_loudness,
"max_momentary_loudness": self.max_momentary_loudness, 'max_shortterm_loudness': self.max_shortterm_loudness
"max_shortterm_loudness": self.max_shortterm_loudness, }
}
+56 -74
View File
@@ -7,15 +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 __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from struct import calcsize, unpack
from typing import Any, Generator, NamedTuple
from .riff_parser import ChunkDescriptor from .riff_parser import ChunkDescriptor
from struct import unpack, calcsize
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.
CountryCodes = """000 None Indicated CountryCodes = """000 None Indicated
@@ -103,7 +100,6 @@ 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
@@ -122,37 +118,29 @@ 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( return cls(name=parsed[0], position=parsed[1], chunk_id=parsed[2],
name=parsed[0], chunk_start=parsed[3], block_start=parsed[4],
position=parsed[1], sample_offset=parsed[5])
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( return cls(name=unpack("<I", data[0:4])[0],
name=unpack("<I", data[0:4])[0], text=data[4:].decode(encoding).rstrip("\0") text=data[4:].decode(encoding).rstrip("\0"))
)
NoteEntry = LabelEntry NoteEntry = LabelEntry
@@ -162,7 +150,6 @@ class RangeLabel(NamedTuple):
""" """
A ``ltxt`` structure. A ``ltxt`` structure.
""" """
name: int name: int
length: int length: int
purpose: str purpose: str
@@ -175,50 +162,41 @@ 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):]
purpose_str = parsed[2].decode('ascii')
if data[6] != 0: if data[6] != 0:
fallback_encoding = f"cp{data[6]}" fallback_encoding = f"cp{data[6]}"
return cls( return cls(name=parsed[0], length=parsed[1], purpose=parsed[2],
name=parsed[0], country=parsed[3], language=parsed[4],
length=parsed[1], dialect=parsed[5], codepage=parsed[6],
purpose=purpose_str, text=text_data.decode(fallback_encoding))
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]
#: Every ``labl`` in the file #: Every ``labl`` in the file
labels: list[LabelEntry] labels: List[LabelEntry]
#: Every ``ltxt`` in the file #: Every ``ltxt`` in the file
ranges: list[RangeLabel] ranges: List[RangeLabel]
#: Every ``note`` in the file #: Every ``note`` in the file
notes: list[NoteEntry] notes: List[NoteEntry]
@classmethod @classmethod
def read_all( def read_all(cls, f,
cls, cues: Optional[ChunkDescriptor],
f, labls: List[ChunkDescriptor],
cues: ChunkDescriptor | None, ltxts: List[ChunkDescriptor],
labls: list[ChunkDescriptor], notes: List[ChunkDescriptor],
ltxts: list[ChunkDescriptor], fallback_encoding: str) -> 'WavCuesReader':
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)
@@ -227,33 +205,35 @@ 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), encoding=fallback_encoding) LabelEntry.read(labl.read_data(f),
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), fallback_encoding=fallback_encoding) RangeLabel.read(r.read_data(f),
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), encoding=fallback_encoding) NoteEntry.read(note.read_data(f),
encoding=fallback_encoding)
) )
return WavCuesReader( return WavCuesReader(cues=cue_list, labels=label_list,
cues=cue_list, labels=label_list, ranges=range_list, notes=note_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]:
""" """
Iterate through each cue. Iterate through each cue.
@@ -262,7 +242,8 @@ 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[str | None, str | None]: def label_and_note(self, cue_ident: int) -> Tuple[Optional[str],
Optional[str]]:
""" """
Get the label and note (extended comment) for a cue. Get the label and note (extended comment) for a cue.
@@ -270,35 +251,36 @@ 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 = next((label.text for label in self.labels
(label.text for label in self.labels if label.name == cue_ident), None if label.name == cue_ident), None)
) note = next((n.text for n in self.notes
note = next((n.text for n in self.notes if n.name == cue_ident), None) if n.name == cue_ident), None)
return (label, note) return (label, note)
def range(self, cue_ident: int) -> int | None: def range(self, cue_ident: int) -> Optional[int]:
""" """
Get the length of the time range for a cue, if it has one. Get the length of the time range for a cue, if it has one.
: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 if r.name == cue_ident), None) return next((r.length for r in self.ranges
if r.name == cue_ident), None)
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
retval = {} retval = dict()
for n, t in self.each_cue(): for n, t in self.each_cue():
retval[n] = {} 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
+105 -139
View File
@@ -7,20 +7,18 @@ Unless otherwise stated, all § references here are to
.. _EBU Tech 3285 Supplement 6: https://tech.ebu.ch/docs/tech/tech3285s6.pdf .. _EBU Tech 3285 Supplement 6: https://tech.ebu.ch/docs/tech/tech3285s6.pdf
""" """
from __future__ import annotations from enum import IntEnum, Enum
from dataclasses import asdict, dataclass
from enum import Enum, IntEnum
from io import BytesIO
from struct import unpack from struct import unpack
from typing import Any from dataclasses import dataclass, asdict
from typing import List, Tuple, Any, Union
from io import BytesIO
class SegmentType(IntEnum): class SegmentType(IntEnum):
""" """
Metadata segment type. Metadata segment type.
""" """
EndMarker = 0x0 EndMarker = 0x0
DolbyE = 0x1 DolbyE = 0x1
# Reserved2 = 0x2 # Reserved2 = 0x2
@@ -31,7 +29,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):
@@ -84,7 +82,6 @@ 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
@@ -129,7 +126,6 @@ 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"
@@ -167,7 +163,6 @@ 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
@@ -179,22 +174,24 @@ 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
class MixLevel(int): class MixLevel(int):
""" """
§ 4.3.6.2 § 4.3.6.2
""" """
pass
class DialnormLevel(int): class DialnormLevel(int):
""" """
§ 4.3.4.4 § 4.3.4.4
""" """
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
@@ -206,7 +203,6 @@ 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
@@ -217,7 +213,6 @@ 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
@@ -227,7 +222,6 @@ 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
@@ -252,7 +246,6 @@ 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,20 +334,16 @@ class DolbyDigitalPlusMetadata:
@staticmethod @staticmethod
def load(buffer: bytes): def load(buffer: bytes):
assert len(buffer) == 96, ( assert len(buffer) == 96, "Dolby Digital Plus segment incorrect size, "
"Dolby Digital Plus segment incorrect size, " "expected 96 got %i" % len(buffer)
"expected 96 got %i" % len(buffer)
)
def program_id(b) -> int: def program_id(b) -> int:
return b return b
def program_info(b): def program_info(b):
return ( return (b & 0x40) > 0, \
(b & 0x40) > 0, DolbyDigitalPlusMetadata.BitStreamMode(b & 0x38 >> 3), \
DolbyDigitalPlusMetadata.BitStreamMode(b & 0x38 >> 3), DolbyDigitalPlusMetadata.AudioCodingMode(b & 0x7)
DolbyDigitalPlusMetadata.AudioCodingMode(b & 0x7),
)
def ddplus_reserved1(_): def ddplus_reserved1(_):
pass pass
@@ -362,49 +351,39 @@ 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 ( return (b & 0x80) > 0, b & 0x40 > 0, b & 0x20 > 0, \
(b & 0x80) > 0, DolbyDigitalPlusMetadata.DialnormLevel(b & 0x1f)
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 ( return (b & 0x80) > 0, \
(b & 0x80) > 0, DolbyDigitalPlusMetadata.MixLevel(b & 0x7c >> 2), \
DolbyDigitalPlusMetadata.MixLevel(b & 0x7C >> 2), DolbyDigitalPlusMetadata.RoomType(b & 0x3)
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( return DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x38 >> 3), \
b & 0x38 >> 3 DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x7)
), 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 ( return DolbyDigitalPlusMetadata\
DolbyDigitalPlusMetadata.PreferredDownMixMode(b & 0xC0 >> 6), .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 ( return DolbyDigitalPlusMetadata.SurroundEXMode(b & 0x60 >> 5), \
DolbyDigitalPlusMetadata.SurroundEXMode(b & 0x60 >> 5), DolbyDigitalPlusMetadata.HeadphoneMode(b & 0x18 >> 3), \
DolbyDigitalPlusMetadata.HeadphoneMode(b & 0x18 >> 3), DolbyDigitalPlusMetadata.ADConverterType(b & 0x4 >> 2)
DolbyDigitalPlusMetadata.ADConverterType(b & 0x4 >> 2),
)
def ddplus_reserved2(_): def ddplus_reserved2(_):
pass pass
@@ -413,13 +392,13 @@ class DolbyDigitalPlusMetadata:
return DolbyDigitalPlusMetadata.RFCompressionProfile(b) return DolbyDigitalPlusMetadata.RFCompressionProfile(b)
def dynrng1(b): def dynrng1(b):
return DolbyDigitalPlusMetadata.RFCompressionProfile(b) DolbyDigitalPlusMetadata.RFCompressionProfile(b)
def ddplus_reserved3(_): def ddplus_reserved3(_):
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
@@ -433,24 +412,19 @@ 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, dolby_surround_encoded = ( center_downmix_level, surround_downmix_level, \
surround_config(buffer[4]) dolby_surround_encoded = surround_config(buffer[4])
) langcode_present, copyright_bitstream, original_bitstream, \
langcode_present, copyright_bitstream, original_bitstream, dialnorm = ( dialnorm = dialnorm_info(buffer[5])
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_surround_downmix_level = ext_bsi1_word1( loro_center_downmix_level, \
buffer[8] loro_surround_downmix_level = ext_bsi1_word1(buffer[8])
) downmix_mode, ltrt_center_downmix_level, \
downmix_mode, ltrt_center_downmix_level, ltrt_surround_downmix_level = ( ltrt_surround_downmix_level = ext_bsi1_word2(buffer[9])
ext_bsi1_word2(buffer[9]) surround_ex_mode, dolby_headphone_encoded, \
) 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])
@@ -462,34 +436,32 @@ class DolbyDigitalPlusMetadata:
reserved(buffer[27:69]) reserved(buffer[27:69])
return DolbyDigitalPlusMetadata( return DolbyDigitalPlusMetadata(
program_id=pid, program_id=pid, lfe_on=lfe_on,
lfe_on=lfe_on, bitstream_mode=bitstream_mode,
bitstream_mode=bitstream_mode, audio_coding_mode=audio_coding_mode,
audio_coding_mode=audio_coding_mode, center_downmix_level=center_downmix_level,
center_downmix_level=center_downmix_level, surround_downmix_level=surround_downmix_level,
surround_downmix_level=surround_downmix_level, dolby_surround_encoded=dolby_surround_encoded,
dolby_surround_encoded=dolby_surround_encoded, langcode_present=langcode_present,
langcode_present=langcode_present, copyright_bitstream=copyright_bitstream,
copyright_bitstream=copyright_bitstream, original_bitstream=original_bitstream,
original_bitstream=original_bitstream, dialnorm=dialnorm,
dialnorm=dialnorm, langcode=langcode,
langcode=langcode, prod_info_exists=prod_info_exists,
prod_info_exists=prod_info_exists, mixlevel=mixlevel,
mixlevel=mixlevel, roomtype=roomtype,
roomtype=roomtype, loro_center_downmix_level=loro_center_downmix_level,
loro_center_downmix_level=loro_center_downmix_level, loro_surround_downmix_level=loro_surround_downmix_level,
loro_surround_downmix_level=loro_surround_downmix_level, downmix_mode=downmix_mode,
downmix_mode=downmix_mode, ltrt_center_downmix_level=ltrt_center_downmix_level,
ltrt_center_downmix_level=ltrt_center_downmix_level, ltrt_surround_downmix_level=ltrt_surround_downmix_level,
ltrt_surround_downmix_level=ltrt_surround_downmix_level, surround_ex_mode=surround_ex_mode,
surround_ex_mode=surround_ex_mode, dolby_headphone_encoded=dolby_headphone_encoded,
dolby_headphone_encoded=dolby_headphone_encoded, ad_converter_type=ad_converter_type,
ad_converter_type=ad_converter_type, compression_profile=compression,
compression_profile=compression, dynamic_range=dynamic_range,
dynamic_range=dynamic_range, stream_dependency=stream_info,
stream_dependency=stream_info, datarate_kbps=data_rate)
datarate_kbps=data_rate,
)
@dataclass @dataclass
@@ -508,7 +480,7 @@ class DolbyAtmosMetadata:
NOT_INDICATED = 0x04 NOT_INDICATED = 0x04
tool_name: str tool_name: str
tool_version: tuple[int, int, int] tool_version: Tuple[int, int, int]
warp_mode: WarpMode warp_mode: WarpMode
SEGMENT_LENGTH = 248 SEGMENT_LENGTH = 248
@@ -516,6 +488,7 @@ 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)}")
@@ -525,7 +498,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)
@@ -535,11 +508,10 @@ 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( return DolbyAtmosMetadata(tool_name=toolname,
tool_name=toolname, tool_version=(major, minor, fix),
tool_version=(major, minor, fix), warp_mode=DolbyAtmosMetadata
warp_mode=DolbyAtmosMetadata.WarpMode(warp_mode), .WarpMode(warp_mode))
)
@dataclass @dataclass
@@ -559,14 +531,15 @@ 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 = []
@@ -591,9 +564,9 @@ class DolbyAtmosSupplementalMetadata:
binaural_mode &= 0x7 binaural_mode &= 0x7
render_modes.append(binaural_mode) render_modes.append(binaural_mode)
return DolbyAtmosSupplementalMetadata( return DolbyAtmosSupplementalMetadata(object_count=object_count,
object_count=object_count, render_modes=render_modes, trim_modes=trim_modes render_modes=render_modes,
) trim_modes=trim_modes)
class WavDolbyMetadataReader: class WavDolbyMetadataReader:
@@ -607,18 +580,18 @@ class WavDolbyMetadataReader:
#: indicating if the segment's checksum was valid, and the #: indicating if the segment's checksum was valid, and the
#: segment's parsed dataclass (or a `bytes` array if it was #: segment's parsed dataclass (or a `bytes` array if it was
#: not recognized). #: not recognized).
segment_list: list[tuple[SegmentType | int, bool, Any]] segment_list: List[Tuple[Union[SegmentType, int], bool, Any]]
version: tuple[int, int, int, int] version: Tuple[int, int, int, int]
@staticmethod @staticmethod
def segment_checksum(bs: bytes, size: int): def segment_checksum(bs: bytes, size: int):
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
@@ -641,9 +614,8 @@ 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.segment_checksum( expected_checksum = WavDolbyMetadataReader\
seg_payload, seg_size .segment_checksum(seg_payload, seg_size)
)
checksum = unpack("B", h.read(1))[0] checksum = unpack("B", h.read(1))[0]
segment = seg_payload segment = seg_payload
@@ -654,41 +626,35 @@ class WavDolbyMetadataReader:
elif stype == SegmentType.DolbyAtmosSupplemental: elif stype == SegmentType.DolbyAtmosSupplemental:
segment = DolbyAtmosSupplementalMetadata.load(segment) segment = DolbyAtmosSupplementalMetadata.load(segment)
self.segment_list.append( self.segment_list\
(stype, checksum == expected_checksum, segment) .append((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 [ return [x[2] for x in self.segment_list
x[2] if x[0] == SegmentType.DolbyDigitalPlus and x[1]]
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 [ return [x[2] for x in self.segment_list
x[2] for x in self.segment_list if x[0] == SegmentType.DolbyAtmos and x[1] 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 [ return [x[2] for x in self.segment_list
x[2] if x[0] == SegmentType.DolbyAtmosSupplemental and x[1]]
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 = [asdict(x) for x in self.dolby_digital_plus()]
atmos = [asdict(x) for x in self.dolby_atmos()] ddp = map(lambda x: asdict(x), self.dolby_digital_plus())
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 {"dolby_digital_plus": ddp, "dolby_atmos": atmos} return dict(dolby_digital_plus=list(ddp),
dolby_atmos=list(atmos))
+49 -57
View File
@@ -1,9 +1,10 @@
from __future__ import annotations from .riff_parser import parse_chunk, ListChunkDescriptor
from .riff_parser import ListChunkDescriptor, parse_chunk from typing import Optional
class WavInfoChunkReader: class WavInfoChunkReader:
def __init__(self, f, encoding): def __init__(self, f, encoding):
self.encoding = encoding self.encoding = encoding
@@ -11,64 +12,56 @@ 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 = [ list_chunks = [chunk for chunk in parsed_chunks.children
chunk if type(chunk) is ListChunkDescriptor]
for chunk in parsed_chunks.children
if type(chunk) is ListChunkDescriptor
]
self.info_chunk = next( self.info_chunk = next((chunk for chunk in list_chunks
(chunk for chunk in list_chunks if chunk.signature == b"INFO"), None if chunk.signature == b'INFO'), None)
)
#: 'ICOP' Copyright #: 'ICOP' Copyright
self.copyright: str | None = self._get_field(f, b"ICOP") self.copyright: Optional[str] = self._get_field(f, b'ICOP')
#: 'IPRD' Product #: 'IPRD' Product
self.product: str | None = self._get_field(f, b"IPRD") self.product: Optional[str] = self._get_field(f, b'IPRD')
self.album: str | None = self.product self.album: Optional[str] = self.product
#: 'IGNR' Genre #: 'IGNR' Genre
self.genre: str | None = self._get_field(f, b"IGNR") self.genre: Optional[str] = self._get_field(f, b'IGNR')
#: 'ISBJ' Subject #: 'ISBJ' Subject
self.subject: str | None = 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: str | None = self._get_field(f, b"IART") self.artist: Optional[str] = self._get_field(f, b'IART')
#: 'ICMT' Comment #: 'ICMT' Comment
self.comment: str | None = 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: str | None = 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: str | None = self._get_field(f, b"ICRD") self.created_date: Optional[str] = self._get_field(f, b'ICRD')
#: 'IENG' Engineer #: 'IENG' Engineer
self.engineer: str | None = self._get_field(f, b"IENG") self.engineer: Optional[str] = self._get_field(f, b'IENG')
#: 'ITCH' Technician #: 'ITCH' Technician
self.technician: str | None = 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: str | None = self._get_field(f, b"IKEY") self.keywords: Optional[str] = self._get_field(f, b'IKEY')
#: 'INAM' Name, title #: 'INAM' Name, title
self.title: str | None = self._get_field(f, b"INAM") self.title: Optional[str] = self._get_field(f, b'INAM')
#: 'ISRC' Source #: 'ISRC' Source
self.source: str | None = self._get_field(f, b"ISRC") self.source: Optional[str] = self._get_field(f, b'ISRC')
#: 'TAPE' Tape #: 'TAPE' Tape
self.tape: str | None = 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: str | None = self._get_field(f, b"IARL") self.archival_location: Optional[str] = self._get_field(f, b'IARL')
#: 'ICSM' Commissioned #: 'ICSM' Commissioned
self.commissioned: str | None = self._get_field(f, b"ICMS") self.commissioned: Optional[str] = self._get_field(f, b'ICMS')
def _get_field(self, f, field_ident) -> str | None: def _get_field(self, f, field_ident) -> Optional[str]:
search = next( search = next(((chunk.start, chunk.length)
( for chunk in self.info_chunk.children
(chunk.start, chunk.length) if chunk.ident == field_ident),
for chunk in self.info_chunk.children None)
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
@@ -76,27 +69,26 @@ 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 { return {'copyright': self.copyright,
"copyright": self.copyright, 'product': self.product,
"product": self.product, 'album': self.album,
"album": self.album, 'genre': self.genre,
"genre": self.genre, 'artist': self.artist,
"artist": self.artist, 'comment': self.comment,
"comment": self.comment, 'software': self.software,
"software": self.software, 'created_date': self.created_date,
"created_date": self.created_date, 'engineer': self.engineer,
"engineer": self.engineer, 'keywords': self.keywords,
"keywords": self.keywords, 'title': self.title,
"title": self.title, 'source': self.source,
"source": self.source, 'tape': self.tape,
"tape": self.tape, 'commissioned': self.commissioned,
"commissioned": self.commissioned, 'archival_location': self.archival_location,
"archival_location": self.archival_location, 'subject': self.subject,
"subject": self.subject, 'technician': self.technician
"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)
+62 -62
View File
@@ -1,12 +1,9 @@
from __future__ import annotations
import io
from enum import IntEnum
# from collections import namedtuple
from typing import NamedTuple
from lxml import etree as ET from lxml import etree as ET
import io
# from collections import namedtuple
from typing import Optional
from enum import IntEnum
from typing import NamedTuple
class IXMLTrack(NamedTuple): class IXMLTrack(NamedTuple):
@@ -25,7 +22,6 @@ class SteinbergMetadata:
""" """
Steinberg speaker format enumeration. Steinberg speaker format enumeration.
""" """
MONO = 0 MONO = 0
STEREO = 1 STEREO = 1
LRC = 10 LRC = 10
@@ -78,75 +74,82 @@ class SteinbergMetadata:
self.parsed = xml.find(self.Steinberg_xpath) self.parsed = xml.find(self.Steinberg_xpath)
@property @property
def audio_speaker_arrangement(self) -> AudioSpeakerArrangement | None: def audio_speaker_arrangement(self) -> Optional[AudioSpeakerArrangement]:
""" """
`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))
@property @property
def sample_format_size(self) -> int | None: def sample_format_size(self) -> Optional[int]:
""" """
AudioSampleFormatSize AudioSampleFormatSize
""" """
val = self.parsed.find("./ATTR_LIST/ATTR[NAME = 'AudioSampleFormatSize']/VALUE") val = self.parsed.find(
"./ATTR_LIST/ATTR[NAME = 'AudioSampleFormatSize']/VALUE")
if val is not None: if val is not None:
return int(val.text) return int(val.text)
@property @property
def media_company(self) -> str | None: def media_company(self) -> Optional[str]:
""" """
MediaCompany MediaCompany
""" """
val = self.parsed.find("./ATTR_LIST/ATTR[NAME = 'MediaCompany']/VALUE") val = self.parsed.find(
"./ATTR_LIST/ATTR[NAME = 'MediaCompany']/VALUE")
if val is not None: if val is not None:
return val.text return val.text
@property @property
def media_drop_frames(self) -> bool | None: def media_drop_frames(self) -> Optional[bool]:
""" """
MediaDropFrames MediaDropFrames
""" """
val = self.parsed.find("./ATTR_LIST/ATTR[NAME = 'MediaDropFrames']/VALUE") val = self.parsed.find(
"./ATTR_LIST/ATTR[NAME = 'MediaDropFrames']/VALUE")
if val is not None: if val is not None:
return val.text == "1" return val.text == "1"
@property @property
def media_duration(self) -> float | None: def media_duration(self) -> Optional[float]:
""" """
MediaDuration MediaDuration
""" """
val = self.parsed.find("./ATTR_LIST/ATTR[NAME = 'MediaDuration']/VALUE") val = self.parsed.find(
"./ATTR_LIST/ATTR[NAME = 'MediaDuration']/VALUE")
if val is not None: if val is not None:
return float(val.text) return float(val.text)
# @property @property
# def media_start_time(self) -> float | None: def media_start_time(self) -> Optional[float]:
# """ """
# MediaStartTime MediaStartTime
# """ """
pass
# @property @property
# def media_track_title(self) -> str | None: def media_track_title(self) -> Optional[str]:
# """ """
# MediaTrackTitle MediaTrackTitle
# """ """
pass
# @property @property
# def program_name(self) -> str | None: def program_name(self) -> Optional[str]:
# """ """
# ProgramName ProgramName
# """ """
pass
# @property @property
# def program_version(self) -> str | None: def program_version(self) -> Optional[str]:
# """ """
# ProgramVersion ProgramVersion
# """ """
pass
class WavIXMLFormat: class WavIXMLFormat:
@@ -164,7 +167,7 @@ class WavIXMLFormat:
parser = ET.XMLParser(recover=True) parser = ET.XMLParser(recover=True)
self.parsed: ET.ElementTree = ET.parse(xml_bytes, parser=parser) self.parsed: ET.ElementTree = ET.parse(xml_bytes, parser=parser)
def _get_text_value(self, xpath) -> str | None: def _get_text_value(self, xpath) -> Optional[str]:
e = self.parsed.find("./" + xpath) e = self.parsed.find("./" + xpath)
if e is not None: if e is not None:
return e.text return e.text
@@ -189,44 +192,45 @@ 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("string(INTERLEAVE_INDEX/text())"), interleave_index=track.xpath(
name=track.xpath("string(NAME/text())"), 'string(INTERLEAVE_INDEX/text())'),
function=track.xpath("string(FUNCTION/text())"), name=track.xpath('string(NAME/text())'),
function=track.xpath('string(FUNCTION/text())')
) )
@property @property
def project(self) -> str | None: def project(self) -> Optional[str]:
""" """
The project/film name entered for the recording. The project/film name entered for the recording.
""" """
return self._get_text_value("PROJECT") return self._get_text_value("PROJECT")
@property @property
def scene(self) -> str | None: def scene(self) -> Optional[str]:
""" """
Scene/slate. Scene/slate.
""" """
return self._get_text_value("SCENE") return self._get_text_value("SCENE")
@property @property
def take(self) -> str | None: def take(self) -> Optional[str]:
""" """
Take number. Take number.
""" """
return self._get_text_value("TAKE") return self._get_text_value("TAKE")
@property @property
def tape(self) -> str | None: def tape(self) -> Optional[str]:
""" """
Tape name. Tape name.
""" """
return self._get_text_value("TAPE") return self._get_text_value("TAPE")
@property @property
def family_uid(self) -> str | None: def family_uid(self) -> Optional[str]:
""" """
The globally-unique ID for this file family. This may be in the format The globally-unique ID for this file family. This may be in the format
of a GUID, or an EBU Rec 9 source identifier, or some other dumb of a GUID, or an EBU Rec 9 source identifier, or some other dumb
@@ -235,14 +239,14 @@ class WavIXMLFormat:
return self._get_text_value("FILE_SET/FAMILY_UID") return self._get_text_value("FILE_SET/FAMILY_UID")
@property @property
def family_name(self) -> str | None: def family_name(self) -> Optional[str]:
""" """
The name of this file's file family. The name of this file's file family.
""" """
return self._get_text_value("FILE_SET/FAMILY_NAME") return self._get_text_value("FILE_SET/FAMILY_NAME")
@property @property
def steinberg(self) -> SteinbergMetadata | None: def steinberg(self) -> Optional[SteinbergMetadata]:
""" """
Steinberg vendor iXML metadata if present. Steinberg vendor iXML metadata if present.
""" """
@@ -252,12 +256,8 @@ class WavIXMLFormat:
return None return None
def to_dict(self): def to_dict(self):
return { return dict(
"track_list": [x._asdict() for x in self.track_list], track_list=list(map(lambda x: x._asdict(), self.track_list)),
"project": self.project, project=self.project, scene=self.scene, take=self.take,
"scene": self.scene, tape=self.tape, family_uid=self.family_uid,
"take": self.take, family_name=self.family_name)
"tape": self.tape,
"family_uid": self.family_uid,
"family_name": self.family_name,
}
+85 -106
View File
@@ -1,17 +1,18 @@
from __future__ import annotations # -*- coding: utf-8 -*-
import os
import pathlib
import struct import struct
from typing import Any, Generator, NamedTuple import os
from typing import Optional, Generator, Any, NamedTuple
from .riff_parser import ChunkDescriptor, ListChunkDescriptor, parse_chunk import pathlib
from .wave_adm_reader import WavADMReader
from .wave_bext_reader import WavBextReader
from .wave_cues_reader import WavCuesReader from .riff_parser import parse_chunk, ChunkDescriptor, ListChunkDescriptor
from .wave_dbmd_reader import WavDolbyMetadataReader
from .wave_info_reader import WavInfoChunkReader
from .wave_ixml_reader import WavIXMLFormat from .wave_ixml_reader import WavIXMLFormat
from .wave_bext_reader import WavBextReader
from .wave_info_reader import WavInfoChunkReader
from .wave_adm_reader import WavADMReader
from .wave_dbmd_reader import WavDolbyMetadataReader
from .wave_cues_reader import WavCuesReader
from .wave_smpl_reader import WavSmplReader from .wave_smpl_reader import WavSmplReader
#: Calculated statistics about the audio data. #: Calculated statistics about the audio data.
@@ -37,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.
@@ -59,35 +60,35 @@ class WavInfoReader:
self.bext_encoding = bext_encoding self.bext_encoding = bext_encoding
#: Wave audio data format. #: Wave audio data format.
self.fmt: WavAudioFormat | None = None self.fmt: Optional[WavAudioFormat] = None
#: Statistics of the `data` section. #: Statistics of the `data` section.
self.data: WavDataDescriptor | None = None self.data: Optional[WavDataDescriptor] = None
#: Broadcast-Wave metadata. #: Broadcast-Wave metadata.
self.bext: WavBextReader | None = None self.bext: Optional[WavBextReader] = None
#: iXML metadata. #: iXML metadata.
self.ixml: WavIXMLFormat | None = None self.ixml: Optional[WavIXMLFormat] = None
#: ADM Audio Definiton Model metadata. #: ADM Audio Definiton Model metadata.
self.adm: WavADMReader | None = None self.adm: Optional[WavADMReader] = None
#: Dolby bitstream metadata. #: Dolby bitstream metadata.
self.dolby: WavDolbyMetadataReader | None = None self.dolby: Optional[WavDolbyMetadataReader] = None
#: RIFF INFO metadata. #: RIFF INFO metadata.
self.info: WavInfoChunkReader | None = None self.info: Optional[WavInfoChunkReader] = None
#: RIFF cues markers, labels, and notes. #: RIFF cues markers, labels, and notes.
self.cues: WavCuesReader | None = None self.cues: Optional[WavCuesReader] = None
#: Sampler `smpl` metadata #: Sampler `smpl` metadata
self.smpl: WavSmplReader | None = 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:
@@ -98,8 +99,8 @@ class WavInfoReader:
self.path = absolute_path self.path = absolute_path
with open(path, "rb") as file: with open(path, 'rb') as path:
self.get_wav_info(file) self.get_wav_info(path)
def get_wav_info(self, wavfile): def get_wav_info(self, wavfile):
chunks = parse_chunk(wavfile) chunks = parse_chunk(wavfile)
@@ -118,41 +119,36 @@ 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, default_none=False) -> bytes | None: def _find_chunk_data(self, ident, from_stream,
top_chunks = ( default_none=False) -> Optional[bytes]:
chunk top_chunks = (chunk for chunk in self.main_list
for chunk in self.main_list if type(chunk) is ChunkDescriptor and
if type(chunk) is ChunkDescriptor and chunk.ident == ident chunk.ident == ident)
)
chunk_descriptor = next(top_chunks, None) if default_none else next(top_chunks) chunk_descriptor = next(top_chunks, None) \
if default_none else next(top_chunks)
return chunk_descriptor.read_data(from_stream) if chunk_descriptor else None return chunk_descriptor.read_data(from_stream) \
if chunk_descriptor else None
def _find_list_chunk(self, signature) -> ListChunkDescriptor | None: def _find_list_chunk(self, signature) -> Optional[ListChunkDescriptor]:
top_chunks = ( top_chunks = (chunk for chunk in self.main_list
chunk if type(chunk) is ListChunkDescriptor and
for chunk in self.main_list chunk.signature == signature)
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( data_chunk = next(c for c in self.main_list
c if type(c) is ChunkDescriptor and c.ident == b'data')
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"
@@ -160,79 +156,62 @@ class WavInfoReader:
unpacked = struct.unpack(packstring, fmt_data[:rest_starts]) unpacked = struct.unpack(packstring, fmt_data[:rest_starts])
return WavAudioFormat( return WavAudioFormat(audio_format=unpacked[0],
audio_format=unpacked[0], channel_count=unpacked[1],
channel_count=unpacked[1], sample_rate=unpacked[2],
sample_rate=unpacked[2], byte_rate=unpacked[3],
byte_rate=unpacked[3], block_align=unpacked[4],
block_align=unpacked[4], bits_per_sample=unpacked[5]
bits_per_sample=unpacked[5], )
)
def _get_info(self, f, encoding): def _get_info(self, f, encoding):
finder = ( finder = (chunk.signature for chunk in self.main_list
chunk.signature if type(chunk) is ListChunkDescriptor)
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) if axml and chna else None return WavADMReader(axml_data=axml, chna_data=chna) \
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) if dbmd_data else None return WavDolbyMetadataReader(dbmd_data=dbmd_data) \
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 = next((cue_chunk for cue_chunk in self.main_list if
( type(cue_chunk) is ChunkDescriptor and
cue_chunk cue_chunk.ident == b'cue '), None)
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 = [ labls = [c for c in adtl.children
c if type(c) is ChunkDescriptor and c.ident == b'labl']
for c in adtl.children ltxts = [c for c in adtl.children
if type(c) is ChunkDescriptor and c.ident == b"labl" if type(c) is ChunkDescriptor and c.ident == b'ltxt']
] notes = [c for c in adtl.children
ltxts = [ if type(c) is ChunkDescriptor and c.ident == b'note']
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( return WavCuesReader.read_all(f, cue, labls, ltxts, notes,
f, cue, labls, ltxts, notes, fallback_encoding=self.info_encoding 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()"
@@ -246,22 +225,22 @@ class WavInfoReader:
"smpl". "smpl".
""" """
scopes = ("fmt", "data", "ixml", "bext", "info", "adm", "cues", "dolby", "smpl") scopes = ('fmt', 'data', 'ixml', 'bext', 'info', 'adm', 'cues',
'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 = ( mdict = self.__getattribute__(scope).to_dict(
self.__getattribute__(scope).to_dict() ) if self.__getattribute__(scope) else {}
if self.__getattribute__(scope) for key in mdict.keys():
else {}
)
for key in mdict:
yield scope, key, mdict[key] yield scope, key, mdict[key]
def __repr__(self): def __repr__(self):
return f"WavInfoReader({self.path}, {self.info_encoding}, {self.bext_encoding})" return 'WavInfoReader({}, {}, {})'.format(self.path,
self.info_encoding,
self.bext_encoding)
+39 -42
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import struct import struct
from typing import NamedTuple
from typing import Tuple, NamedTuple, List
class WaveSmplLoop(NamedTuple): class WaveSmplLoop(NamedTuple):
@@ -14,29 +13,30 @@ 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,7 +47,8 @@ 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, smpl_data[0:header_size]) unpacked_data = struct.unpack(header_field_fmt,
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.
@@ -71,47 +72,43 @@ class WavSmplReader:
#: The SMPTE offset to apply, as a tuple of four ints representing #: The SMPTE offset to apply, as a tuple of four ints representing
#: hh, mm, ss, ff #: hh, mm, ss, ff
self.smpte_offset: tuple[int, int, int, int] = unpacked_data[6:10] self.smpte_offset: Tuple[int, int, int, int] = unpacked_data[6:10]
loop_count = unpacked_data[10] loop_count = unpacked_data[10]
sampler_udata_length = unpacked_data[11] sampler_udata_length = unpacked_data[11]
#: 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 : header_size + loop_size * loop_count] loop_buffer = smpl_data[header_size:
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( self.sample_loops.append(WaveSmplLoop(
WaveSmplLoop( ident=unpacked_loop[0],
ident=unpacked_loop[0], loop_type=unpacked_loop[1],
loop_type=unpacked_loop[1], start=unpacked_loop[2],
start=unpacked_loop[2], end=unpacked_loop[3],
end=unpacked_loop[3], detune_cents=unpacked_loop[4],
detune_cents=unpacked_loop[4], repetition_count=unpacked_loop[5]))
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 header_size + loop_size * loop_count:
+ loop_size * loop_count header_size + loop_size * loop_count + sampler_udata_length]
+ 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,
} }
+17 -13
View File
@@ -1,30 +1,34 @@
""" """
wavfind.py
Under construction! Do Not Use!
""" """
from optparse import OptionParser, OptionGroup
import sys import sys
from optparse import OptionGroup, OptionParser
def main(): def main():
parser = OptionParser() parser = OptionParser()
parser.usage = "wavfind [--scene=SCENE] [--take=TAKE] [--desc=DESC] <PATH> +" parser.usage = ("wavfind [--scene=SCENE] [--take=TAKE] [--desc=DESC] "
"<PATH> +")
primaries = OptionGroup( primaries = OptionGroup(parser, title="Search Predicates",
parser, description="Argument values can be globs, "
title="Search Predicates", "and are logically-AND'ed.")
description="Argument values can be globs, and are logically-AND'ed.",
)
primaries.add_option("--scene", help="Search for this scene", metavar="SCENE") primaries.add_option("--scene",
help='Search for this scene',
metavar='SCENE')
primaries.add_option("--take", help="Search for this take", metavar="TAKE") primaries.add_option("--take",
help='Search for this take',
metavar='TAKE')
primaries.add_option("--desc", help="Search descriptions", metavar="DESC") primaries.add_option("--desc",
help='Search descriptions',
metavar='DESC')
(_options, _args) = parser.parse_args(sys.argv) (options, args) = parser.parse_args(sys.argv)
if __name__ == "__main__": if __name__ == "__main__":
+44
View File
@@ -0,0 +1,44 @@
from . import test_wave_parsing
from io import BytesIO
from typing import Generator
import zipfile as zf
import os.path
import os
from contextlib import contextmanager
# class TestFileLoader:
# """
# This guy manages the test_files archive.
# """
# def __init__(self, test_file_arch_name = 'archive.zip') -> None:
# self.base_path = os.path.join(os.path.dirname(__file__), "test_files")
# self.test_file_arch_name = test_file_arch_name
# self._gather_test_files_into_archive()
# @property
# def arch_path(self):
# return os.path.join(self.base_path, self.test_file_arch_name)
# @contextmanager
# def open(self, name) -> Generator[BytesIO]:
# z = zf.ZipFile(self.arch_path, 'r')
# member = z.open(name, 'r')
# try:
# yield member
# finally:
# zf.close()
# def _gather_test_files_into_archive(self):
# with zf.ZipFile(self.arch_path, 'a') as zip:
# for root, _, files in os.walk(self.base_path):
# for name in files:
# if root == self.base_path and name == self.test_file_arch_name:
# continue
# else:
# p = os.path.join(root, name)
# zip.write(p)
# os.unlink(p)
+7 -8
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()
@@ -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)
@@ -42,14 +42,13 @@ class TestADMWave(TestCase):
adm = info.adm adm = info.adm
assert adm is not None assert adm is not None
t1 = adm.track_info(0) t1 = adm.track_info(0)
assert t1 is not None self.assertTrue("channel_format_name" in t1.keys())
self.assertTrue("channel_format_name" in t1)
self.assertEqual("RoomCentricLeft", t1["channel_format_name"]) self.assertEqual("RoomCentricLeft", t1["channel_format_name"])
self.assertTrue("pack_format_name" in t1) self.assertTrue("pack_format_name" in t1.keys())
self.assertEqual("AtmosCustomPackFormat1", t1["pack_format_name"]) self.assertEqual("AtmosCustomPackFormat1", t1["pack_format_name"])
t10 = adm.track_info(10) t10 = adm.track_info(10)
assert t10 is not None self.assertTrue("content_name" in t10.keys())
self.assertTrue("content_name" in t10)
self.assertEqual("Dialog", t10["content_name"]) self.assertEqual("Dialog", t10["content_name"])
+11 -10
View File
@@ -1,9 +1,8 @@
from glob import glob
from unittest import TestCase from unittest import TestCase
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")
@@ -15,7 +14,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"
@@ -24,7 +23,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)
@@ -45,11 +44,9 @@ class TestCue(TestCase):
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]
@@ -65,10 +62,14 @@ class TestCue(TestCase):
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")
+17 -18
View File
@@ -1,8 +1,7 @@
from unittest import TestCase from unittest import TestCase
import wavinfo import wavinfo
from wavinfo.wave_dbmd_reader import DolbyDigitalPlusMetadata, SegmentType from wavinfo.wave_dbmd_reader import SegmentType, DolbyDigitalPlusMetadata
class TestDolby(TestCase): class TestDolby(TestCase):
def setUp(self): def setUp(self):
@@ -13,15 +12,17 @@ class TestDolby(TestCase):
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 if x[0] == SegmentType.DolbyDigitalPlus] ddp = [x for x in d.segment_list \
atmos = [x for x in d.segment_list if x[0] == SegmentType.DolbyAtmos] if x[0] == SegmentType.DolbyDigitalPlus]
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)
@@ -39,23 +40,21 @@ 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( self.assertEqual(len(ddp), 1,
len(ddp), ("Failed to find exactly one Dolby Digital Plus "
1, "metadata segment")
("Failed to find exactly one Dolby Digital Plus metadata segment"), )
)
self.assertTrue( self.assertTrue( ddp[0].audio_coding_mode,
ddp[0].audio_coding_mode, DolbyDigitalPlusMetadata.AudioCodingMode.CH_ORD_3_2 )
DolbyDigitalPlusMetadata.AudioCodingMode.CH_ORD_3_2, self.assertTrue( ddp[0].lfe_on)
)
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( self.assertEqual(len(atmos), 1,
len(atmos), 1, "Failed to find exactly one Atmos metadata segment" "Failed to find exactly one Atmos metadata segment")
)
Binary file not shown.
+21 -10
View File
@@ -1,23 +1,34 @@
import glob
import sys
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from wavinfo.__main__ import main from wavinfo.__main__ import main
import sys
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', []):
main() try:
main()
except:
self.fail("main() throwing an exception")
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]):
main() try:
main()
except:
self.fail("main() throwing an exception")
def test_ixml(self): def test_ixml(self):
with patch.object( with patch.object(sys, 'argv',
sys, "argv", ["TEST", "--ixml", "tests/test_files/sounddevices/A101_1.WAV"] ['TEST', '--ixml',
): 'tests/test_files/sounddevices/A101_1.WAV']):
main() try:
main()
except:
self.fail("main() throwing an exception")
+10 -5
View File
@@ -1,20 +1,25 @@
# import os.path # import os.path
import gzip import gzip
from glob import glob from glob import glob
# from typing import Dict, Any, cast # from typing import Dict, Any, cast
from unittest import TestCase from unittest import TestCase
# from .utils import all_files, ffprobe # from .utils import all_files, ffprobe
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"):
with gzip.open(path) as gz: 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)
+1 -2
View File
@@ -1,9 +1,8 @@
from glob import glob
from unittest import TestCase from unittest import TestCase
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")
+15 -13
View File
@@ -1,35 +1,37 @@
import glob
import unittest import unittest
import wavinfo import wavinfo
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" and key == "channel_count": if scope == 'fmt':
if key == 'channel_count':
tested_format = True tested_format = True
self.assertEqual(value, 2) self.assertEqual(value, 2)
if scope == "data" and key == "frame_count": if scope == 'data':
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 Exception as e: except:
# self.fail(f"Failed to walk metadata in file {file}: {e}") self.fail(f"Failed to walk metadata in file {file}")
if __name__ == "__main__":
if __name__ == '__main__':
unittest.main() unittest.main()
+91 -111
View File
@@ -1,21 +1,21 @@
import os.path import os.path
from glob import glob from glob import glob
from typing import Any, Dict, cast from typing import Dict, Any, cast
from unittest import TestCase from unittest import TestCase
import wavinfo
from .utils import all_files, ffprobe from .utils import all_files, ffprobe
import wavinfo
class TestWaveInfo(TestCase): 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( self.assertEqual(info.__repr__(),
info.__repr__(), 'WavInfoReader({}, latin_1, ascii)'
f"WavInfoReader({os.path.abspath(wav_file)}, 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,39 +26,31 @@ 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( self.assertEqual(info.fmt.channel_count,
info.fmt.channel_count, ffprobe_info["streams"][0]["channels"] ffprobe_info['streams'][0]['channels'])
) self.assertEqual(info.fmt.sample_rate,
self.assertEqual( int(ffprobe_info['streams'][0]['sample_rate']))
info.fmt.sample_rate, int(ffprobe_info["streams"][0]["sample_rate"]) self.assertEqual(info.fmt.bits_per_sample,
) 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( self.assertTrue(ffprobe_info['streams'][0]['codec_name']\
ffprobe_info["streams"][0]["codec_name"].startswith("pcm") .startswith('pcm'))
) streams = ffprobe_info['streams'][0]
streams = ffprobe_info["streams"][0] byte_rate = int(streams['sample_rate']) * \
byte_rate = ( streams['channels'] * \
int(streams["sample_rate"]) int(streams['bits_per_sample']) / 8
* 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( self.assertEqual(info.data.frame_count,
info.data.frame_count, int(ffprobe_info["streams"][0]["duration_ts"]) 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():
@@ -67,78 +59,65 @@ 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( self.assertEqual(info.bext.description,
info.bext.description, ffprobe_info["format"]["tags"]["comment"] ffprobe_info['format']['tags']\
) ['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( self.assertEqual(info.bext.originator,
info.bext.originator, ffprobe_info['format']['tags']\
ffprobe_info["format"]["tags"]["encoded_by"], ['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( self.assertEqual(info.bext.originator_ref,
info.bext.originator_ref, ffprobe_info['format']['tags']\
ffprobe_info["format"]["tags"]["originator_reference"], ['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( self.assertEqual(info.bext.time_reference,
info.bext.time_reference, int(ffprobe_info['format']['tags']\
int(ffprobe_info["format"]["tags"]["time_reference"]), ['time_reference']))
)
if "coding_history" in ffprobe_info["format"]["tags"]: if 'coding_history' in ffprobe_info['format']['tags']:
self.assertEqual( self.assertEqual(info.bext.coding_history,
info.bext.coding_history, ffprobe_info['format']['tags']\
ffprobe_info["format"]["tags"]["coding_history"], ['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 = { expected = {'A101_4.WAV': {'project': 'BMH',
"A101_4.WAV": { 'scene': 'A101', 'take': '4',
"project": "BMH", 'tape': '18Y12M31',
"scene": "A101", 'family_uid':
"take": "4", 'USSDVGR1112089007124015008231000'},
"tape": "18Y12M31", 'A101_3.WAV': {'project': 'BMH',
"family_uid": "USSDVGR1112089007124015008231000", 'scene': 'A101', 'take': '3',
}, 'tape': '18Y12M31',
"A101_3.WAV": { 'family_uid':
"project": "BMH", 'USSDVGR1112089007124014008228300'},
"scene": "A101", 'A101_2.WAV': {'project': 'BMH',
"take": "3", 'scene': 'A101', 'take': '2',
"tape": "18Y12M31", 'tape': '18Y12M31',
"family_uid": "USSDVGR1112089007124014008228300", 'family_uid':
}, 'USSDVGR1112089007124004008218600'},
"A101_2.WAV": { 'A101_1.WAV': {'project': 'BMH',
"project": "BMH", 'scene': 'A101', 'take': '1',
"scene": "A101", 'tape': '18Y12M31',
"take": "2", 'family_uid':
"tape": "18Y12M31", 'USSDVGR1112089007124001008206300'},
"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)
@@ -147,19 +126,19 @@ class TestWaveInfo(TestCase):
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
@@ -167,10 +146,8 @@ class TestWaveInfo(TestCase):
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( self.assertEqual(info.ixml.steinberg.media_company,
info.ixml.steinberg.media_company, "https://github.com/iluvcapra/wavinfo")
"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)
@@ -182,21 +159,24 @@ 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 = "tests/test_files/sound_grinder_pro/new_camera bumb 1.wav" file_with_metadata = \
'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, "Sound Effect, movement, microphone, bump") self.assertEqual(info.keywords,
self.assertEqual(info.title, "camera bumb 1") 'Sound Effect, movement, microphone, bump')
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)
+7 -1
View File
@@ -1,6 +1,12 @@
import os.path
import sys
import json
import subprocess
from subprocess import PIPE
from unittest import TestCase from unittest import TestCase
# import wavinfo import wavinfo
class TestZoomF8(TestCase): class TestZoomF8(TestCase):
+25 -10
View File
@@ -1,23 +1,38 @@
import json
import os.path import os.path
import sys
import subprocess import subprocess
from subprocess import PIPE
import json
FFPROBE = "ffprobe" FFPROBE = 'ffprobe'
def ffprobe(path): def ffprobe(path):
arguments = [FFPROBE, "-of", "json", "-show_format", "-show_streams", path] arguments = [FFPROBE, "-of", "json",
process = subprocess.run(arguments, check=True, capture_output=True) "-show_format", "-show_streams", path]
if process.returncode == 0: if int(sys.version[0]) < 3:
output_str = process.stdout.decode("utf-8") process = subprocess.Popen(arguments, stdout=PIPE)
return json.loads(output_str) process.wait()
if process.returncode == 0:
output = process.communicate()[0]
if output:
output_str = output.decode('utf-8')
return json.loads(output_str)
else:
return None
else: else:
return None process = subprocess.run(arguments, stdin=None,
stdout=PIPE, stderr=PIPE)
if process.returncode == 0:
output_str = process.stdout.decode('utf-8')
return json.loads(output_str)
else:
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)