mirror of
https://github.com/iluvcapra/wavinfo.git
synced 2026-10-02 09:47:27 +00:00
Compare commits
173 Commits
v2.3.0
..
d7612f2fdf
| Author | SHA1 | Date | |
|---|---|---|---|
| d7612f2fdf | |||
| e57bcc30c9 | |||
| 39c3868b7f | |||
| 739e574a10 | |||
| b3d5377f7b | |||
| f3547c8bc5 | |||
| 5d481e2960 | |||
| 94aec13d00 | |||
| 0060212083 | |||
| 90d15c78c0 | |||
| ae79a32d4a | |||
| 50dbc35595 | |||
| 6f5cb795a3 | |||
| 7d5bb29a42 | |||
| f9de312610 | |||
| e6320e84c6 | |||
| 13d9cd4914 | |||
| 742c0b91fb | |||
| 8e56a1aeb2 | |||
| 6bc3636814 | |||
| e12ae4519e | |||
| 8926274c50 | |||
| 95ba34187a | |||
| 4d2dfcd370 | |||
| 717b6a4117 | |||
| 7393afac95 | |||
| e9450cd65a | |||
| 51b1a2e8b4 | |||
| 53303232b4 | |||
| 3519a0251f | |||
| 79ec1649c4 | |||
| 7c3f4c9b5e | |||
| 2e8224cb3c | |||
| 8ef4186b4c | |||
| cd5346c1cb | |||
| 925bf4f8a6 | |||
| e98ba0bf07 | |||
| afca634dc3 | |||
| df9ae0f4d6 | |||
| 2f23bcb982 | |||
| 5e641b0963 | |||
| 1b57ad0fac | |||
| c7a34e0064 | |||
| 6b788484da | |||
| 76905f1a40 | |||
| 9ac06040a2 | |||
| 1b78f5b821 | |||
| 03d718b4ad | |||
| 61f79760e6 | |||
| afe5ea9ed3 | |||
| c1205d52e8 | |||
| b82b6b6d43 | |||
| 8ef664266f | |||
| dfb7e34fc7 | |||
| 2ebdefaab5 | |||
| c609e22270 | |||
| ef9c39f1b6 | |||
| cc9d884ea8 | |||
| 94563f69a9 | |||
| 2830cb87a4 | |||
| 1c8581ff35 | |||
| 1d499d9741 | |||
| 299f79aeb3 | |||
| a46590df29 | |||
| c6f66b2d6e | |||
| b8617a35e2 | |||
| 8cabf948ff | |||
| 8a755b4466 | |||
| c13b07e4a3 | |||
| ac37c14b3d | |||
| 36e4a02ab8 | |||
| ffc0c48af7 | |||
| 206962b218 | |||
| d560e5a9f0 | |||
| 98ca1ec462 | |||
| f0353abd4e | |||
| 6304666d11 | |||
| d2b0c68dd2 | |||
| a0a9c38cb4 | |||
| f68eea4cd9 | |||
| 016e504f65 | |||
| bf536f66ec | |||
| 2ab9e940ab | |||
| 7104f3c18a | |||
| f04c563fe2 | |||
| 06fa3cc422 | |||
| 83a44de492 | |||
| d8f57c8607 | |||
| 7c3ae745b7 | |||
| dc18b4eb99 | |||
| 259994d514 | |||
| 9c51a6d146 | |||
| 28e0532994 | |||
| 29ca62b970 | |||
| 77ce1e3bc0 | |||
| 82129cee07 | |||
| c249ce058d | |||
| a66049b425 | |||
| e60723afcf | |||
| 8b402f310c | |||
| c3c8ba2908 | |||
| 9b4f3d7ede | |||
| 38eddccf85 | |||
| d3e8349d81 | |||
| 57603ff618 | |||
| e7d5f612ea | |||
| b322c8171b | |||
| 7e5c888e32 | |||
| 275ac10636 | |||
| 38601c64db | |||
| 067cca82b6 | |||
| 37ae8de5b0 | |||
| a20e9dd9ac | |||
| 94a84b49dd | |||
| 4210905e17 | |||
| a8ede17201 | |||
| 8579dc0693 | |||
| 89e9959a43 | |||
| bfd2217e23 | |||
| f32055964d | |||
| 8e97c2f7b0 | |||
| 6d0fee02fc | |||
| 10a28f8fb3 | |||
| 514cfe0e75 | |||
| ab42cba5b0 | |||
| 73a9f93beb | |||
| 3071bad007 | |||
| dbb282ad07 | |||
| 51ca03816a | |||
| 6107342e98 | |||
| adf90612cd | |||
| a196e4786e | |||
| e2ca087e08 | |||
| c5841a5fd0 | |||
| 2e5cd4331f | |||
| 5e07d01688 | |||
| a01d791262 | |||
| ca7a177ea6 | |||
| 4206cd4473 | |||
| 5b1e4ab631 | |||
| f978927648 | |||
| 6575a0c442 | |||
| f5be5b36d7 | |||
| 8a58df2b87 | |||
| 3817357fac | |||
| 4f51584fe9 | |||
| 86a4edc983 | |||
| ce2e1fe8bc | |||
| 6a10cd8427 | |||
| d75e55e870 | |||
| 4f3ea72c98 | |||
| 32b0878229 | |||
| 9fee03a67b | |||
| a2ea978de0 | |||
| bfeb7ed651 | |||
| f978c5cf8b | |||
| 41b84b8399 | |||
| 77275a7351 | |||
| c25ac56555 | |||
| 99118367e9 | |||
| c002120c61 | |||
| d7540b0a79 | |||
| d04af2d194 | |||
| bbbe947f3b | |||
| 71a6d752ca | |||
| 42c0f9ce0d | |||
| 75ec68f500 | |||
| f3f9f6b784 | |||
| 7bc5378304 | |||
| 45c6e90db6 | |||
| 8da8e0f4f4 | |||
| 9e41d39b26 | |||
| cd5aacfe10 |
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Add Support For New Metadata Type
|
||||
about: For adding support for a new type of metadata
|
||||
title: "[METADATA]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the type of metadata you want to read:**
|
||||
???
|
||||
|
||||
**List some applications that read and write this metadata:**
|
||||
???
|
||||
|
||||
**List the authorities or organizations that use and standardize this metadata:**
|
||||
???
|
||||
|
||||
**URL for example WAVE file with this metadata:**
|
||||
???
|
||||
|
||||
**(Optional) Four-character code identifier for this metadata (if known):**
|
||||
???
|
||||
|
||||
**(Optional) URLs for documentation of this metadata:**
|
||||
???
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
python-version: ["3.11"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2.5.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
||||
|
||||
name: Python Lint and Test
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -16,27 +16,21 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2.5.0
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4.3.0
|
||||
uses: actions/setup-python@v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install flake8 pytest
|
||||
python -m pip install -e .
|
||||
python -m pip install --group dev
|
||||
python -m pip install .
|
||||
- name: Setup FFmpeg
|
||||
uses: FedericoCarboni/setup-ffmpeg@v2
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
uses: federicocarboni/setup-ffmpeg@v3.1
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
pytest
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
||||
|
||||
name: Lint with Ruff
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2.5.0
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4.3.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --group dev
|
||||
python -m pip install .
|
||||
- name: Lint with ruff
|
||||
run: |
|
||||
ruff check src
|
||||
@@ -8,29 +8,33 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4.2.2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5.3.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install setuptools build wheel twine lxml
|
||||
- name: Build and publish
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_APIKEY }}
|
||||
- name: Setup uv and Handle Its Cache
|
||||
# You may pin to the exact commit or the version.
|
||||
# uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817
|
||||
uses: hynek/setup-cached-uv@v2.3.0
|
||||
- name: Build
|
||||
run: |
|
||||
python -m build .
|
||||
twine upload dist/*
|
||||
- name: Report to Mastodon
|
||||
uses: cbrgm/mastodon-github-action@v1.0.1
|
||||
uv build --wheel
|
||||
- name: Publish to Pypi
|
||||
uses: pypa/gh-action-pypi-publish@v1.13.0
|
||||
with:
|
||||
message: |
|
||||
I just released a new version of wavinfo, my library for reading WAVE file metadata!
|
||||
#sounddesign #filmmaking #audio #python
|
||||
${{ github.server_url }}/${{ github.repository }}
|
||||
env:
|
||||
MASTODON_URL: ${{ secrets.MASTODON_URL }}
|
||||
MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }}
|
||||
password: ${{ secrets.PYPI_APIKEY }}
|
||||
# - name: Send Bluesky Post
|
||||
# uses: myConsciousness/bluesky-post@v5
|
||||
# with:
|
||||
# text: |
|
||||
# I've released a new version of wavinfo, my module for
|
||||
# reading WAVE metadata.
|
||||
# link-preview-url: ${{ github.server_url }}/${{ github.repository }}
|
||||
# identifier: ${{ secrets.BLUESKY_APP_USER }}
|
||||
# password: ${{ secrets.BLUESKY_APP_PASSWORD }}
|
||||
# service: bsky.social
|
||||
# retry-count: 1
|
||||
|
||||
@@ -110,3 +110,5 @@ venv_docs/
|
||||
.DS_Store
|
||||
|
||||
.vscode/
|
||||
|
||||
poetry.lock
|
||||
|
||||
+6
-8
@@ -7,13 +7,13 @@ version: 2
|
||||
|
||||
# Set the version of Python and other tools you might need
|
||||
build:
|
||||
os: ubuntu-20.04
|
||||
os: ubuntu-lts-latest
|
||||
tools:
|
||||
python: "3.10"
|
||||
# You can also specify other tool versions:
|
||||
# nodejs: "16"
|
||||
# rust: "1.55"
|
||||
# golang: "1.17"
|
||||
python: "3.13"
|
||||
jobs:
|
||||
install:
|
||||
- pip install --upgrade pip
|
||||
- pip install --group 'doc'
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
@@ -28,5 +28,3 @@ python:
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
||||
extra_requirements:
|
||||
- doc
|
||||
|
||||
+33
-1
@@ -12,4 +12,36 @@ If you discover a bug or would like better support for a feature, please do the
|
||||
If you're facile with Python and understand what you'd like to fix, submit a PR and I'll
|
||||
review it as soon as I can. There's a `.devcontainer` available so you can creates commits
|
||||
on this project in a GitHub codespace.
|
||||
|
||||
|
||||
|
||||
## 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,34 +1,44 @@
|
||||
[](https://wavinfo.readthedocs.io/en/latest/?badge=latest)   [](https://pypi.org/project/wavinfo/) 
|
||||
[](https://github.com/iluvcapra/wavinfo/actions/workflows/python-package.yml)
|
||||
[](https://codecov.io/gh/iluvcapra/wavinfo)
|
||||
 [](https://pypi.org/project/wavinfo/) 
|
||||
 [](https://wavinfo.readthedocs.io/en/latest/?badge=latest) 
|
||||
|
||||
[](https://github.com/iluvcapra/wavinfo/actions/workflows/python-package.yml)
|
||||
[](https://github.com/iluvcapra/wavinfo/actions/workflows/python-ruff.yml)
|
||||
|
||||
# wavinfo
|
||||
|
||||
The `wavinfo` package allows you to probe WAVE and [RF64/WAVE files][eburf64]
|
||||
and extract extended metadata, with an emphasis on film, video and
|
||||
professional music production.
|
||||
and extract extended metadata. `wavinfo` has an emphasis on film, video and
|
||||
professional music production but can read many other kinds.
|
||||
|
||||
If you are trying to read a particular kind of metadata from a WAV file and
|
||||
it is not supported, please submit an issue!
|
||||
|
||||
|
||||
## Metadata Support
|
||||
|
||||
`wavinfo` reads:
|
||||
|
||||
* [Broadcast-WAVE][bext] metadata, including embedded program
|
||||
* All defined [Broadcast-WAVE][bext] fields, including embedded program
|
||||
loudness, coding history and [SMPTE UMID][smpte_330m2011].
|
||||
* [Audio Definition Model (ADM)][adm] track metadata and schema, including
|
||||
channel, pack formats,
|
||||
object, content and programme.
|
||||
* [Dolby Digital Plus][ebu3285s6] and Dolby Atmos `dbmd` metadata.
|
||||
* [iXML][ixml] production recorder metadata, including project, scene, and
|
||||
take tags, recorder notes and file family information.
|
||||
* iXML `STEINBERG` sound library attributes.
|
||||
* Wave embedded cue markers, cue marker labels, notes and timed ranges as used
|
||||
* All known [RIFF INFO][info-tags] metadata fields.
|
||||
* [Audio Definition Model (ADM)][adm] track metadata and schema, including
|
||||
channel, pack formats,
|
||||
object, content and programme, including [Dolby Digital Plus][ebu3285s6]
|
||||
and Dolby Atmos `dbmd` metadata for re-renders and mixdowns.
|
||||
* Wave embedded [cue markers][cues], cue marker labels, notes and timed ranges as used
|
||||
by Zoom, iZotope RX, etc.
|
||||
* Most of the common [RIFF INFO][info-tags] metadata fields.
|
||||
* The __wav format__ is also parsed, so you can access the basic sample rate
|
||||
* Wave embedded [sampler][smpl] and sample loop metadata.
|
||||
* The [wav format][format] is also parsed, so you can access the basic sample rate
|
||||
and channel count information.
|
||||
|
||||
|
||||
[format]:https://wavinfo.readthedocs.io/en/latest/classes.html#wavinfo.wave_reader.WavAudioFormat
|
||||
[cues]:https://wavinfo.readthedocs.io/en/latest/scopes/cue.html
|
||||
[bext]:https://wavinfo.readthedocs.io/en/latest/scopes/bext.html
|
||||
[smpl]:https://wavinfo.readthedocs.io/en/latest/scopes/smpl.html
|
||||
[smpte_330m2011]:https://wavinfo.readthedocs.io/en/latest/scopes/bext.html#wavinfo.wave_bext_reader.WavBextReader.umid
|
||||
[adm]:https://wavinfo.readthedocs.io/en/latest/scopes/adm.html
|
||||
[ebu3285s6]:https://wavinfo.readthedocs.io/en/latest/scopes/dolby.html
|
||||
@@ -58,6 +68,12 @@ The package also installs a shell command:
|
||||
$ wavinfo test_files/A101_1.WAV
|
||||
```
|
||||
|
||||
## Contributions!
|
||||
|
||||
Any new or different kind of metadata you find, or any
|
||||
new or different use of exising metadata you encounter, please submit
|
||||
an Issue or Pull Request!
|
||||
|
||||
## Other Resources
|
||||
|
||||
* For other file formats and ID3 decoding,
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
.TH waveinfo 7 "2023-11-07" "Jamie Hardt" "Miscellaneous Information Manuals"
|
||||
.SH NAME
|
||||
wavinfo \- information about wave sound file metadata
|
||||
.\" .SH DESCRIPTION
|
||||
.SH CHUNK MENAGERIE
|
||||
A list of chunks that you may find in a wave file from our experience.
|
||||
.SS Essential WAV Chunks
|
||||
.IP fmt
|
||||
Defines the format of the audio in the
|
||||
.I data
|
||||
chunk: the audio codec, the sample rate, bit depth, channel count, block
|
||||
alignment and other data. May take an "extended" form, with additional data
|
||||
(such as channel speaker assignments) if there are more than two channels in
|
||||
the file or if it is a compressed format.
|
||||
.IP data
|
||||
The audio data itself. PCM audio data is always stored as interleaved samples.
|
||||
.IP JUNK
|
||||
A region of the file not currently in use. Clients sometimes add these before
|
||||
the
|
||||
.I data
|
||||
chunk in order to align the beginning of the audio data with a memory page
|
||||
boundary (this can make memory-mapped reads from a wave file a little more
|
||||
efficient). A
|
||||
.I JUNK
|
||||
chunk is often placed at the beginning of a WAVE file to reserve space for
|
||||
a
|
||||
.I ds64
|
||||
chunk that will be written to the file at the end of recording, in the event
|
||||
that after the file is finalized, it exceeds the RIFF size limit. Thus a WAVE
|
||||
file can be upgraded in-place to an RF64 without re-writing the audio data.
|
||||
.IP fact
|
||||
Fact chunks record the number of samples in the decoded audio stream. It's only
|
||||
present in WAVE files that contain compressed audio.
|
||||
.IP "LIST or list"
|
||||
(Both have been seen) Not a chunk type itself but signals to a RIFF parser that
|
||||
this chunk contains chunks itself. A LIST chunk's payload will begin with a
|
||||
four-character code identifying the form of the list, and is then followed
|
||||
by chunks of the standard key-length-data form, which may themselves be
|
||||
LISTs that themselves contain child chunks. WAVE files don't tend to have a
|
||||
very deep heirarchy of chunks, compared to AVI files.
|
||||
.SS RIFF Metadata
|
||||
The RIFF container format has a metadata system common to all RIFF files, WAVE
|
||||
being the most common at present, AVI being another very common format
|
||||
historically.
|
||||
.IP INFO
|
||||
A
|
||||
.I LIST
|
||||
form containing a flat list of chunks, each containing text metadata. The role
|
||||
of the string, like "Artist", "Composer", "Comment", "Engineer" etc. are given
|
||||
by the four-character code: "Artist" is
|
||||
.IR IART ,
|
||||
Composer is
|
||||
.IR ICMP ,
|
||||
engineer is
|
||||
.IR IENG ,
|
||||
Comment is
|
||||
.IR ICMT ,
|
||||
etc.
|
||||
.IP cue
|
||||
A binary list of cues, which are timed points within the audio data.
|
||||
.IP adtl
|
||||
A
|
||||
.I LIST
|
||||
form containing text labels
|
||||
.RI ( labl )
|
||||
for the cues in the
|
||||
.I cue
|
||||
chunk, "notes"
|
||||
.RI ( note ,
|
||||
which are structurally identical to
|
||||
.I labl
|
||||
but hosts tend to use notes for longer text), and "length text"
|
||||
.I ltxt
|
||||
metadata records, which can give a cue a length, making it a range, and a text
|
||||
field that defines its own encoding.
|
||||
.IP CSET
|
||||
Defines the character set for all text fields in
|
||||
.IR INFO ,
|
||||
.I adtl
|
||||
and other RIFF-defined text fields. By default, all of the text in RIFF
|
||||
metadata fields is Windows Latin 1/ISO 8859-1, though as time passes many
|
||||
clients have simply taken to sticking UTF-8 into these fields. The
|
||||
.I CSET
|
||||
cannot represent UTF-8 as a valid option for text encoding, it only speaks
|
||||
Windows codepages, and we've never seen one in a WAVE file in any event and
|
||||
it's vanishingly likely an audio app would recognize one if it saw it.
|
||||
.SS Broadcast-WAVE Metadata
|
||||
Broadcast-WAVE is a set of extensions to WAVE files to facilitate media
|
||||
production maintained by the EBU.
|
||||
.IP bext
|
||||
A multi-field structure containing mostly fixed-width text data capturing
|
||||
essential production information: a 256 character free description field,
|
||||
originator name and a unique reference, recording date and time, a frame-based
|
||||
timestamp for sample-accurate recording time, and a coding history record. The
|
||||
extended form of the structure can hold a SMPTE UMID (a kind of UUID, which
|
||||
may also contain timestamp and geolocation data) and pre-computed program
|
||||
loudness measurements.
|
||||
.IP peak
|
||||
A binary data structure containing the peak envelope for the audio data, for
|
||||
use by clients to generate a waveform overview.
|
||||
.SS Audio Definition Model Metadata
|
||||
Audio Definition Model (ADM) metadata is a metadata standard for audio
|
||||
broadcast and distribution maintained by the ITU.
|
||||
.IP chna
|
||||
A binary list that associates individual channels in the file to entities
|
||||
in the ADM XML document stored in the
|
||||
.I axml
|
||||
chunk. A
|
||||
.I chna
|
||||
chunk will always appear with an
|
||||
.I axml
|
||||
chunk and vice versa.
|
||||
.IP axml
|
||||
Contains an XML document with Audio Definition Model metadata. ADM metadata
|
||||
describes the program the WAVE file belongs to, role, channel assignment,
|
||||
and encoding properties of individual channels in the WAVE file, and if the
|
||||
WAVE file contains object-based audio, it will also give all of the positioning
|
||||
and panning automation envelopes.
|
||||
.IP bxml
|
||||
This is defined by the ITU as a gzip-compressed version of the
|
||||
.I axml
|
||||
chunk.
|
||||
.IP sxml
|
||||
This is a hybrid binary/gzip-compressed-XML chunk that associates ADM
|
||||
documents with timed ranges of a WAVE file.
|
||||
.SS Dolby Metadata
|
||||
.IP dbmd
|
||||
Records hints for Dolby playback applications for downmixing, level
|
||||
normalization and other things.
|
||||
.SS Proprietary Chunks
|
||||
.IP ovwf
|
||||
.B (Pro Tools)
|
||||
Pre-computed waveform overview data.
|
||||
.IP regn
|
||||
.B (Pro Tools)
|
||||
Region and cue point metadata.
|
||||
.SS Chunks of Unknown Purpose
|
||||
.IP elm1
|
||||
.IP minf
|
||||
.IP umid
|
||||
.SH HISTORY
|
||||
The oldest document that defines the form of a Wave file is the
|
||||
.I Multimedia Programming Interface and Data Specifications 1.0
|
||||
of August 1991.
|
||||
.\" .SH REFERENCES
|
||||
.\" .SS ESSENTIAL FILE FORMAT
|
||||
.\" .TP
|
||||
.\" .UR https://www.aelius.com/njh/wavemetatools/doc/riffmci.pdf
|
||||
.\" Multimedia Programming Interface and Data Specifications 1.0
|
||||
.\" .UE
|
||||
.\" The original definition of the
|
||||
.\" .I RIFF
|
||||
.\" container, the
|
||||
.\" .I WAVE
|
||||
.\" form, the original metadata facilites, and things like language, country and
|
||||
.\" dialect enumerations.
|
||||
.\" .TP
|
||||
.\" .UR https://datatracker.ietf.org/doc/html/rfc2361
|
||||
.\" RFC 2361
|
||||
.\" .UE
|
||||
.\" A large RFC compilation of all of the known (in 1998) audio encoding formats
|
||||
.\" in use. 104 different codecs are documented with a name, the corresponding
|
||||
.\" magic number, and a vendor contact name, phone number and address (no
|
||||
.\" emails, strangely). Almost all of these are of historical interest only.
|
||||
.\" .SS RF64/Extended WAVE Format
|
||||
.\"
|
||||
.\" .TP
|
||||
.\" .UR https://www.itu.int/dms_pubrec/itu-r/rec/bs/R-REC-BS.2088-1-201910-I!!PDF-E.pdf
|
||||
.\" ITU Recommendation BS.2088-1-2019
|
||||
.\" .UE
|
||||
.\" BS.2088 gives a detailed description of the internals of an RF64 file,
|
||||
.\" .I ds64
|
||||
.\" structure and all formal requirements. It also defines the use of
|
||||
.\" .IR <axml> ,
|
||||
.\" .IR <bxml> ,
|
||||
.\" .IR <sxml> ,
|
||||
.\" and
|
||||
.\" .I <chna>
|
||||
.\" metadata chunks for the carriage of Audio Definition Model metadata.
|
||||
.\" .TP
|
||||
.\" .UR https://tech.ebu.ch/docs/tech/tech3306.pdf
|
||||
.\" EBU Tech 3306 "RF64: An Extended File Format for Audio Data"
|
||||
.\" .UE
|
||||
.\" Version 1 of Tech 3306 laid out the
|
||||
.\" .I RF64
|
||||
.\" extended WAVE
|
||||
.\" file format almost identically to
|
||||
.\" .IR BS.2088 ,
|
||||
.\" Version 2 of the standard wholly adopted
|
||||
.\" .IR BS.2088 .
|
||||
+106
-60
@@ -6,89 +6,135 @@ from the command line and output metadata to stdout.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
$ wavinfo [--ixml | --adm] INFILE +
|
||||
|
||||
By default, `wavinfo` will output a JSON dictionary for each file argument.
|
||||
$ wavinfo [[-i] | [--ixml | --adm]] INFILE +
|
||||
|
||||
|
||||
Options
|
||||
-------
|
||||
|
||||
Two option flags will change the behavior of the command:
|
||||
By default, `wavinfo` will output a JSON dictionary for each file argument.
|
||||
|
||||
``-i``
|
||||
`wavinfo` will run in `interactive mode`_.
|
||||
|
||||
Two option flags will change the behavior of the command in non-interactive
|
||||
mode:
|
||||
|
||||
``--ixml``
|
||||
The *\-\-ixml* flag will cause `wavinfo` to output the iXML metadata payload
|
||||
of each input wave file, or will emit an error message to stderr if iXML
|
||||
metadata is not present.
|
||||
The *\-\-ixml* flag will cause `wavinfo` to output the iXML metadata
|
||||
payload of each input wave file, or will emit an error message to stderr if
|
||||
iXML metadata is not present.
|
||||
|
||||
``--adm``
|
||||
The *\-\-adm* flag will cause `wavinfo` to output the ADM XML metadata
|
||||
payload of each input wave file, or will emit an error message to stderr if
|
||||
ADM XML metadata is not present.
|
||||
|
||||
These options are mutually-exclusive, with `\-\-adm` taking precedence.
|
||||
These options are mutually-exclusive, with `\-\-adm` taking precedence. The
|
||||
``--ixml`` and ``--adm`` flags futher take precedence over ``-i``.
|
||||
|
||||
|
||||
Interactive Mode
|
||||
-----------------
|
||||
|
||||
In interactive mode, `wavinfo` will present a command prompt which allows you
|
||||
to query the files provided on the command line and explore the metadata tree
|
||||
interactively. Each file on the command line is scanned and presented as a
|
||||
tree of metadata records.
|
||||
|
||||
Commands include:
|
||||
|
||||
``ls``
|
||||
List the available metadata keys at the current level.
|
||||
|
||||
``cd``
|
||||
Traverse to a metadata key in the current level (or enter `..` to go up
|
||||
to the prevvious level).
|
||||
|
||||
``bye``
|
||||
Exit to the shell.
|
||||
|
||||
Type `help` or `?` at the prompt to get a full list of commands.
|
||||
|
||||
|
||||
Example Output
|
||||
--------------
|
||||
|
||||
.. attention::
|
||||
|
||||
Metadata fields containing binary data, such as the Broadcast-WAV UMID, will
|
||||
be included in the JSON output as a base-64 encoded string, preceded by the
|
||||
marker "base64:".
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
{
|
||||
"filename": "tests/test_files/sounddevices/A101_1.WAV",
|
||||
"run_date": "2022-11-26T17:56:38.342935",
|
||||
"application": "wavinfo 2.1.0",
|
||||
"scopes": {
|
||||
"fmt": {
|
||||
"audio_format": 1,
|
||||
"channel_count": 2,
|
||||
"sample_rate": 48000,
|
||||
"byte_rate": 288000,
|
||||
"block_align": 6,
|
||||
"bits_per_sample": 24
|
||||
{
|
||||
"filename": "../tests/test_files/nuendo/wavinfo Test Project - Audio - 1OA.wav",
|
||||
"run_date": "2024-11-25T10:26:11.280053",
|
||||
"application": "wavinfo 3.0.0",
|
||||
"scopes": {
|
||||
"fmt": {
|
||||
"audio_format": 65534,
|
||||
"channel_count": 4,
|
||||
"sample_rate": 48000,
|
||||
"byte_rate": 576000,
|
||||
"block_align": 12,
|
||||
"bits_per_sample": 24
|
||||
},
|
||||
"data": {
|
||||
"byte_count": 576000,
|
||||
"frame_count": 48000
|
||||
},
|
||||
"ixml": {
|
||||
"track_list": [
|
||||
{
|
||||
"channel_index": "1",
|
||||
"interleave_index": "1",
|
||||
"name": "",
|
||||
"function": "ACN0-FOA"
|
||||
},
|
||||
"data": {
|
||||
"byte_count": 1441434,
|
||||
"frame_count": 240239
|
||||
{
|
||||
"channel_index": "2",
|
||||
"interleave_index": "2",
|
||||
"name": "",
|
||||
"function": "ACN1-FOA"
|
||||
},
|
||||
"ixml": {
|
||||
"track_list": [
|
||||
{
|
||||
"channel_index": "1",
|
||||
"interleave_index": "1",
|
||||
"name": "MKH516 A",
|
||||
"function": ""
|
||||
},
|
||||
{
|
||||
"channel_index": "2",
|
||||
"interleave_index": "2",
|
||||
"name": "Boom",
|
||||
"function": ""
|
||||
}
|
||||
],
|
||||
"project": "BMH",
|
||||
"scene": "A101",
|
||||
"take": "1",
|
||||
"tape": "18Y12M31",
|
||||
"family_uid": "USSDVGR1112089007124001008206300",
|
||||
"family_name": null
|
||||
{
|
||||
"channel_index": "3",
|
||||
"interleave_index": "3",
|
||||
"name": "",
|
||||
"function": "ACN2-FOA"
|
||||
},
|
||||
"bext": {
|
||||
"description": "sSPEED=023.976-ND\r\nsTAKE=1\r\nsUBITS=$12311801\r\nsSWVER=2.67\r\nsPROJECT=BMH\r\nsSCENE=A101\r\nsFILENAME=A101_1.WAV\r\nsTAPE=18Y12M31\r\nsTRK1=MKH516 A\r\nsTRK2=Boom\r\nsNOTE=\r\n",
|
||||
"originator": "Sound Dev: 702T S#GR1112089007",
|
||||
"originator_ref": "USSDVGR1112089007124001008206301",
|
||||
"originator_date": "2018-12-31",
|
||||
"originator_time": "12:40:00",
|
||||
"time_reference": 2190940753,
|
||||
"version": 1,
|
||||
"umid": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"coding_history": "A=PCM,F=48000,W=24,M=stereo,R=48000,T=2 Ch\r\n",
|
||||
"loudness_value": null,
|
||||
"loudness_range": null,
|
||||
"max_true_peak": null,
|
||||
"max_momentary_loudness": null,
|
||||
"max_shortterm_loudness": null
|
||||
{
|
||||
"channel_index": "4",
|
||||
"interleave_index": "4",
|
||||
"name": "",
|
||||
"function": "ACN3-FOA"
|
||||
}
|
||||
],
|
||||
"project": "wavinfo Test Project",
|
||||
"scene": null,
|
||||
"take": null,
|
||||
"tape": null,
|
||||
"family_uid": "E5DDE719B9484A758162FF7B652383A3",
|
||||
"family_name": null
|
||||
},
|
||||
"bext": {
|
||||
"description": "wavinfo Test Project Nuendo output",
|
||||
"originator": "Nuendo",
|
||||
"originator_ref": "USJPHNNNNNNNNN202829RRRRRRRRR",
|
||||
"originator_date": "2022-12-02",
|
||||
"originator_time": "10:21:06",
|
||||
"time_reference": 172800000,
|
||||
"version": 2,
|
||||
"umid": "base64:k/zr4qE4RiaXyd/fO7GuCwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"coding_history": "A=PCM,F=48000,W=24,T=Nuendo\r\n",
|
||||
"loudness_value": 327.67,
|
||||
"loudness_range": 327.67,
|
||||
"max_true_peak": 327.67,
|
||||
"max_momentary_loudness": 327.67,
|
||||
"max_shortterm_loudness": 327.67
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-34
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
@@ -12,24 +11,25 @@
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
# import importlib
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath('../..'))
|
||||
sys.path.insert(0, os.path.abspath("../../.."))
|
||||
print(sys.path)
|
||||
|
||||
import wavinfo
|
||||
sys.path.insert(0, os.path.abspath("../../src"))
|
||||
sys.path.insert(0, os.path.abspath("../../../src"))
|
||||
print(sys.path)
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = u'wavinfo'
|
||||
copyright = u'2018-2023, Jamie Hardt'
|
||||
author = u'Jamie Hardt'
|
||||
project = "wavinfo"
|
||||
copyright = "2018-2025, Jamie Hardt"
|
||||
author = "Jamie Hardt"
|
||||
|
||||
# The short X.Y version
|
||||
version = wavinfo.__short_version__
|
||||
version = "4.0"
|
||||
# The full version, including alpha/beta/rc tags
|
||||
release = wavinfo.__version__
|
||||
release = "4.0.0"
|
||||
# release = importlib.metadata.version("wavinfo")
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
@@ -42,34 +42,34 @@ release = wavinfo.__version__
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.todo',
|
||||
'sphinx.ext.coverage',
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.todo",
|
||||
"sphinx.ext.coverage",
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
templates_path = ["_templates"]
|
||||
|
||||
# The suffix(es) of source filenames.
|
||||
# You can specify multiple suffix as a list of string:
|
||||
#
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = '.rst'
|
||||
source_suffix = ".rst"
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
master_doc = "index"
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# 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
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = [u'_build', 'Thumbs.db', '.DS_Store']
|
||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = None
|
||||
@@ -80,7 +80,7 @@ pygments_style = None
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# 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
|
||||
# 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,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
html_static_path = ["_static"]
|
||||
|
||||
# Custom sidebar templates, must be a dictionary that maps document names
|
||||
# to template names.
|
||||
@@ -107,7 +107,7 @@ html_static_path = ['_static']
|
||||
# -- Options for HTMLHelp output ---------------------------------------------
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'wavinfodoc'
|
||||
htmlhelp_basename = "wavinfodoc"
|
||||
|
||||
|
||||
# -- Options for LaTeX output ------------------------------------------------
|
||||
@@ -116,15 +116,12 @@ latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#
|
||||
# 'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#
|
||||
# 'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#
|
||||
# 'preamble': '',
|
||||
|
||||
# Latex figure (float) alignment
|
||||
#
|
||||
# 'figure_align': 'htbp',
|
||||
@@ -134,8 +131,7 @@ latex_elements = {
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, 'wavinfo.tex', u'wavinfo Documentation',
|
||||
u'Jamie Hardt', 'manual'),
|
||||
(master_doc, "wavinfo.tex", "wavinfo Documentation", "Jamie Hardt", "manual"),
|
||||
]
|
||||
|
||||
|
||||
@@ -143,10 +139,7 @@ latex_documents = [
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
(master_doc, 'wavinfo', u'wavinfo Documentation',
|
||||
[author], 1)
|
||||
]
|
||||
man_pages = [(master_doc, "wavinfo", "wavinfo Documentation", [author], 1)]
|
||||
|
||||
|
||||
# -- Options for Texinfo output ----------------------------------------------
|
||||
@@ -155,9 +148,15 @@ man_pages = [
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(master_doc, 'wavinfo', u'wavinfo Documentation',
|
||||
author, 'wavinfo', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
(
|
||||
master_doc,
|
||||
"wavinfo",
|
||||
"wavinfo Documentation",
|
||||
author,
|
||||
"wavinfo",
|
||||
"One line description of project.",
|
||||
"Miscellaneous",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -176,7 +175,7 @@ epub_title = project
|
||||
# epub_uid = ''
|
||||
|
||||
# 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 -------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
References
|
||||
==========
|
||||
|
||||
A complete list of technical references and commentary is available as a man page
|
||||
and is installed as wavinfo(7) when you install `wavinfo` via pip.
|
||||
|
||||
Wave File Format
|
||||
----------------
|
||||
|
||||
@@ -33,6 +36,11 @@ iXML
|
||||
* `Gallery Software iXML Specification <http://www.gallery.co.uk/ixml/>`_
|
||||
|
||||
|
||||
Sampler Metadata
|
||||
----------------
|
||||
|
||||
* `RecordingBlogs.com — Sample chunk (of a Wave file) <https://www.recordingblogs.com/wiki/sample-chunk-of-a-wave-file>`_
|
||||
|
||||
RIFF Metadata
|
||||
-------------
|
||||
* `1991. Multimedia Programming Interface and Data Specifications 1.0 <https://www.aelius.com/njh/wavemetatools/doc/riffmci.pdf>`_
|
||||
|
||||
@@ -29,3 +29,12 @@ Class Reference
|
||||
|
||||
.. autoclass:: wavinfo.wave_cues_reader.WavCuesReader
|
||||
:members:
|
||||
|
||||
.. autoclass:: wavinfo.wave_cues_reader.CueEntry
|
||||
:members:
|
||||
|
||||
.. autoclass:: wavinfo.wave_cues_reader.LabelEntry
|
||||
:members:
|
||||
|
||||
.. autoclass:: wavinfo.wave_cues_reader.NoteEntry
|
||||
:members:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
Sampler Metadata
|
||||
=================
|
||||
|
||||
Class Reference
|
||||
---------------
|
||||
|
||||
.. automodule:: wavinfo.wave_smpl_reader
|
||||
|
||||
.. autoclass:: wavinfo.wave_smpl_reader.WavSmplReader
|
||||
:members:
|
||||
|
||||
.. autoclass:: wavinfo.wave_smpl_reader.WaveSmplLoop
|
||||
:members:
|
||||
+12
-4
@@ -26,7 +26,7 @@
|
||||
"source": [
|
||||
"from wavinfo import WavInfoReader\n",
|
||||
"\n",
|
||||
"path = '../tests/test_files/sounddevices/A101_1.WAV'\n",
|
||||
"path = \"../tests/test_files/sounddevices/A101_1.WAV\"\n",
|
||||
"\n",
|
||||
"info = WavInfoReader(path)"
|
||||
]
|
||||
@@ -46,6 +46,7 @@
|
||||
" * `adm`: EBU Audio Defintion Model metadata, as used by Dolby Atmos.\n",
|
||||
" * `cues`: Cue marker metadata, including labels and notes \n",
|
||||
" * `dolby`: Dolby recorder and playback metadata\n",
|
||||
" * `smpl`: Sampler midi note and loop metadata\n",
|
||||
"\n",
|
||||
"Each of these is an attribute of a `WavInfoReader` object.\n",
|
||||
"\n",
|
||||
@@ -112,7 +113,12 @@
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"(info.fmt.sample_rate, info.fmt.channel_count, info.fmt.block_align, info.fmt.bits_per_sample)"
|
||||
"(\n",
|
||||
" info.fmt.sample_rate,\n",
|
||||
" info.fmt.channel_count,\n",
|
||||
" info.fmt.block_align,\n",
|
||||
" info.fmt.bits_per_sample,\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -270,7 +276,9 @@
|
||||
],
|
||||
"source": [
|
||||
"path = \"../tests/test_files/cue_chunks/izotoperx_cues_test.wav\"\n",
|
||||
"info = WavInfoReader(path, info_encoding=\"utf-8\") # iZotope RX seems to encode marker text as UTF-8\n",
|
||||
"info = WavInfoReader(\n",
|
||||
" path, info_encoding=\"utf-8\"\n",
|
||||
") # iZotope RX seems to encode marker text as UTF-8\n",
|
||||
"\n",
|
||||
"for cue in info.cues.each_cue():\n",
|
||||
" print(f\"Cue ID: {cue[0]}\")\n",
|
||||
@@ -304,7 +312,7 @@
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.11.5"
|
||||
"version": "3.12.5"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
|
||||
+36
-39
@@ -1,27 +1,31 @@
|
||||
[build-system]
|
||||
requires = ["flit_core >=3.2,<4"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
requires = ["uv_build>=0.8.18,<0.9.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "wavinfo"
|
||||
authors = [{name = "Jamie Hardt", email = "jamiehardt@me.com"}]
|
||||
version = "4.0.1"
|
||||
description = "Probe WAVE files for all metadata"
|
||||
authors = [{ name = "Jamie Hardt", email = "jamiehardt@me.com"}]
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
dynamic = ["version", "description"]
|
||||
requires-python = "~=3.8"
|
||||
requires-python = ">=3.8"
|
||||
classifiers = [
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Topic :: Multimedia',
|
||||
'Topic :: Multimedia :: Sound/Audio',
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12"
|
||||
]
|
||||
dependencies = [
|
||||
"lxml ~= 4.9.2"
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14"
|
||||
]
|
||||
homepage = "https://github.com/iluvcapra/wavinfo"
|
||||
repository = "https://github.com/iluvcapra/wavinfo.git"
|
||||
documentation = "https://wavinfo.readthedocs.io/"
|
||||
urls.Tracker = 'https://github.com/iluvcapra/wavinfo/issues'
|
||||
keywords = [
|
||||
'waveform',
|
||||
'metadata',
|
||||
@@ -34,41 +38,34 @@ keywords = [
|
||||
'broadcast'
|
||||
]
|
||||
|
||||
[tool.flit.module]
|
||||
name = "wavinfo"
|
||||
|
||||
[project.optional-dependencies]
|
||||
doc = [
|
||||
'sphinx >= 5.3.0',
|
||||
'sphinx_rtd_theme >= 1.1.1',
|
||||
dependencies = [
|
||||
"lxml>=6.0.2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Home = "https://github.com/iluvcapra/wavinfo"
|
||||
Documentation = "https://wavinfo.readthedocs.io/"
|
||||
Source = "https://github.com/iluvcapra/wavinfo.git"
|
||||
Issues = 'https://github.com/iluvcapra/wavinfo/issues'
|
||||
|
||||
[project.entry_points.console_scripts]
|
||||
wavinfo = 'wavinfo.__main__:main'
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3.5",
|
||||
"ruff>=0.16.8",
|
||||
]
|
||||
doc = [
|
||||
"sphinx>=7.1.2",
|
||||
"sphinx-rtd-theme>=3.0.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
wavinfo = "wavinfo.__main__:main"
|
||||
|
||||
[tool.flit.external-data]
|
||||
directory = "data"
|
||||
wavinfo = "wavinfo:__main__.main"
|
||||
|
||||
[tool.pyright]
|
||||
typeCheckingMode = "basic"
|
||||
|
||||
[tool.pylint]
|
||||
max-line-length = 88
|
||||
disable = [
|
||||
"C0103", # (invalid-name)
|
||||
"C0114", # (missing-module-docstring)
|
||||
"C0115", # (missing-class-docstring)
|
||||
"C0116", # (missing-function-docstring)
|
||||
"R0903", # (too-few-public-methods)
|
||||
"R0913", # (too-many-arguments)
|
||||
"W0105", # (pointless-string-statement)
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
indent-width = 4
|
||||
|
||||
[tool.ruff.lint]
|
||||
fixable = ['ALL']
|
||||
ignore = [
|
||||
'UP031', #Use format specifiers instead of percent format
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
Probe WAVE Files for iXML, Broadcast-WAVE and other metadata.
|
||||
"""
|
||||
|
||||
from .wave_reader import WavInfoReader
|
||||
from .riff_parser import WavInfoEOFError
|
||||
__all__ = ["WavInfoEOFError", "WavInfoReader"]
|
||||
|
||||
__version__ = '2.3.0'
|
||||
__short_version__ = '2.3.0'
|
||||
from .riff_parser import WavInfoEOFError
|
||||
from .wave_reader import WavInfoReader
|
||||
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from base64 import b64encode
|
||||
from cmd import Cmd
|
||||
from enum import Enum
|
||||
from optparse import OptionParser
|
||||
from shlex import split
|
||||
|
||||
from . import WavInfoReader
|
||||
|
||||
|
||||
class MyJSONEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
if isinstance(o, Enum):
|
||||
return o._name_
|
||||
elif isinstance(o, bytes):
|
||||
return "base64:" + b64encode(o).decode("ascii")
|
||||
else:
|
||||
return super().default(o)
|
||||
|
||||
|
||||
class MissingDataError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class MetaBrowser(Cmd):
|
||||
prompt = "(wavinfo) "
|
||||
|
||||
metadata: list | dict
|
||||
path: list[str]
|
||||
|
||||
def preloop(self) -> None:
|
||||
self.path = []
|
||||
return super().preloop()
|
||||
|
||||
@property
|
||||
def cwd(self):
|
||||
root: list | dict = self.metadata
|
||||
for key in self.path:
|
||||
if isinstance(root, list):
|
||||
root = root[int(key)]
|
||||
else:
|
||||
root = root[key]
|
||||
|
||||
return root
|
||||
|
||||
@staticmethod
|
||||
def print_value(collection, key):
|
||||
val = collection[key]
|
||||
if isinstance(val, int):
|
||||
print(f" - {key}: {val}")
|
||||
elif isinstance(val, str):
|
||||
print(f' - {key}: "{val}"')
|
||||
elif isinstance(val, dict):
|
||||
print(f" - {key}: Dict ({len(val)} keys)")
|
||||
elif isinstance(val, list):
|
||||
print(f" - {key}: List ({len(val)} keys)")
|
||||
elif isinstance(val, bytes):
|
||||
print(f" - {key}: ({len(val)} bytes)")
|
||||
elif val is None:
|
||||
print(f" - {key}: (NO VALUE)")
|
||||
else:
|
||||
print(f" - {key}: Unknown")
|
||||
|
||||
def do_ls(self, _):
|
||||
"List items at the current node: LS"
|
||||
root = self.cwd
|
||||
|
||||
if isinstance(root, list):
|
||||
print("List:")
|
||||
for i in range(len(root)):
|
||||
self.print_value(root, i)
|
||||
|
||||
elif isinstance(root, dict):
|
||||
print("Dictionary:")
|
||||
for key in root:
|
||||
self.print_value(root, key)
|
||||
|
||||
else:
|
||||
print("Cannot print node, is not a list or dictionary.")
|
||||
|
||||
def do_cd(self, args):
|
||||
'Switch to a different node: CD node-name | ".."'
|
||||
argv = split(args)
|
||||
if argv[0] == "..":
|
||||
self.path = self.path[0:-1]
|
||||
else:
|
||||
if isinstance(self.cwd, list):
|
||||
if int(argv[0]) < len(self.cwd):
|
||||
self.path = self.path + [argv[0]]
|
||||
else:
|
||||
print(f"Index {argv[0]} does not exist")
|
||||
elif isinstance(self.cwd, dict):
|
||||
if argv[0] in self.cwd:
|
||||
self.path = self.path + [argv[0]]
|
||||
else:
|
||||
print(f'Key "{argv[0]}" does not exist')
|
||||
|
||||
if len(self.path) > 0:
|
||||
self.prompt = "(" + "/".join(self.path) + ") "
|
||||
else:
|
||||
self.prompt = "(wavinfo) "
|
||||
|
||||
def do_bye(self, _):
|
||||
"Exit the interactive browser: BYE"
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
version = importlib.metadata.version("wavinfo")
|
||||
manpath = os.path.dirname(__file__) + "/man"
|
||||
parser = OptionParser()
|
||||
|
||||
parser.usage = "wavinfo (--adm | --ixml) <FILE> +"
|
||||
|
||||
# parser.add_option('--install-manpages',
|
||||
# help="Install manual pages for wavinfo",
|
||||
# default=False,
|
||||
# action='store_true')
|
||||
|
||||
parser.add_option(
|
||||
"--man", help="Read the manual and exit.", default=False, action="store_true"
|
||||
)
|
||||
|
||||
parser.add_option(
|
||||
"--adm", dest="adm", help="Output ADM XML", default=False, action="store_true"
|
||||
)
|
||||
|
||||
parser.add_option(
|
||||
"--ixml", dest="ixml", help="Output iXML", default=False, action="store_true"
|
||||
)
|
||||
|
||||
parser.add_option(
|
||||
"-i",
|
||||
help="Read metadata with an interactive prompt",
|
||||
default=False,
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
(options, args) = parser.parse_args(sys.argv)
|
||||
|
||||
interactive_dict = []
|
||||
|
||||
# if options.install_manpages:
|
||||
# print("Installing manpages...")
|
||||
# print(f"Docfiles at {__file__}")
|
||||
# return
|
||||
|
||||
if options.man:
|
||||
import shlex
|
||||
|
||||
print("Which man page?")
|
||||
print("1) wavinfo usage")
|
||||
print("7) General info on Wave file metadata")
|
||||
m = input("?> ")
|
||||
|
||||
args = ["man", "-M", manpath, "1", "wavinfo"]
|
||||
if m.startswith("7"):
|
||||
args[3] = "7"
|
||||
|
||||
os.system(shlex.join(args))
|
||||
return
|
||||
|
||||
for arg in args[1:]:
|
||||
try:
|
||||
this_file = WavInfoReader(path=arg)
|
||||
if options.adm:
|
||||
if this_file.adm:
|
||||
sys.stdout.write(this_file.adm.xml_str())
|
||||
else:
|
||||
raise MissingDataError("adm")
|
||||
elif options.ixml:
|
||||
if this_file.ixml:
|
||||
sys.stdout.write(this_file.ixml.xml_str())
|
||||
else:
|
||||
raise MissingDataError("ixml")
|
||||
else:
|
||||
ret_dict = {
|
||||
"filename": arg,
|
||||
"run_date": datetime.datetime.now(
|
||||
tz=datetime.timezone.utc
|
||||
).isoformat(),
|
||||
"application": f"wavinfo {version}",
|
||||
"scopes": {},
|
||||
}
|
||||
for scope, name, value in this_file.walk():
|
||||
if scope not in ret_dict["scopes"]:
|
||||
ret_dict["scopes"][scope] = {}
|
||||
|
||||
ret_dict["scopes"][scope][name] = value
|
||||
|
||||
if options.i:
|
||||
interactive_dict.append(ret_dict)
|
||||
else:
|
||||
json.dump(ret_dict, cls=MyJSONEncoder, fp=sys.stdout, indent=2)
|
||||
|
||||
except MissingDataError as e:
|
||||
print(
|
||||
"MissingDataError: Missing metadata (%s) in file %s" % (e, arg),
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
|
||||
if len(interactive_dict) > 0:
|
||||
cli = MetaBrowser()
|
||||
cli.metadata = interactive_dict
|
||||
cli.cmdloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,6 +3,7 @@
|
||||
wavinfo \- probe wave files for metadata
|
||||
.SH SYNOPSIS
|
||||
.SY wavinfo
|
||||
.I "[\-i]"
|
||||
.I "[\-\-adm]"
|
||||
.I "[\-\-ixml]"
|
||||
.I FILE ...
|
||||
@@ -17,13 +18,17 @@ With no options,
|
||||
will emit a JSON (Javascript Object Notation) object containing all
|
||||
detected metadata.
|
||||
.IP "\-\-adm"
|
||||
Output any Audio Definition Model (ADM) metadata in
|
||||
Output Audio Definition Model (ADM) XML metadata in
|
||||
.BR FILE .
|
||||
.IP "\-\-ixml"
|
||||
Output any iXML metdata in
|
||||
.BR FILE .
|
||||
.IP "\-h, \-\-help"
|
||||
Print brief help.
|
||||
.IP "\-i"
|
||||
Enter
|
||||
.I "interactive mode"
|
||||
and browse metadata in FILE with an interactive command prompt.
|
||||
.SH DETAILED DESCRIPTION
|
||||
.B wavinfo
|
||||
collects metadata according to different
|
||||
@@ -0,0 +1,380 @@
|
||||
.TH waveinfo 7 "2024-07-10" "Jamie Hardt" "Miscellaneous Information Manuals"
|
||||
.SH NAME
|
||||
wavinfo \- WAVE file metadata
|
||||
.SH SYNOPSIS
|
||||
Everything you ever wanted to know about WAVE metadata but were afraid to ask.
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The WAVE file format is forwards-compatible. Apart from audio data, it can
|
||||
hold arbitrary blocks of bytes which clients will automatically ignore
|
||||
unless they recognize them and know how to read them.
|
||||
.PP
|
||||
Without saying too much about the structure and parsing of WAVE files
|
||||
themselves \- a subject beyond the scope of this document \- WAVE files are
|
||||
divided into segments or
|
||||
.BR chunks ,
|
||||
which a client parser can either read or skip without reading. Chunks have
|
||||
an identifier, or signature: a four-character-code that tells a client what
|
||||
kind of chunk it is, and a length. Based on this information, a client can look
|
||||
at the identifier and decide if it knows how to read a chunk and if it wants
|
||||
to. If it doesn't, it can simply read the length and skip past it.
|
||||
.PP
|
||||
Some chunks are mandated by the Microsoft standard, specifically
|
||||
.I fmt
|
||||
and
|
||||
.I data
|
||||
in the case of PCM-encoded WAVE files. Other chunks, like
|
||||
.I cue
|
||||
or
|
||||
.IR bext ,
|
||||
are optional, and optional chunks usually hold metadata.
|
||||
.PP
|
||||
Chunks can also nest inside other chunks, a special identifier
|
||||
.I LIST
|
||||
is used to indicate these. A WAVE file is a recursive list: a top level
|
||||
list of chunks, where chunks may contain a list of chunks themselves.
|
||||
.SS Order and Arrangement of Metadata Chunks in a WAVE File
|
||||
.PP
|
||||
Chunks in a WAVE file can appear in any order, and a capable parser can accept
|
||||
them appearing in any order. However, authorities give guidance on where chunks
|
||||
should be placed when creating a new WAVE file.
|
||||
.PP
|
||||
.IP 1)
|
||||
For all new WAVE files, clients should always place an empty chunk, a
|
||||
so-called
|
||||
.I JUNK
|
||||
chunk, in the first position in the top-level list of a WAVE file, and
|
||||
it should be sized large enough to hold a
|
||||
.I ds64
|
||||
chunk record. This will allow clients to upgrade the file to a RF64
|
||||
WAVE file
|
||||
.BR in-place ,
|
||||
without having to re-write the file or audio data.
|
||||
.IP 2)
|
||||
Older authorites recommend placing metadata before the audio data, so clients
|
||||
reading the file sequentially will hit it before having to seek through the
|
||||
audio. This may improve metadata read performance on certain architectures.
|
||||
.IP 3)
|
||||
Older authorities also recommend inserting
|
||||
.I JUNK
|
||||
before the
|
||||
.I data
|
||||
chunk, sized so that the first byte of the
|
||||
.I data
|
||||
payload lands immediately at 0x1000 (4096), because this was a common factor of
|
||||
the page boundaries of many operating systems and architectures. This may
|
||||
optimize the audio I/O performance in certain situations.
|
||||
.IP 4)
|
||||
Modern implementations (we're looking at
|
||||
.B Pro Tools
|
||||
here) tend to place the Broadcast-WAVE
|
||||
.I bext
|
||||
metadata before the data, followed by the data itself, and then other data
|
||||
after that.
|
||||
.\" .PP
|
||||
.\" Clients reading WAVE files should be tolerant and accept any configuration of
|
||||
.\" chunks, and should accept any file as long as the obligatory
|
||||
.\" .I fmt
|
||||
.\" and
|
||||
.\" .I data
|
||||
.\" chunks
|
||||
.\" are present.
|
||||
.PP
|
||||
It's not unheard-of to see a naive implementor expect
|
||||
.B only
|
||||
.I fmt
|
||||
and
|
||||
.I data
|
||||
chunks, in this order, and to hard-code the offsets of the short
|
||||
.I fmt
|
||||
chunk and
|
||||
.I data
|
||||
chunk into their program, and this is something that should always be checked
|
||||
when evaluating a new tool, just to make sure the developer didn't do this.
|
||||
Many coding examples and WAVE file explainers from the 90s and early aughts
|
||||
give the basic layout of a WAVE file, and naive devs go along with it.
|
||||
.SS Encoding and Decoding Text Metadata
|
||||
.\" .PP
|
||||
.\" Modern metadata systems, anything developed since the late aughts, will defer
|
||||
.\" encoding to an XML parser, so when dealing with
|
||||
.\" .I ixml
|
||||
.\" or
|
||||
.\" .I axml
|
||||
.\" so a client can mostly ignore this problem.
|
||||
.\" .PP
|
||||
.\" The most established metadata systems are older than this though, and so the
|
||||
.\" entire weight of text encoding history falls upon the client.
|
||||
.\" .PP
|
||||
.\" The original WAVE specification, a part of the Microsoft/IBM Multimedia
|
||||
.\" interface of 1991, was written at a time when Windows was an ascendant and
|
||||
.\" soon-to-be dominant desktop environment. Audio files were almost
|
||||
.\" never shared via LANs or the Internet or any other way. When audio files were
|
||||
.\" shared, among the miniscule number of people who did this, it was via BBS or
|
||||
.\" Usenet. Users at this time may have ripped them from CDs, but the cost of hard
|
||||
.\" drives and low quality of compressed formats at the time made this little more
|
||||
.\" than a curiosity. There was no CDBaby or CDDB to download and populate metadata
|
||||
.\" from at this time.
|
||||
.\" .PP
|
||||
.\" So, the
|
||||
.\" .I INFO
|
||||
.\" and
|
||||
.\" .I cue
|
||||
.\" metadata systems, which are by far the most prevalent and supported, were
|
||||
.\" published two years before the so-called "Endless September" of 1993 when the
|
||||
.\" Internet became mainstream, when Unicode was still a twinkle in the eye, and
|
||||
.\" two years before Ariana Grande was born.
|
||||
.PP
|
||||
The safest assumption, and the mandate of the Microsoft, is that all text
|
||||
metadata, by default, be encoded in Windows codepage 819, a.k.a. ISO Latin
|
||||
alphabet 1, or ISO 8859-1. This covers most Western European scripts but
|
||||
excludes all of Asia, Russia, most of the European Near East, the Middle
|
||||
East.
|
||||
.PP
|
||||
To account for this, Microsoft proposed a few conventions, none of which have
|
||||
been adopted with any consistency among clients of the WAVE file standard.
|
||||
.IP 1)
|
||||
The RIFF standard defines a
|
||||
.I cset
|
||||
chunk which declares a Windows codepage for character encoding, along with a
|
||||
native country code, language and dialect, which clients should use for
|
||||
determining text information. We have never seen a WAVE
|
||||
file with a
|
||||
.I cest
|
||||
chunk.
|
||||
.IP 2)
|
||||
Certain RIFF chunks allow the writing client to override the default encoding.
|
||||
Relevant to audio files are the
|
||||
.I ltxt
|
||||
chunk, which encodes a country, language, dialect and codepage along with a
|
||||
time range text note. We have never seen the text field on one of these
|
||||
filled-out either.
|
||||
.PP
|
||||
Some clients, in our experience, simply write UTF-8 into
|
||||
.IR cue ,
|
||||
.IR labl ,
|
||||
and
|
||||
.I note
|
||||
fields without any kind of framing.
|
||||
.PP
|
||||
A practical solution is to assume either ISO Latin 1, Windows CP 859 or Windows
|
||||
CP 1252, and allow the client or user to override this based on its own
|
||||
inferences. The
|
||||
.I chardet
|
||||
python package may provide useable guesses for text encoding, YMMV.
|
||||
.SH CHUNK MENAGERIE
|
||||
A list of chunks that you may find in a wave file from our experience.
|
||||
.SS Essential WAV Chunks
|
||||
.IP fmt
|
||||
Defines the format of the audio in the
|
||||
.I data
|
||||
chunk: the audio codec, the sample rate, bit depth, channel count, block
|
||||
alignment and other data. May take an "extended" form, with additional data
|
||||
(such as channel speaker assignments) if there are more than two channels in
|
||||
the file or if it is a compressed format.
|
||||
.IP data
|
||||
The audio data itself. PCM audio data is always stored as interleaved samples.
|
||||
.SS Optional WAVE Chunks
|
||||
.IP JUNK
|
||||
A region of the file not currently in use. Clients sometimes add these before
|
||||
the
|
||||
.I data
|
||||
chunk in order to align the beginning of the audio data with a memory page
|
||||
boundary (this can make memory-mapped reads from a wave file a little more
|
||||
efficient). A
|
||||
.I JUNK
|
||||
chunk is often placed at the beginning of a WAVE file to reserve space for
|
||||
a
|
||||
.I ds64
|
||||
chunk that will be written to the file at the end of recording, in the event
|
||||
that after the file is finalized, it exceeds the RIFF size limit. Thus a WAVE
|
||||
file can be upgraded in-place to an RF64 without re-writing the audio data.
|
||||
.IP fact
|
||||
Fact chunks record the number of samples in the decoded audio stream. It's only
|
||||
present in WAVE files that contain compressed audio.
|
||||
.IP "LIST or list"
|
||||
(Both have been seen) Not a chunk type itself but signals to a RIFF parser that
|
||||
this chunk contains chunks itself. A LIST chunk's payload will begin with a
|
||||
four-character code identifying the form of the list, and is then followed
|
||||
by chunks of the standard key-length-data form, which may themselves be
|
||||
LISTs that themselves contain child chunks. WAVE files don't tend to have a
|
||||
very deep heirarchy of chunks, compared to AVI files.
|
||||
.SS RIFF Metadata
|
||||
The RIFF container format has a metadata system common to all RIFF files, WAVE
|
||||
being the most common at present, AVI being another very common format
|
||||
historically.
|
||||
.IP "LIST form INFO"
|
||||
A flat list of chunks, each containing text metadata. The role
|
||||
of the string, like "Artist", "Composer", "Comment", "Engineer" etc. are given
|
||||
by the four-character code: "Artist" is
|
||||
.IR IART ,
|
||||
Composer is
|
||||
.IR ICMP ,
|
||||
engineer is
|
||||
.IR IENG ,
|
||||
Comment is
|
||||
.IR ICMT ,
|
||||
etc.
|
||||
.IP cue
|
||||
A binary list of cues, which are timed points within the audio data.
|
||||
.IP "LIST form adtl"
|
||||
Contains text labels
|
||||
.RI ( labl )
|
||||
for the cues in the
|
||||
.I cue
|
||||
chunk, "notes"
|
||||
.RI ( note ,
|
||||
which are structurally identical to
|
||||
.I labl
|
||||
but hosts tend to use notes for longer text), and "length text"
|
||||
.I ltxt
|
||||
metadata records, which can give a cue a length, making it a range, and a text
|
||||
field that defines its own encoding.
|
||||
.IP cset
|
||||
Defines the character set for all text fields in
|
||||
.IR INFO ,
|
||||
.I adtl
|
||||
and other RIFF-defined text fields. By default, all of the text in RIFF
|
||||
metadata fields is Windows Latin 1/ISO 8859-1, though as time passes many
|
||||
clients have simply taken to sticking UTF-8 into these fields. The
|
||||
.I cset
|
||||
cannot represent UTF-8 as a valid option for text encoding, it only speaks
|
||||
Windows codepages, and we've never seen one in a WAVE file in any event, and
|
||||
it's unlikely an audio app would recognize one if it saw it.
|
||||
.SS Broadcast-WAVE Metadata
|
||||
Broadcast-WAVE is a set of extensions to WAVE files to facilitate media
|
||||
production maintained by the EBU.
|
||||
.IP bext
|
||||
A multi-field structure containing mostly fixed-width text data capturing
|
||||
essential production information: a 256 character free description field,
|
||||
originator name and a unique reference, recording date and time, a frame-based
|
||||
timestamp for sample-accurate recording time, and a coding history record. The
|
||||
extended form of the structure can hold a SMPTE UMID (a kind of UUID, which
|
||||
may also contain timestamp and geolocation data) and pre-computed program
|
||||
loudness measurements.
|
||||
.IP peak
|
||||
A binary data structure containing the peak envelope for the audio data, for
|
||||
use by clients to generate a waveform overview.
|
||||
.SS Audio Definition Model Metadata
|
||||
Audio Definition Model (ADM) metadata is a metadata standard for audio
|
||||
broadcast and distribution maintained by the ITU.
|
||||
.IP chna
|
||||
A binary list that associates individual channels in the file to entities
|
||||
in the ADM XML document stored in the
|
||||
.I axml
|
||||
chunk. A
|
||||
.I chna
|
||||
chunk will always appear with an
|
||||
.I axml
|
||||
chunk and vice versa.
|
||||
.IP axml
|
||||
Contains an XML document with Audio Definition Model metadata. ADM metadata
|
||||
describes the program the WAVE file belongs to, role, channel assignment,
|
||||
and encoding properties of individual channels in the WAVE file, and if the
|
||||
WAVE file contains object-based audio, it will also give all of the positioning
|
||||
and panning automation envelopes.
|
||||
.IP bxml
|
||||
This is defined by the ITU as a gzip-compressed version of the
|
||||
.I axml
|
||||
chunk.
|
||||
.IP sxml
|
||||
This is a hybrid binary/gzip-compressed-XML chunk that associates ADM
|
||||
documents with timed ranges of a WAVE file.
|
||||
.SS Dolby Metadata
|
||||
Dolby metadata is present in Dolby Atmos master ADM WAVE files.
|
||||
.IP dbmd
|
||||
Records hints for Dolby playback applications for downmixing, level
|
||||
normalization and other things.
|
||||
.SS Proprietary Chunks
|
||||
.IP ovwf
|
||||
.B (Pro Tools)
|
||||
Pre-computed waveform overview data.
|
||||
.IP regn
|
||||
.B (Pro Tools)
|
||||
Region and cue point metadata.
|
||||
.SS Chunks of Unknown Purpose
|
||||
.IP elm1
|
||||
.IP minf
|
||||
.IP umid
|
||||
.SH REFERENCES
|
||||
(Note: We're not including URLs in this list, the title and standard number
|
||||
should be sufficient to find almost all of these documents. The ITU, EBU and
|
||||
IETF standards documents are freely-available.)
|
||||
.SS Essential File Format
|
||||
.TP
|
||||
.B Multimedia Programming Interface and Data Specifications 1.0. Microsoft Corporation, 1991.
|
||||
The original definition of the
|
||||
.I RIFF
|
||||
container, the
|
||||
.I WAVE
|
||||
form, the original metadata facilites (like
|
||||
.IR INFO " and " cue ),
|
||||
and things like language, country and
|
||||
dialect enumerations. This document also contains descriptions of certain
|
||||
variations on the WAVE, such as
|
||||
.I LIST wavl
|
||||
and compressed WAVE files that are so rare in practice as to be virtually
|
||||
non-existent.
|
||||
.TP
|
||||
.B ITU Recommendation BS.2088-1-2019 \- Long-form file format for the international exchange of audio programme mterials with metadata. ITU 2019.
|
||||
Formalized the RF64 file format, ADM carrier chunks like
|
||||
.IR axml
|
||||
and
|
||||
.IR chna .
|
||||
Formally supercedes the previous standard for RF64,
|
||||
.BR "EBU 3306 v1" .
|
||||
One oddity with this standard is it defines the file header for an extended
|
||||
WAVE file to be
|
||||
.IR BW64 ,
|
||||
but this is never seen in practice.
|
||||
.TP
|
||||
.B RFC 2361 \- WAVE and AVI Codec Registries. IETF Network Working Group, 1998.
|
||||
Gives an exhaustive list of all of the codecs that Microsoft had assigned to
|
||||
vendor WAVE files as of 1998. At the time, numerous hardware vendors, sound
|
||||
card and chip manufacturers, sound software developers and others all provided
|
||||
their own slightly-different adaptive PCM codecs, linear predictive compression
|
||||
codes, DCTs and other things, and Microsoft would issue these vendors WAVE
|
||||
codec magic numbers. Almost all of these are no longer in use, the only ones
|
||||
one ever encounters in the modern era are integer PCM (0x01), floating-point
|
||||
PCM (0x03) and the extended format marker (0xFFFFFFFF). There are over a
|
||||
hundred codecs assigned, however, a roll-call of failed software and hardware
|
||||
brands.
|
||||
.SS Broadcast WAVE Format
|
||||
.TP
|
||||
.B EBU Tech 3285 \- Specification of the Broadcast Wave Format (BWF). EBU, 2011.
|
||||
Defines the elements of a Broadcast WAVE file, the
|
||||
.I bext
|
||||
metadata chunk structure, allowed sample formats and other things. Over the
|
||||
years the EBU has published numerous supplements covering extensions to the
|
||||
format, such as embedding SMPTE UMIDs, pre-calculated loudness data (EBU Tech
|
||||
3285 v2),
|
||||
.I peak
|
||||
waveform overview data (Suppl. 3), ADM metadata (Suppl. 5 and 7), Dolby master
|
||||
metadata (Suppl. 6), and other things.
|
||||
.TP
|
||||
.B SMPTE 330M-2011 \- Unique Material Identifier. SMPTE, 2011.
|
||||
Describes the format of the SMPTE UMID field, a 32- or 64-byte UUID used to
|
||||
identify media files. UMIDs are usually a dumb number in their 32-byte form,
|
||||
but the extended form can encode a high-precision timestamp (with options for
|
||||
epoch and timescale) and geolocation information. Broadcast-WAVE files
|
||||
conforming to
|
||||
.B "EBU 3285 v2"
|
||||
have a SMPTE UMID embedded in the
|
||||
.I bext
|
||||
chunk.
|
||||
.SS Audio Definition Model
|
||||
.TP
|
||||
.B ITU Recommendation BS.2076-2-2019 \- Audio definition model. ITU, 2019.
|
||||
Defines the Audio Definition Model, entities, relationships and properties. If
|
||||
you ever had any questions about how ADM works, this is where you would start.
|
||||
.SS iXML Metadata
|
||||
.TP
|
||||
.B iXML Specification v3.01. Gallery Software, 2021.
|
||||
iXML is a standard for embedding mostly human-created metadata into WAVE files,
|
||||
and mostly with an emphasis on location sound recorders used on film and
|
||||
television productions. Frustratingly the developer has never published a DTD
|
||||
or schema validation or strict formal standard, and encourages vendors to just
|
||||
do whatever, but most of the heavily-traveled metadata fields are standardized,
|
||||
for recording information like a recording's scene, take, recording notes,
|
||||
circled or alt status. iXML also has a system of
|
||||
.B "families"
|
||||
for associating several WAVE files together into one recording.
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import NamedTuple
|
||||
|
||||
from . import riff_parser
|
||||
|
||||
|
||||
class RF64Context(NamedTuple):
|
||||
sample_count: int
|
||||
bigchunk_table: dict[str, int]
|
||||
|
||||
|
||||
def parse_rf64(stream, signature=b"RF64") -> RF64Context:
|
||||
start = stream.tell()
|
||||
assert stream.read(4) == b"WAVE"
|
||||
|
||||
ds64_chunk = riff_parser.parse_chunk(stream)
|
||||
assert type(ds64_chunk) is riff_parser.ChunkDescriptor, (
|
||||
f"Expected ds64 chunk here, found {type(ds64_chunk)}"
|
||||
)
|
||||
|
||||
ds64_field_spec = "<QQQI"
|
||||
ds64_fields_size = struct.calcsize(ds64_field_spec)
|
||||
assert ds64_chunk.ident == b"ds64"
|
||||
|
||||
ds64_data = ds64_chunk.read_data(stream)
|
||||
assert len(ds64_data) >= ds64_fields_size
|
||||
|
||||
riff_size, data_size, sample_count, length_lookup_table = struct.unpack(
|
||||
ds64_field_spec, ds64_data[0:ds64_fields_size]
|
||||
)
|
||||
|
||||
bigchunk_table = {}
|
||||
chunksize64format = "<4sL"
|
||||
# chunksize64size = struct.calcsize(chunksize64format)
|
||||
|
||||
for _ in range(length_lookup_table):
|
||||
bigname, bigsize = struct.unpack_from(
|
||||
chunksize64format, ds64_data, offset=ds64_fields_size
|
||||
)
|
||||
bigchunk_table[bigname] = bigsize
|
||||
|
||||
bigchunk_table[b"data"] = data_size
|
||||
bigchunk_table[signature] = riff_size
|
||||
|
||||
stream.seek(start, 0)
|
||||
return RF64Context(sample_count=sample_count, bigchunk_table=bigchunk_table)
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from collections import namedtuple
|
||||
from .rf64_parser import parse_rf64
|
||||
from typing import NamedTuple
|
||||
|
||||
from .rf64_parser import RF64Context, parse_rf64
|
||||
|
||||
|
||||
class WavInfoEOFError(EOFError):
|
||||
@@ -10,11 +12,17 @@ class WavInfoEOFError(EOFError):
|
||||
self.chunk_start = chunk_start
|
||||
|
||||
|
||||
class ListChunkDescriptor(namedtuple('ListChunkDescriptor', 'signature children')):
|
||||
pass
|
||||
class ListChunkDescriptor(NamedTuple):
|
||||
signature: bytes
|
||||
children: list[ChunkDescriptor | ListChunkDescriptor]
|
||||
|
||||
|
||||
class ChunkDescriptor(namedtuple('ChunkDescriptor', 'ident start length rf64_context')):
|
||||
class ChunkDescriptor(NamedTuple):
|
||||
ident: bytes
|
||||
start: int
|
||||
length: int
|
||||
rf64_context: RF64Context | None
|
||||
|
||||
def read_data(self, from_stream) -> bytes:
|
||||
from_stream.seek(self.start)
|
||||
return from_stream.read(self.length)
|
||||
@@ -42,27 +50,30 @@ def parse_chunk(stream, rf64_context=None):
|
||||
if len(ident) != 4 or len(size_bytes) != 4:
|
||||
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 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)
|
||||
|
||||
assert rf64_context is not None, \
|
||||
f"Sentinel data size 0xFFFFFFFF found outside of RF64 context"
|
||||
|
||||
assert rf64_context is not None, (
|
||||
"Sentinel data size 0xFFFFFFFF found outside of RF64 context"
|
||||
)
|
||||
|
||||
data_size = rf64_context.bigchunk_table[ident]
|
||||
|
||||
displacement = data_size
|
||||
if displacement % 2:
|
||||
displacement += 1
|
||||
|
||||
if ident in {b'RIFF', b'LIST', b'RF64', b'BW64', b'list'}:
|
||||
return parse_list_chunk(stream=stream, length=data_size,
|
||||
rf64_context=rf64_context)
|
||||
if ident in {b"RIFF", b"LIST", b"RF64", b"BW64", b"list"}:
|
||||
return parse_list_chunk(
|
||||
stream=stream, length=data_size, rf64_context=rf64_context
|
||||
)
|
||||
|
||||
else:
|
||||
data_start = stream.tell()
|
||||
stream.seek(displacement, 1)
|
||||
return ChunkDescriptor(ident=ident, start=data_start, length=data_size,
|
||||
rf64_context=rf64_context)
|
||||
return ChunkDescriptor(
|
||||
ident=ident, start=data_start, length=data_size, rf64_context=rf64_context
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
# from functools import reduce
|
||||
|
||||
|
||||
# def binary_to_string(binary_value):
|
||||
# return reduce(lambda val, el: val + "{:02x}".format(el),
|
||||
# binary_value, '')
|
||||
|
||||
# class UMIDParser:
|
||||
# """
|
||||
# Parse a raw binary SMPTE 330M Universal Materials Identifier
|
||||
#
|
||||
# This implementation is based on SMPTE ST 330:2011
|
||||
# """
|
||||
# def __init__(self, raw_umid: bytes):
|
||||
# self.raw_umid = raw_umid
|
||||
#
|
||||
# @property
|
||||
# def universal_label(self) -> bytearray:
|
||||
# return self.raw_umid[0:12]
|
||||
#
|
||||
# @property
|
||||
# def basic_umid(self):
|
||||
# return self.raw_umid[0:32]
|
||||
|
||||
# def basic_umid_to_str(self):
|
||||
# return binary_to_string(self.raw_umid[0:32])
|
||||
#
|
||||
# @property
|
||||
# def universal_label_is_valid(self) -> bool:
|
||||
# valid_preamble = b'\x06\x0a\x2b\x34\x01\x01\x01\x05\x01\x01'
|
||||
# return self.universal_label[0:len(valid_preamble)] == valid_preamble
|
||||
#
|
||||
# @property
|
||||
# def material_type(self) -> str:
|
||||
# material_byte = self.raw_umid[10]
|
||||
# if material_byte == 0x1:
|
||||
# return 'picture'
|
||||
# elif material_byte == 0x2:
|
||||
# return 'audio'
|
||||
# elif material_byte == 0x3:
|
||||
# return 'data'
|
||||
# elif material_byte == 0x4:
|
||||
# return 'other'
|
||||
# elif material_byte == 0x5:
|
||||
# return 'picture_single_component'
|
||||
# elif material_byte == 0x6:
|
||||
# return 'picture_multiple_component'
|
||||
# elif material_byte == 0x7:
|
||||
# return 'audio_single_component'
|
||||
# elif material_byte == 0x9:
|
||||
# return 'audio_multiple_component'
|
||||
# elif material_byte == 0xb:
|
||||
# return 'auxiliary_single_component'
|
||||
# elif material_byte == 0xc:
|
||||
# return 'auxiliary_multiple_component'
|
||||
# elif material_byte == 0xd:
|
||||
# return 'mixed_components'
|
||||
# elif material_byte == 0xf:
|
||||
# return 'not_identified'
|
||||
# else:
|
||||
# return 'not_recognized'
|
||||
#
|
||||
# @property
|
||||
# def material_number_creation_method(self) -> str:
|
||||
# method_byte = self.raw_umid[11]
|
||||
# method_byte = (method_byte << 4) & 0xf
|
||||
# if method_byte == 0x0:
|
||||
# return 'undefined'
|
||||
# elif method_byte == 0x1:
|
||||
# return 'smpte'
|
||||
# elif method_byte == 0x2:
|
||||
# return 'uuid'
|
||||
# elif method_byte == 0x3:
|
||||
# return 'masked'
|
||||
# elif method_byte == 0x4:
|
||||
# return 'ieee1394'
|
||||
# elif 0x5 <= method_byte <= 0x7:
|
||||
# return 'reserved_undefined'
|
||||
# else:
|
||||
# return 'unrecognized'
|
||||
#
|
||||
# @property
|
||||
# def instance_number_creation_method(self) -> str:
|
||||
# method_byte = self.raw_umid[11]
|
||||
# method_byte = method_byte & 0xf
|
||||
# if method_byte == 0x0:
|
||||
# return 'undefined'
|
||||
# elif method_byte == 0x01:
|
||||
# return 'local_registration'
|
||||
# elif method_byte == 0x02:
|
||||
# return '24_bit_prs'
|
||||
# elif method_byte == 0x03:
|
||||
# return 'copy_number_and_16_bit_prs'
|
||||
# elif 0x04 <= method_byte <= 0x0e:
|
||||
# return 'reserved_undefined'
|
||||
# elif method_byte == 0x0f:
|
||||
# return 'live_stream'
|
||||
# else:
|
||||
# return 'unrecognized'
|
||||
#
|
||||
# @property
|
||||
# def indicated_length(self) -> str:
|
||||
# if self.raw_umid[12] == 0x13:
|
||||
# return 'basic'
|
||||
# elif self.raw_umid[12] == 0x33:
|
||||
# return 'extended'
|
||||
#
|
||||
# @property
|
||||
# def instance_number(self) -> bytearray:
|
||||
# return self.raw_umid[13:3]
|
||||
#
|
||||
# @property
|
||||
# def material_number(self) -> bytearray:
|
||||
# return self.raw_umid[16:16]
|
||||
#
|
||||
# @property
|
||||
# def source_pack(self) -> Union[bytearray, None]:
|
||||
# if self.indicated_length == 'extended':
|
||||
# return self.raw_umid[32:32]
|
||||
# else:
|
||||
# return None
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
ADM Reader
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import namedtuple
|
||||
from io import BytesIO
|
||||
from struct import calcsize, unpack, unpack_from
|
||||
|
||||
from lxml import etree as ET
|
||||
|
||||
ChannelEntry = namedtuple("ChannelEntry", "track_index uid track_ref pack_ref")
|
||||
|
||||
|
||||
class WavADMReader:
|
||||
"""
|
||||
Reads XML data from an EBU ADM (Audio Definiton Model) WAV File.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, axml_data: bytes, chna_data: bytes):
|
||||
header_fmt = "<HH"
|
||||
uid_fmt = "<H12s14s11sx"
|
||||
|
||||
#: An :mod:`lxml.etree` of the ADM XML document
|
||||
self.axml = ET.parse(BytesIO(axml_data))
|
||||
|
||||
_, uid_count = unpack(header_fmt, chna_data[0:4])
|
||||
|
||||
self.channel_uids = []
|
||||
|
||||
offset = calcsize(header_fmt)
|
||||
for _ in range(uid_count):
|
||||
track_index, uid, track_ref, pack_ref = unpack_from(
|
||||
uid_fmt, chna_data, offset
|
||||
)
|
||||
|
||||
# these values are either ascii or all null
|
||||
|
||||
self.channel_uids.append(
|
||||
ChannelEntry(
|
||||
track_index - 1,
|
||||
uid.decode("ascii"),
|
||||
track_ref.decode("ascii"),
|
||||
pack_ref.decode("ascii"),
|
||||
)
|
||||
)
|
||||
|
||||
offset += calcsize(uid_fmt)
|
||||
|
||||
def xml_str(self) -> str:
|
||||
"""ADM XML as a string"""
|
||||
return ET.tostring(self.axml).decode("utf-8")
|
||||
|
||||
def programme(self) -> dict:
|
||||
"""
|
||||
Read the ADM `audioProgramme` data structure and some of its reference
|
||||
properties.
|
||||
"""
|
||||
ret_dict = {}
|
||||
|
||||
nsmap = self.axml.getroot().nsmap
|
||||
|
||||
afext = self.axml.find(".//audioFormatExtended", namespaces=nsmap)
|
||||
|
||||
program = afext.find("audioProgramme", namespaces=nsmap)
|
||||
ret_dict["programme_id"] = program.get("audioProgrammeID")
|
||||
ret_dict["programme_name"] = program.get("audioProgrammeName")
|
||||
ret_dict["programme_start"] = program.get("start")
|
||||
ret_dict["programme_end"] = program.get("end")
|
||||
ret_dict["contents"] = []
|
||||
|
||||
for content_ref in program.findall("audioContentIDRef", namespaces=nsmap):
|
||||
content_dict = {}
|
||||
content_dict["content_id"] = cid = content_ref.text
|
||||
content = afext.find(
|
||||
"audioContent[@audioContentID='%s']" % cid, namespaces=nsmap
|
||||
)
|
||||
content_dict["content_name"] = content.get("audioContentName")
|
||||
content_dict["objects"] = []
|
||||
|
||||
for object_ref in content.findall("audioObjectIDRef", namespaces=nsmap):
|
||||
object_dict = {}
|
||||
object_dict["object_id"] = oid = object_ref.text
|
||||
object = afext.find(
|
||||
"audioObject[@audioObjectID='%s']" % oid, namespaces=nsmap
|
||||
)
|
||||
pack = object.find("audioPackFormatIDRef", namespaces=nsmap)
|
||||
object_dict["object_name"] = object.get("audioObjectName")
|
||||
object_dict["object_start"] = object.get("start")
|
||||
object_dict["object_duration"] = object.get("duration")
|
||||
object_dict["pack_id"] = pack.text
|
||||
track_uid_list = []
|
||||
for t in object.findall("audioTrackUIDRef", namespaces=nsmap):
|
||||
track_uid_list.append(t.text)
|
||||
|
||||
object_dict["track_uids"] = track_uid_list
|
||||
content_dict["objects"].append(object_dict)
|
||||
|
||||
ret_dict["contents"].append(content_dict)
|
||||
|
||||
return ret_dict
|
||||
|
||||
def track_info(self, index) -> dict | None:
|
||||
"""
|
||||
Information about a track in the WAV file.
|
||||
|
||||
:param index: index of audio track (indexed from zero)
|
||||
:returns: a dictionary with *content_name*, *content_id*,
|
||||
*object_name*, *object_id*,
|
||||
*pack_format_name*, *pack_type*, *channel_format_name*
|
||||
"""
|
||||
channel_info = next(
|
||||
(x for x in self.channel_uids if x.track_index == index), None
|
||||
)
|
||||
|
||||
if channel_info is None:
|
||||
return None
|
||||
|
||||
ret_dict = {}
|
||||
|
||||
nsmap = self.axml.getroot().nsmap
|
||||
|
||||
afext = self.axml.find(".//audioFormatExtended", namespaces=nsmap)
|
||||
|
||||
trackformat_elem = afext.find(
|
||||
"audioTrackFormat[@audioTrackFormatID='%s']" % channel_info.track_ref,
|
||||
namespaces=nsmap,
|
||||
)
|
||||
|
||||
stream_id = trackformat_elem[0].text
|
||||
|
||||
channelformatref_elem = afext.find(
|
||||
("audioStreamFormat[@audioStreamFormatID='%s']/audioChannelFormatIDRef")
|
||||
% stream_id,
|
||||
namespaces=nsmap,
|
||||
)
|
||||
if channelformatref_elem is not None:
|
||||
channelformat_id = channelformatref_elem.text
|
||||
else:
|
||||
channelformat_id = None
|
||||
|
||||
packformatref_elem = afext.find(
|
||||
("audioStreamFormat[@audioStreamFormatID='%s']/audioPackFormatIDRef")
|
||||
% stream_id,
|
||||
namespaces=nsmap,
|
||||
)
|
||||
|
||||
if packformatref_elem is not None:
|
||||
packformat_id = packformatref_elem.text
|
||||
else:
|
||||
packformat_id = None
|
||||
|
||||
if channelformat_id:
|
||||
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(
|
||||
"audioPackFormat[@audioPackFormatID='%s']" % packformat_id, namespaces=nsmap
|
||||
)
|
||||
if packformat_elem is not None:
|
||||
ret_dict["pack_type"] = packformat_elem.get("typeDefinition")
|
||||
ret_dict["pack_format_name"] = packformat_elem.get("audioPackFormatName")
|
||||
else:
|
||||
ret_dict["pack_type"] = None
|
||||
ret_dict["pack_format_name"] = None
|
||||
|
||||
object_elem = afext.find(
|
||||
"audioObject[audioPackFormatIDRef = '%s']" % packformat_id, namespaces=nsmap
|
||||
)
|
||||
|
||||
if object_elem is not None:
|
||||
ret_dict["audio_object_name"] = object_elem.get("audioObjectName")
|
||||
object_id = object_elem.get("audioObjectID")
|
||||
ret_dict["object_id"] = object_id
|
||||
|
||||
content_elem = afext.find(
|
||||
"audioContent/[audioObjectIDRef = '%s']" % object_id, namespaces=nsmap
|
||||
)
|
||||
|
||||
ret_dict["content_name"] = content_elem.get("audioContentName")
|
||||
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
|
||||
|
||||
def to_dict(self) -> dict: # FIXME should be "asdict"
|
||||
"""
|
||||
Get ADM metadata as a dictionary.
|
||||
"""
|
||||
|
||||
def make_entry(channel_uid_rec):
|
||||
rd = channel_uid_rec._asdict()
|
||||
rd.update(self.track_info(channel_uid_rec.track_index))
|
||||
return rd
|
||||
|
||||
return {
|
||||
'channel_entries': [make_entry(z) for z in self.channel_uids],
|
||||
'programme': self.programme(),
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
# from .umid_parser import UMIDParser
|
||||
|
||||
|
||||
class WavBextReader:
|
||||
def __init__(self, bext_data, encoding):
|
||||
"""
|
||||
Read Broadcast-WAV extended metadata.
|
||||
:param bext_data: The bytes-like data.
|
||||
:param encoding: The encoding to use when decoding the text fields of
|
||||
the BEXT metadata scope. According to EBU Rec 3285 this shall be
|
||||
ASCII.
|
||||
"""
|
||||
packstring = (
|
||||
"<256s" + "32s" + "32s" + "10s" + "8s" + "QH" + "64s" + "hhhhh" + "180s"
|
||||
)
|
||||
|
||||
rest_starts = struct.calcsize(packstring)
|
||||
unpacked = struct.unpack(packstring, bext_data[:rest_starts])
|
||||
|
||||
def sanitize_bytes(b: bytes) -> str:
|
||||
# honestly can't remember why I'm stripping nulls this way
|
||||
first_null = next(
|
||||
(index for index, byte in enumerate(b) if byte == 0), None
|
||||
)
|
||||
trimmed = b if first_null is None else b[:first_null]
|
||||
decoded = trimmed.decode(encoding)
|
||||
return decoded
|
||||
|
||||
#: Description. A free-text field up to 256 characters long.
|
||||
self.description: str = sanitize_bytes(unpacked[0])
|
||||
|
||||
#: Originator. Usually the name of the encoding application, sometimes
|
||||
#: an artist name.
|
||||
self.originator: str = sanitize_bytes(unpacked[1])
|
||||
|
||||
#: A unique identifier for the file, a serial number.
|
||||
self.originator_ref: str = sanitize_bytes(unpacked[2])
|
||||
|
||||
#: Date of the recording, in the format YYYY-MM-DD.
|
||||
self.originator_date: str = sanitize_bytes(unpacked[3])
|
||||
|
||||
#: Time of the recording, in the format HH:MM:SS.
|
||||
self.originator_time: str = sanitize_bytes(unpacked[4])
|
||||
|
||||
#: The sample offset of the start, usually relative
|
||||
#: to midnight.
|
||||
self.time_reference: int = unpacked[5]
|
||||
|
||||
#: A variable-length text field containing a list of processes and
|
||||
#: and conversions performed on the file.
|
||||
self.coding_history: str = sanitize_bytes(bext_data[rest_starts:])
|
||||
|
||||
#: BEXT version.
|
||||
self.version: int = unpacked[6]
|
||||
|
||||
#: SMPTE 330M UMID of this audio file, 64 bytes are allocated though
|
||||
#: the UMID may only be 32 bytes long.
|
||||
self.umid: bytes | None = None
|
||||
|
||||
#: EBU R128 Integrated loudness, in LUFS.
|
||||
self.loudness_value: float | None = None
|
||||
|
||||
#: EBU R128 Loudness range, in LUFS.
|
||||
self.loudness_range: float | None = None
|
||||
|
||||
#: True peak level, in dBFS TP
|
||||
self.max_true_peak: float | None = None
|
||||
|
||||
#: EBU R128 Maximum momentary loudness, in LUFS
|
||||
self.max_momentary_loudness: float | None = None
|
||||
|
||||
#: EBU R128 Maximum short-term loudness, in LUFS.
|
||||
self.max_shortterm_loudness: float | None = None
|
||||
|
||||
if self.version > 0:
|
||||
self.umid = unpacked[7]
|
||||
|
||||
if self.version > 1:
|
||||
self.loudness_value = unpacked[8] / 100.0
|
||||
self.loudness_range = unpacked[9] / 100.0
|
||||
self.max_true_peak = unpacked[10] / 100.0
|
||||
self.max_momentary_loudness = unpacked[11] / 100.0
|
||||
self.max_shortterm_loudness = unpacked[12] / 100.0
|
||||
|
||||
def to_dict(self):
|
||||
# if self.umid is not None:
|
||||
# umid_parsed = UMIDParser(self.umid)
|
||||
# umid_str = umid_parsed.basic_umid_to_str()
|
||||
# else:
|
||||
|
||||
# umid_str = None
|
||||
|
||||
return {
|
||||
"description": self.description,
|
||||
"originator": self.originator,
|
||||
"originator_ref": self.originator_ref,
|
||||
"originator_date": self.originator_date,
|
||||
"originator_time": self.originator_time,
|
||||
"time_reference": self.time_reference,
|
||||
"version": self.version,
|
||||
"umid": self.umid,
|
||||
"coding_history": self.coding_history,
|
||||
"loudness_value": self.loudness_value,
|
||||
"loudness_range": self.loudness_range,
|
||||
"max_true_peak": self.max_true_peak,
|
||||
"max_momentary_loudness": self.max_momentary_loudness,
|
||||
"max_shortterm_loudness": self.max_shortterm_loudness,
|
||||
}
|
||||
@@ -1,26 +1,28 @@
|
||||
"""
|
||||
Cues metadata
|
||||
|
||||
For reference on implementation of cues and related metadata see:
|
||||
For reference on implementation of cues and related metadata see:
|
||||
August 1991, "Multimedia Programming Interface and Data Specifications 1.0",
|
||||
IBM Corporation and Microsoft Corporation
|
||||
|
||||
https://www.aelius.com/njh/wavemetatools/doc/riffmci.pdf
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import encodings
|
||||
from struct import calcsize, unpack
|
||||
from typing import Any, Generator, NamedTuple
|
||||
|
||||
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.
|
||||
CountryCodes = """000 None Indicated
|
||||
001,USA
|
||||
002,Canada
|
||||
003,Latin America
|
||||
030,Greece
|
||||
030,Greece
|
||||
031,Netherlands
|
||||
032,Belgium
|
||||
033,France
|
||||
@@ -44,50 +46,50 @@ CountryCodes = """000 None Indicated
|
||||
090,Turkey
|
||||
351,Portugal
|
||||
352,Luxembourg
|
||||
354,Iceland
|
||||
354,Iceland
|
||||
358,Finland"""
|
||||
|
||||
#: Language and Dialect codes used in the RIFF standard to resolve native
|
||||
#: Language and Dialect codes used in the RIFF standard to resolve native
|
||||
#: language of text fields. These codes appear in CSET and LTXT metadata.
|
||||
LanguageDialectCodes = """0 0 None Indicated
|
||||
1,1,Arabic
|
||||
2,1,Bulgarian
|
||||
3,1,Catalan
|
||||
4,1,Traditional Chinese
|
||||
4,2,Simplified Chinese
|
||||
4,1,Traditional Chinese
|
||||
4,2,Simplified Chinese
|
||||
5,1,Czech
|
||||
6,1,Danish
|
||||
7,1,German
|
||||
7,2,Swiss German
|
||||
7,2,Swiss German
|
||||
8,1,Greek
|
||||
9,1,US English
|
||||
9,2,UK English
|
||||
9,2,UK English
|
||||
10,1,Spanish
|
||||
10,2,Spanish Mexican
|
||||
10,2,Spanish Mexican
|
||||
11,1,Finnish
|
||||
12,1,French
|
||||
12,2,Belgian French
|
||||
12,3,Canadian French
|
||||
12,4,Swiss French
|
||||
13,1,Hebrew
|
||||
12,2,Belgian French
|
||||
12,3,Canadian French
|
||||
12,4,Swiss French
|
||||
13,1,Hebrew
|
||||
14,1,Hungarian
|
||||
15,1,Icelandic
|
||||
16,1,Italian
|
||||
16,2,Swiss Italian
|
||||
16,2,Swiss Italian
|
||||
17,1,Japanese
|
||||
18,1,Korean
|
||||
19,1,Dutch
|
||||
19,2,Belgian Dutch
|
||||
20,1,Norwegian - Bokmal
|
||||
20,2,Norwegian - Nynorsk
|
||||
19,2,Belgian Dutch
|
||||
20,1,Norwegian - Bokmal
|
||||
20,2,Norwegian - Nynorsk
|
||||
21,1,Polish
|
||||
22,1,Brazilian Portuguese
|
||||
22,2,Portuguese
|
||||
23,1,Rhaeto-Romanic
|
||||
22,1,Brazilian Portuguese
|
||||
22,2,Portuguese
|
||||
23,1,Rhaeto-Romanic
|
||||
24,1,Romanian
|
||||
25,1,Russian
|
||||
26,1,Serbo-Croatian (Latin)
|
||||
26,2,Serbo-Croatian (Cyrillic)
|
||||
26,1,Serbo-Croatian (Latin)
|
||||
26,2,Serbo-Croatian (Cyrillic)
|
||||
27,1,Slovak
|
||||
28,1,Albanian
|
||||
29,1,Swedish
|
||||
@@ -98,7 +100,15 @@ LanguageDialectCodes = """0 0 None Indicated
|
||||
|
||||
|
||||
class CueEntry(NamedTuple):
|
||||
"""
|
||||
A ``cue`` element structure.
|
||||
"""
|
||||
|
||||
#: Cue "name" or id number
|
||||
name: int
|
||||
#: Cue position, as a frame count in the play order of the WAVE file. In
|
||||
#: principle this can be affected by playlists and ``wavl`` chunk
|
||||
#: placement.
|
||||
position: int
|
||||
chunk_id: bytes
|
||||
chunk_start: int
|
||||
@@ -106,37 +116,53 @@ class CueEntry(NamedTuple):
|
||||
sample_offset: int
|
||||
|
||||
Format = "<II4sIII"
|
||||
|
||||
|
||||
@classmethod
|
||||
def format_size(cls) -> int:
|
||||
return calcsize(cls.Format)
|
||||
|
||||
@classmethod
|
||||
def read(cls, data: bytes) -> 'CueEntry':
|
||||
assert len(data) == cls.format_size(), \
|
||||
f"cue data size incorrect, expected {calcsize(cls.Format)} found {len(data)}"
|
||||
def read(cls, data: bytes) -> CueEntry:
|
||||
assert len(data) == cls.format_size(), (
|
||||
f"cue data size incorrect, expected {calcsize(cls.Format)} "
|
||||
"found {len(data)}"
|
||||
)
|
||||
|
||||
parsed = unpack(cls.Format, data)
|
||||
|
||||
return cls(name=parsed[0], position=parsed[1], chunk_id=parsed[2],
|
||||
chunk_start=parsed[3], block_start=parsed[4],
|
||||
sample_offset=parsed[5])
|
||||
return cls(
|
||||
name=parsed[0],
|
||||
position=parsed[1],
|
||||
chunk_id=parsed[2],
|
||||
chunk_start=parsed[3],
|
||||
block_start=parsed[4],
|
||||
sample_offset=parsed[5],
|
||||
)
|
||||
|
||||
|
||||
class LabelEntry(NamedTuple):
|
||||
"""
|
||||
A ``labl`` structure.
|
||||
"""
|
||||
|
||||
name: int
|
||||
text: str
|
||||
|
||||
@classmethod
|
||||
def read(cls, data: bytes, encoding: str):
|
||||
return cls(name=unpack("<I", data[0:4])[0],
|
||||
text=data[4:].decode(encoding).rstrip("\0"))
|
||||
return cls(
|
||||
name=unpack("<I", data[0:4])[0], text=data[4:].decode(encoding).rstrip("\0")
|
||||
)
|
||||
|
||||
|
||||
NoteEntry = LabelEntry
|
||||
|
||||
|
||||
class RangeLabel(NamedTuple):
|
||||
"""
|
||||
A ``ltxt`` structure.
|
||||
"""
|
||||
|
||||
name: int
|
||||
length: int
|
||||
purpose: str
|
||||
@@ -149,124 +175,130 @@ class RangeLabel(NamedTuple):
|
||||
@classmethod
|
||||
def read(cls, data: bytes, fallback_encoding: str):
|
||||
leader_struct_fmt = "<II4sHHHH"
|
||||
parsed = unpack(leader_struct_fmt, data[0:calcsize(leader_struct_fmt)])
|
||||
text_data = data[calcsize(leader_struct_fmt):]
|
||||
parsed = unpack(leader_struct_fmt, data[0 : calcsize(leader_struct_fmt)])
|
||||
text_data = data[calcsize(leader_struct_fmt) :]
|
||||
|
||||
purpose_str = parsed[2].decode('ascii')
|
||||
|
||||
if data[6] != 0:
|
||||
fallback_encoding = f"cp{data[6]}"
|
||||
|
||||
return cls(name=parsed[0], length=parsed[1], purpose=parsed[2],
|
||||
country=parsed[3], language=parsed[4],
|
||||
dialect=parsed[5], codepage=parsed[6],
|
||||
text=text_data.decode(fallback_encoding))
|
||||
return cls(
|
||||
name=parsed[0],
|
||||
length=parsed[1],
|
||||
purpose=purpose_str,
|
||||
country=parsed[3],
|
||||
language=parsed[4],
|
||||
dialect=parsed[5],
|
||||
codepage=parsed[6],
|
||||
text=text_data.decode(fallback_encoding),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WavCuesReader:
|
||||
cues: List[CueEntry]
|
||||
labels: List[LabelEntry]
|
||||
ranges: List[RangeLabel]
|
||||
notes: List[NoteEntry]
|
||||
#: Every ``cue`` entry in the file
|
||||
cues: list[CueEntry]
|
||||
|
||||
#: Every ``labl`` in the file
|
||||
labels: list[LabelEntry]
|
||||
|
||||
#: Every ``ltxt`` in the file
|
||||
ranges: list[RangeLabel]
|
||||
|
||||
#: Every ``note`` in the file
|
||||
notes: list[NoteEntry]
|
||||
|
||||
@classmethod
|
||||
def read_all(cls, f,
|
||||
cues: Optional[ChunkDescriptor],
|
||||
labls: List[ChunkDescriptor],
|
||||
ltxts: List[ChunkDescriptor],
|
||||
notes: List[ChunkDescriptor],
|
||||
fallback_encoding: str) -> 'WavCuesReader':
|
||||
|
||||
def read_all(
|
||||
cls,
|
||||
f,
|
||||
cues: ChunkDescriptor | None,
|
||||
labls: list[ChunkDescriptor],
|
||||
ltxts: list[ChunkDescriptor],
|
||||
notes: list[ChunkDescriptor],
|
||||
fallback_encoding: str,
|
||||
) -> WavCuesReader:
|
||||
cue_list = []
|
||||
if cues is not None:
|
||||
cues_data = cues.read_data(f)
|
||||
assert len(cues_data) >= 4, "cue metadata too short"
|
||||
offset = calcsize("<I")
|
||||
cues_count = unpack("<I", cues_data[0:offset])
|
||||
|
||||
|
||||
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))
|
||||
offset += CueEntry.format_size()
|
||||
|
||||
label_list = []
|
||||
for labl in labls:
|
||||
label_list.append(
|
||||
LabelEntry.read(labl.read_data(f),
|
||||
encoding=fallback_encoding)
|
||||
LabelEntry.read(labl.read_data(f), encoding=fallback_encoding)
|
||||
)
|
||||
|
||||
range_list = []
|
||||
for r in ltxts:
|
||||
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 = []
|
||||
for note in notes:
|
||||
note_list.append(
|
||||
NoteEntry.read(note.read_data(f),
|
||||
encoding=fallback_encoding)
|
||||
NoteEntry.read(note.read_data(f), encoding=fallback_encoding)
|
||||
)
|
||||
|
||||
return WavCuesReader(cues=cue_list, labels=label_list,
|
||||
ranges=range_list, notes=note_list)
|
||||
return WavCuesReader(
|
||||
cues=cue_list, labels=label_list, ranges=range_list, notes=note_list
|
||||
)
|
||||
|
||||
def each_cue(self) -> Generator[Tuple[int, int], None, None]:
|
||||
def each_cue(self) -> Generator[tuple[int, int], None, None]:
|
||||
"""
|
||||
Iterate through each cue.
|
||||
Iterate through each cue.
|
||||
|
||||
:yields: the cue's ``name`` and ``sample_offset``
|
||||
"""
|
||||
for cue in self.cues:
|
||||
yield (cue.name, cue.sample_offset)
|
||||
|
||||
def label_and_note(self, cue_ident: int) -> Tuple[Optional[str],
|
||||
Optional[str]]:
|
||||
def label_and_note(self, cue_ident: int) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
Get the label and note (extended comment) for a cue.
|
||||
|
||||
:param cue_ident: the cue's name, its unique identifying number
|
||||
:returns: a tuple of the the cue's label (if present) and note (if
|
||||
present)
|
||||
present)
|
||||
"""
|
||||
label = next((l.text for l in self.labels
|
||||
if l.name == cue_ident), None)
|
||||
note = next((n.text for n in self.notes
|
||||
if n.name == cue_ident), None)
|
||||
label = next(
|
||||
(label.text for label in self.labels if label.name == cue_ident), None
|
||||
)
|
||||
note = next((n.text for n in self.notes if n.name == cue_ident), None)
|
||||
return (label, note)
|
||||
|
||||
def range(self, cue_ident: int) -> Optional[int]:
|
||||
def range(self, cue_ident: int) -> int | None:
|
||||
"""
|
||||
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
|
||||
: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]:
|
||||
retval = dict()
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
retval = {}
|
||||
|
||||
for n, t in self.each_cue():
|
||||
retval[n] = dict()
|
||||
retval[n]['frame'] = t
|
||||
retval[n] = {}
|
||||
retval[n]["frame"] = t
|
||||
label, note = self.label_and_note(n)
|
||||
r = self.range(n)
|
||||
|
||||
if label is not None:
|
||||
retval[n]['label'] = label
|
||||
retval[n]["label"] = label
|
||||
if note is not None:
|
||||
retval[n]['note'] = note
|
||||
retval[n]["note"] = note
|
||||
if r is not None:
|
||||
retval[n]['length'] = r
|
||||
|
||||
return retval
|
||||
# return dict(cues=[c._asdict() for c in self.cues],
|
||||
# labels=[l._asdict() for l in self.labels],
|
||||
# ranges=[r._asdict() for r in self.ranges],
|
||||
# notes=[n._asdict() for n in self.notes])
|
||||
|
||||
|
||||
retval[n]["length"] = r
|
||||
|
||||
return retval
|
||||
@@ -1,23 +1,26 @@
|
||||
"""
|
||||
Reading Dolby Bitstream Metadata
|
||||
|
||||
Unless otherwise stated, all § references here are to
|
||||
Unless otherwise stated, all § references here are to
|
||||
`EBU Tech 3285 Supplement 6`_.
|
||||
|
||||
.. _EBU Tech 3285 Supplement 6: https://tech.ebu.ch/docs/tech/tech3285s6.pdf
|
||||
"""
|
||||
|
||||
from enum import IntEnum, Enum
|
||||
from struct import unpack
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import List, Optional, Tuple, Any, Union
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum, IntEnum
|
||||
from io import BytesIO
|
||||
from struct import unpack
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SegmentType(IntEnum):
|
||||
"""
|
||||
Metadata segment type.
|
||||
"""
|
||||
|
||||
EndMarker = 0x0
|
||||
DolbyE = 0x1
|
||||
# Reserved2 = 0x2
|
||||
@@ -28,10 +31,10 @@ class SegmentType(IntEnum):
|
||||
DolbyDigitalPlus = 0x7
|
||||
AudioInfo = 0x8
|
||||
DolbyAtmos = 0x9
|
||||
DolbyAtmosSupplemental = 0xa
|
||||
DolbyAtmosSupplemental = 0xA
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls,val):
|
||||
def _missing_(cls, val):
|
||||
return val
|
||||
|
||||
|
||||
@@ -39,11 +42,11 @@ class SegmentType(IntEnum):
|
||||
class DolbyDigitalPlusMetadata:
|
||||
"""
|
||||
*Dolby Digital Plus* is Dolby's brand for multichannel surround
|
||||
on discrete formats that aren't AC-3 (Dolby Digital) or Dolby E. This
|
||||
metadata segment is present in ADM wave files created with a Dolby Atmos
|
||||
on discrete formats that aren't AC-3 (Dolby Digital) or Dolby E. This
|
||||
metadata segment is present in ADM wave files created with a Dolby Atmos
|
||||
Production Suite.
|
||||
|
||||
Where an AC-3 bitstream can contain multiple programs, a Dolby Digital
|
||||
Where an AC-3 bitstream can contain multiple programs, a Dolby Digital
|
||||
Plus bitstream will only contain one program.
|
||||
"""
|
||||
|
||||
@@ -77,17 +80,16 @@ class DolbyDigitalPlusMetadata:
|
||||
MUTE = 0b111
|
||||
"-∞ dB"
|
||||
|
||||
|
||||
class DolbySurroundEncodingMode(Enum):
|
||||
"""
|
||||
Dolby surround endcoding mode.
|
||||
"""
|
||||
|
||||
RESERVED = 0b11
|
||||
IN_USE = 0b10
|
||||
NOT_IN_USE = 0b01
|
||||
NOT_INDICATED = 0b00
|
||||
|
||||
|
||||
class BitStreamMode(Enum):
|
||||
"""
|
||||
Dolby Digital Plus `bsmod` field
|
||||
@@ -122,12 +124,12 @@ class DolbyDigitalPlusMetadata:
|
||||
should be interpreted as karaoke.
|
||||
"""
|
||||
|
||||
|
||||
class AudioCodingMode(Enum):
|
||||
"""
|
||||
Dolby Digital Plus `acmod` field
|
||||
§ 4.3.2.3
|
||||
"""
|
||||
|
||||
RESERVED = 0b000
|
||||
CH_ORD_1_0 = 0b001
|
||||
"Mono"
|
||||
@@ -144,7 +146,6 @@ class DolbyDigitalPlusMetadata:
|
||||
CH_ORD_3_2 = 0b111
|
||||
"LCR + LR surround"
|
||||
|
||||
|
||||
class CenterDownMixLevel(Enum):
|
||||
"""
|
||||
§ 4.3.3.1
|
||||
@@ -152,98 +153,90 @@ class DolbyDigitalPlusMetadata:
|
||||
|
||||
DOWN_3DB = 0b00
|
||||
"Attenuate 3 dB"
|
||||
|
||||
|
||||
DOWN_45DB = 0b01
|
||||
"Attenuate 4.5 dB"
|
||||
|
||||
|
||||
DOWN_6DB = 0b10
|
||||
"Attenuate 6 dB"
|
||||
|
||||
RESERVED = 0b11
|
||||
|
||||
|
||||
class SurroundDownMixLevel(Enum):
|
||||
"""
|
||||
Dolby Digital Plus `surmixlev` field
|
||||
§ 4.3.3.2
|
||||
"""
|
||||
|
||||
DOWN_3DB = 0b00
|
||||
DOWN_6DB = 0b01
|
||||
MUTE = 0b10
|
||||
RESERVED = 0b11
|
||||
|
||||
|
||||
class LanguageCode(int):
|
||||
"""
|
||||
§ 4.3.4.1
|
||||
|
||||
Per ATSC/A52 § 5.4.2.12, this is not in use and always 0xFF.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class MixLevel(int):
|
||||
"""
|
||||
§ 4.3.6.2
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class DialnormLevel(int):
|
||||
"""
|
||||
§ 4.3.4.4
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class RoomType(Enum):
|
||||
"""
|
||||
`roomtyp` 4.3.6.3
|
||||
"""
|
||||
|
||||
NOT_INDICATED = 0b00
|
||||
LARGE_ROOM_X_CURVE = 0b01
|
||||
SMALL_ROOM_FLAT_CURVE = 0b10
|
||||
RESERVED = 0b11
|
||||
|
||||
|
||||
class PreferredDownMixMode(Enum):
|
||||
"""
|
||||
Indicates the creating engineer's preference of what the receiver should
|
||||
downmix.
|
||||
Indicates the creating engineer's preference of what the receiver
|
||||
should downmix.
|
||||
§ 4.3.8.1
|
||||
"""
|
||||
|
||||
NOT_INDICATED = 0b00
|
||||
PRO_LOGIC = 0b01
|
||||
STEREO = 0b10
|
||||
PRO_LOGIC_2 = 0b11
|
||||
|
||||
|
||||
class SurroundEXMode(IntEnum):
|
||||
"""
|
||||
Dolby Surround-EX mode.
|
||||
`dsurexmod` § 4.3.9.1
|
||||
"""
|
||||
|
||||
NOT_INDICATED = 0b00
|
||||
NOT_SEX = 0b01
|
||||
SEX = 0b10
|
||||
PRO_LOGIC_2 = 0b11
|
||||
|
||||
|
||||
class HeadphoneMode(IntEnum):
|
||||
"""
|
||||
`dheadphonmod` § 4.3.9.2
|
||||
"""
|
||||
|
||||
NOT_INDICATED = 0b00
|
||||
NOT_DOLBY_HEADPHONE = 0b01
|
||||
DOLBY_HEADPHONE = 0b10
|
||||
RESERVED = 0b11
|
||||
|
||||
|
||||
class ADConverterType(Enum):
|
||||
STANDARD = 0
|
||||
HDCD = 1
|
||||
|
||||
|
||||
class StreamDependency(Enum):
|
||||
"""
|
||||
Encodes `ddplus_info1.stream_type` field § 4.3.12.1
|
||||
@@ -254,12 +247,12 @@ class DolbyDigitalPlusMetadata:
|
||||
INDEPENDENT_FROM_DOLBY_DIGITAL = 2
|
||||
RESERVED = 3
|
||||
|
||||
|
||||
class RFCompressionProfile(Enum):
|
||||
"""
|
||||
`compr1` RF compression profile
|
||||
§ 4.3.10 (fig 42)
|
||||
"""
|
||||
|
||||
NONE = 0
|
||||
FILM_STANDARD = 1
|
||||
FILM_LIGHT = 2
|
||||
@@ -267,7 +260,7 @@ class DolbyDigitalPlusMetadata:
|
||||
MUSIC_LIGHT = 4
|
||||
SPEECH = 5
|
||||
|
||||
#: Program ID number, this identifies the program in a multi-program
|
||||
#: Program ID number, this identifies the program in a multi-program
|
||||
#: element. § 4.3.1
|
||||
program_id: int
|
||||
|
||||
@@ -317,13 +310,13 @@ class DolbyDigitalPlusMetadata:
|
||||
|
||||
#: LoRo preferred center downmix level
|
||||
loro_center_downmix_level: DownMixLevelToken
|
||||
|
||||
|
||||
#: LoRo preferred surround downmix level
|
||||
loro_surround_downmix_level: DownMixLevelToken
|
||||
|
||||
#: Preferred downmix mode
|
||||
downmix_mode: PreferredDownMixMode
|
||||
|
||||
|
||||
#: LtRt preferred center downmix level
|
||||
ltrt_center_downmix_level: DownMixLevelToken
|
||||
|
||||
@@ -332,69 +325,86 @@ class DolbyDigitalPlusMetadata:
|
||||
|
||||
#: Surround-EX mode
|
||||
surround_ex_mode: SurroundEXMode
|
||||
|
||||
|
||||
#: Dolby Headphone mode
|
||||
dolby_headphone_encoded: HeadphoneMode
|
||||
|
||||
ad_converter_type: ADConverterType
|
||||
compression_profile: RFCompressionProfile
|
||||
dynamic_range: RFCompressionProfile
|
||||
|
||||
|
||||
#: Indicates if this stream can be decoded independently or not
|
||||
stream_dependency: StreamDependency
|
||||
|
||||
#: Data rate of this bitstream in kilobits per second
|
||||
datarate_kbps: int
|
||||
|
||||
|
||||
@staticmethod
|
||||
def load(buffer: bytes):
|
||||
assert len(buffer) == 96, "Dolby Digital Plus segment incorrect size, "
|
||||
"expected 96 got %i" % len(buffer)
|
||||
assert len(buffer) == 96, (
|
||||
"Dolby Digital Plus segment incorrect size, "
|
||||
"expected 96 got %i" % len(buffer)
|
||||
)
|
||||
|
||||
def program_id(b) -> int:
|
||||
return b
|
||||
|
||||
def program_info(b):
|
||||
return (b & 0x40) > 0, \
|
||||
DolbyDigitalPlusMetadata.BitStreamMode(b & 0x38 >> 3), \
|
||||
DolbyDigitalPlusMetadata.AudioCodingMode(b & 0x7)
|
||||
return (
|
||||
(b & 0x40) > 0,
|
||||
DolbyDigitalPlusMetadata.BitStreamMode(b & 0x38 >> 3),
|
||||
DolbyDigitalPlusMetadata.AudioCodingMode(b & 0x7),
|
||||
)
|
||||
|
||||
def ddplus_reserved1(_):
|
||||
pass
|
||||
|
||||
def surround_config(b):
|
||||
return DolbyDigitalPlusMetadata.CenterDownMixLevel(b & 0x30 >> 4), \
|
||||
DolbyDigitalPlusMetadata.SurroundDownMixLevel(b & 0xc >> 2), \
|
||||
DolbyDigitalPlusMetadata.DolbySurroundEncodingMode(b & 0x3)
|
||||
return (
|
||||
DolbyDigitalPlusMetadata.CenterDownMixLevel(b & 0x30 >> 4),
|
||||
DolbyDigitalPlusMetadata.SurroundDownMixLevel(b & 0xC >> 2),
|
||||
DolbyDigitalPlusMetadata.DolbySurroundEncodingMode(b & 0x3),
|
||||
)
|
||||
|
||||
def dialnorm_info(b):
|
||||
return (b & 0x80) > 0 , b & 0x40 > 0, b & 0x20 > 0, \
|
||||
DolbyDigitalPlusMetadata.DialnormLevel(b & 0x1f)
|
||||
return (
|
||||
(b & 0x80) > 0,
|
||||
b & 0x40 > 0,
|
||||
b & 0x20 > 0,
|
||||
DolbyDigitalPlusMetadata.DialnormLevel(b & 0x1F),
|
||||
)
|
||||
|
||||
def langcod(b) -> int:
|
||||
return b
|
||||
|
||||
def audio_prod_info(b):
|
||||
return (b & 0x80) > 0, \
|
||||
DolbyDigitalPlusMetadata.MixLevel(b & 0x7c >> 2), \
|
||||
DolbyDigitalPlusMetadata.RoomType(b & 0x3)
|
||||
return (
|
||||
(b & 0x80) > 0,
|
||||
DolbyDigitalPlusMetadata.MixLevel(b & 0x7C >> 2),
|
||||
DolbyDigitalPlusMetadata.RoomType(b & 0x3),
|
||||
)
|
||||
|
||||
# loro_center_downmix_level, loro_surround_downmix_level
|
||||
# loro_center_downmix_level, loro_surround_downmix_level
|
||||
def ext_bsi1_word1(b):
|
||||
return DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x38 >> 3), \
|
||||
DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x7)
|
||||
return DolbyDigitalPlusMetadata.DownMixLevelToken(
|
||||
b & 0x38 >> 3
|
||||
), DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x7)
|
||||
|
||||
# downmix_mode, ltrt_center_downmix_level, ltrt_surround_downmix_level
|
||||
def ext_bsi1_word2(b):
|
||||
return DolbyDigitalPlusMetadata.PreferredDownMixMode(b & 0xC0 >> 6), \
|
||||
DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x38 >> 3), \
|
||||
DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x7)
|
||||
return (
|
||||
DolbyDigitalPlusMetadata.PreferredDownMixMode(b & 0xC0 >> 6),
|
||||
DolbyDigitalPlusMetadata.DownMixLevelToken(b & 0x38 >> 3),
|
||||
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):
|
||||
return DolbyDigitalPlusMetadata.SurroundEXMode(b & 0x60 >> 5), \
|
||||
DolbyDigitalPlusMetadata.HeadphoneMode(b & 0x18 >> 3), \
|
||||
DolbyDigitalPlusMetadata.ADConverterType( b & 0x4 >> 2)
|
||||
return (
|
||||
DolbyDigitalPlusMetadata.SurroundEXMode(b & 0x60 >> 5),
|
||||
DolbyDigitalPlusMetadata.HeadphoneMode(b & 0x18 >> 3),
|
||||
DolbyDigitalPlusMetadata.ADConverterType(b & 0x4 >> 2),
|
||||
)
|
||||
|
||||
def ddplus_reserved2(_):
|
||||
pass
|
||||
@@ -403,13 +413,13 @@ class DolbyDigitalPlusMetadata:
|
||||
return DolbyDigitalPlusMetadata.RFCompressionProfile(b)
|
||||
|
||||
def dynrng1(b):
|
||||
DolbyDigitalPlusMetadata.RFCompressionProfile(b)
|
||||
return DolbyDigitalPlusMetadata.RFCompressionProfile(b)
|
||||
|
||||
def ddplus_reserved3(_):
|
||||
pass
|
||||
|
||||
def ddplus_info1(b):
|
||||
return DolbyDigitalPlusMetadata.StreamDependency(b & 0xc >> 2)
|
||||
return DolbyDigitalPlusMetadata.StreamDependency(b & 0xC >> 2)
|
||||
|
||||
def ddplus_reserved4(_):
|
||||
pass
|
||||
@@ -423,14 +433,24 @@ class DolbyDigitalPlusMetadata:
|
||||
pid = program_id(buffer[0])
|
||||
lfe_on, bitstream_mode, audio_coding_mode = program_info(buffer[1])
|
||||
ddplus_reserved1(buffer[2:2])
|
||||
center_downmix_level, surround_downmix_level, dolby_surround_encoded = surround_config(buffer[4])
|
||||
langcode_present, copyright_bitstream, original_bitstream, dialnorm = dialnorm_info(buffer[5])
|
||||
center_downmix_level, surround_downmix_level, dolby_surround_encoded = (
|
||||
surround_config(buffer[4])
|
||||
)
|
||||
langcode_present, copyright_bitstream, original_bitstream, dialnorm = (
|
||||
dialnorm_info(buffer[5])
|
||||
)
|
||||
langcode = langcod(buffer[6])
|
||||
prod_info_exists, mixlevel, roomtype = audio_prod_info(buffer[7])
|
||||
|
||||
loro_center_downmix_level, loro_surround_downmix_level = ext_bsi1_word1(buffer[8])
|
||||
downmix_mode, ltrt_center_downmix_level, ltrt_surround_downmix_level = ext_bsi1_word2(buffer[9])
|
||||
surround_ex_mode, dolby_headphone_encoded, ad_converter_type = ext_bsi2_word1(buffer[10])
|
||||
loro_center_downmix_level, loro_surround_downmix_level = ext_bsi1_word1(
|
||||
buffer[8]
|
||||
)
|
||||
downmix_mode, ltrt_center_downmix_level, ltrt_surround_downmix_level = (
|
||||
ext_bsi1_word2(buffer[9])
|
||||
)
|
||||
surround_ex_mode, dolby_headphone_encoded, ad_converter_type = ext_bsi2_word1(
|
||||
buffer[10]
|
||||
)
|
||||
|
||||
ddplus_reserved2(buffer[11:14])
|
||||
compression = compr1(buffer[14])
|
||||
@@ -441,17 +461,18 @@ class DolbyDigitalPlusMetadata:
|
||||
data_rate = datarate(buffer[25:27])
|
||||
reserved(buffer[27:69])
|
||||
|
||||
return DolbyDigitalPlusMetadata(program_id=pid,
|
||||
lfe_on=lfe_on,
|
||||
return DolbyDigitalPlusMetadata(
|
||||
program_id=pid,
|
||||
lfe_on=lfe_on,
|
||||
bitstream_mode=bitstream_mode,
|
||||
audio_coding_mode=audio_coding_mode,
|
||||
center_downmix_level=center_downmix_level,
|
||||
surround_downmix_level=surround_downmix_level,
|
||||
dolby_surround_encoded=dolby_surround_encoded,
|
||||
langcode_present=langcode_present,
|
||||
copyright_bitstream=copyright_bitstream,
|
||||
dolby_surround_encoded=dolby_surround_encoded,
|
||||
langcode_present=langcode_present,
|
||||
copyright_bitstream=copyright_bitstream,
|
||||
original_bitstream=original_bitstream,
|
||||
dialnorm=dialnorm,
|
||||
dialnorm=dialnorm,
|
||||
langcode=langcode,
|
||||
prod_info_exists=prod_info_exists,
|
||||
mixlevel=mixlevel,
|
||||
@@ -459,7 +480,7 @@ class DolbyDigitalPlusMetadata:
|
||||
loro_center_downmix_level=loro_center_downmix_level,
|
||||
loro_surround_downmix_level=loro_surround_downmix_level,
|
||||
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,
|
||||
surround_ex_mode=surround_ex_mode,
|
||||
dolby_headphone_encoded=dolby_headphone_encoded,
|
||||
@@ -467,7 +488,8 @@ class DolbyDigitalPlusMetadata:
|
||||
compression_profile=compression,
|
||||
dynamic_range=dynamic_range,
|
||||
stream_dependency=stream_info,
|
||||
datarate_kbps=data_rate)
|
||||
datarate_kbps=data_rate,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -486,7 +508,7 @@ class DolbyAtmosMetadata:
|
||||
NOT_INDICATED = 0x04
|
||||
|
||||
tool_name: str
|
||||
tool_version: Tuple[int,int,int]
|
||||
tool_version: tuple[int, int, int]
|
||||
warp_mode: WarpMode
|
||||
|
||||
SEGMENT_LENGTH = 248
|
||||
@@ -494,15 +516,16 @@ class DolbyAtmosMetadata:
|
||||
|
||||
@classmethod
|
||||
def load(cls, data: bytes):
|
||||
assert len(data) == cls.SEGMENT_LENGTH, "DolbyAtmosMetadata segment "\
|
||||
"is incorrect length, expected %i actual was %i" % (cls.SEGMENT_LENGTH, len(data))
|
||||
assert len(data) == cls.SEGMENT_LENGTH
|
||||
# (f"DolbyAtmosMetadata segment is incorrect length, "
|
||||
# f"expected {cls.SEGMENT_LENGTH} actual was {len(data)}")
|
||||
|
||||
h = BytesIO(data)
|
||||
|
||||
h.seek(32, 1)
|
||||
toolname = h.read(cls.TOOL_NAME_LENGTH)
|
||||
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)
|
||||
major, minor, fix = unpack("BBB", vers)
|
||||
@@ -512,17 +535,21 @@ class DolbyAtmosMetadata:
|
||||
a_val = unpack("B", h.read(1))[0]
|
||||
warp_mode = a_val & 0x7
|
||||
|
||||
return DolbyAtmosMetadata(tool_name=toolname,
|
||||
tool_version=(major, minor, fix), warp_mode=DolbyAtmosMetadata.WarpMode(warp_mode))
|
||||
return DolbyAtmosMetadata(
|
||||
tool_name=toolname,
|
||||
tool_version=(major, minor, fix),
|
||||
warp_mode=DolbyAtmosMetadata.WarpMode(warp_mode),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class DolbyAtmosSupplementalMetadata:
|
||||
"""
|
||||
Dolby Atmos supplemental metadata segment.
|
||||
|
||||
https://github.com/DolbyLaboratories/dbmd-atmos-parser/blob/master/dbmd_atmos_parse/src/dbmd_atmos_parse.c
|
||||
"""
|
||||
https://github.com/DolbyLaboratories/dbmd-atmos-parser/blob/
|
||||
master/dbmd_atmos_parse/src/dbmd_atmos_parse.c
|
||||
"""
|
||||
|
||||
class BinauralRenderMode(Enum):
|
||||
BYPASS = 0x00
|
||||
@@ -531,18 +558,15 @@ class DolbyAtmosSupplementalMetadata:
|
||||
MID = 0x03
|
||||
NOT_INDICATED = 0x04
|
||||
|
||||
|
||||
object_count: int
|
||||
render_modes: List['DolbyAtmosSupplementalMetadata.BinauralRenderMode']
|
||||
trim_modes: List[int]
|
||||
render_modes: list[DolbyAtmosSupplementalMetadata.BinauralRenderMode]
|
||||
trim_modes: list[int]
|
||||
|
||||
|
||||
MAGIC = 0xf8726fbd
|
||||
MAGIC = 0xF8726FBD
|
||||
TRIM_CONFIG_COUNT = 9
|
||||
|
||||
@classmethod
|
||||
def load(cls, data: bytes):
|
||||
|
||||
trim_modes = []
|
||||
render_modes = []
|
||||
|
||||
@@ -552,23 +576,24 @@ class DolbyAtmosSupplementalMetadata:
|
||||
|
||||
object_count = unpack("<H", h.read(2))[0]
|
||||
|
||||
h.read(1) #skip 1
|
||||
h.read(1) # skip 1
|
||||
|
||||
for _ in range(cls.TRIM_CONFIG_COUNT):
|
||||
auto_trim = unpack("B", h.read(1))
|
||||
trim_modes.append(auto_trim)
|
||||
|
||||
h.read(14) #skip 14
|
||||
|
||||
h.read(object_count) # skip object_count bytes
|
||||
h.read(14) # skip 14
|
||||
|
||||
h.read(object_count) # skip object_count bytes
|
||||
|
||||
for _ in range(object_count):
|
||||
binaural_mode = unpack("B", h.read(1))[0]
|
||||
binaural_mode &= 0x7
|
||||
render_modes.append(binaural_mode)
|
||||
|
||||
return DolbyAtmosSupplementalMetadata(object_count=object_count,
|
||||
render_modes=render_modes,trim_modes=trim_modes)
|
||||
return DolbyAtmosSupplementalMetadata(
|
||||
object_count=object_count, render_modes=render_modes, trim_modes=trim_modes
|
||||
)
|
||||
|
||||
|
||||
class WavDolbyMetadataReader:
|
||||
@@ -580,24 +605,23 @@ class WavDolbyMetadataReader:
|
||||
#:
|
||||
#: Each list entry is a tuple of `SegmentType`, a `bool`
|
||||
#: 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).
|
||||
segment_list: List[Tuple[Union[SegmentType, int], bool, Any]]
|
||||
segment_list: list[tuple[SegmentType | int, bool, Any]]
|
||||
|
||||
version: Tuple[int,int,int,int]
|
||||
version: tuple[int, int, int, int]
|
||||
|
||||
@staticmethod
|
||||
def segment_checksum(bs: bytes, size: int):
|
||||
retval = size
|
||||
for b in bs:
|
||||
retval += int(b)
|
||||
retval &= 0xff
|
||||
retval &= 0xFF
|
||||
|
||||
retval = ((~retval) + 1) & 0xff
|
||||
retval = ((~retval) + 1) & 0xFF
|
||||
|
||||
return retval
|
||||
|
||||
|
||||
def __init__(self, dbmd_data):
|
||||
self.segment_list = []
|
||||
|
||||
@@ -606,18 +630,20 @@ class WavDolbyMetadataReader:
|
||||
v_vec = []
|
||||
for _ in range(4):
|
||||
b = h.read(1)
|
||||
v_vec.insert(0, unpack("B",b)[0])
|
||||
|
||||
v_vec.insert(0, unpack("B", b)[0])
|
||||
|
||||
self.version = tuple(v_vec)
|
||||
|
||||
while True:
|
||||
stype= SegmentType(unpack("B", h.read(1))[0])
|
||||
stype = SegmentType(unpack("B", h.read(1))[0])
|
||||
if stype == SegmentType.EndMarker:
|
||||
break
|
||||
else:
|
||||
seg_size = unpack("<H", h.read(2))[0]
|
||||
seg_payload = h.read(seg_size)
|
||||
expected_checksum = WavDolbyMetadataReader.segment_checksum(seg_payload, seg_size)
|
||||
expected_checksum = WavDolbyMetadataReader.segment_checksum(
|
||||
seg_payload, seg_size
|
||||
)
|
||||
checksum = unpack("B", h.read(1))[0]
|
||||
|
||||
segment = seg_payload
|
||||
@@ -627,35 +653,42 @@ class WavDolbyMetadataReader:
|
||||
segment = DolbyAtmosMetadata.load(segment)
|
||||
elif stype == SegmentType.DolbyAtmosSupplemental:
|
||||
segment = DolbyAtmosSupplementalMetadata.load(segment)
|
||||
|
||||
self.segment_list.append( (stype, checksum == expected_checksum, segment) )
|
||||
|
||||
def dolby_digital_plus(self) -> List[DolbyDigitalPlusMetadata]:
|
||||
|
||||
self.segment_list.append(
|
||||
(stype, checksum == expected_checksum, segment)
|
||||
)
|
||||
|
||||
def dolby_digital_plus(self) -> list[DolbyDigitalPlusMetadata]:
|
||||
"""
|
||||
Every valid Dolby Digital Plus metadata segment in the file.
|
||||
"""
|
||||
return [x[2] for x in self.segment_list \
|
||||
if x[0] == SegmentType.DolbyDigitalPlus and x[1]]
|
||||
return [
|
||||
x[2]
|
||||
for x in self.segment_list
|
||||
if x[0] == SegmentType.DolbyDigitalPlus and x[1]
|
||||
]
|
||||
|
||||
def dolby_atmos(self) -> List[DolbyAtmosMetadata]:
|
||||
def dolby_atmos(self) -> list[DolbyAtmosMetadata]:
|
||||
"""
|
||||
Every valid Dolby Atmos metadata segment in the file.
|
||||
"""
|
||||
return [x[2] for x in self.segment_list \
|
||||
if x[0] == SegmentType.DolbyAtmos and x[1]]
|
||||
return [
|
||||
x[2] for x in self.segment_list if x[0] == SegmentType.DolbyAtmos and x[1]
|
||||
]
|
||||
|
||||
def dolby_atmos_supplemental(self) -> List[DolbyAtmosSupplementalMetadata]:
|
||||
def dolby_atmos_supplemental(self) -> list[DolbyAtmosSupplementalMetadata]:
|
||||
"""
|
||||
Every valid Dolby Atmos Supplemental metadata segment in the file.
|
||||
"""
|
||||
return [x[2] for x in self.segment_list \
|
||||
if x[0] == SegmentType.DolbyAtmosSupplemental and x[1]]
|
||||
return [
|
||||
x[2]
|
||||
for x in self.segment_list
|
||||
if x[0] == SegmentType.DolbyAtmosSupplemental and x[1]
|
||||
]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
ddp = [asdict(x) for x in self.dolby_digital_plus()]
|
||||
atmos = [asdict(x) for x in self.dolby_atmos()]
|
||||
# atmos_sup = map(lambda x: asdict(x), self.dolby_atmos_supplemental())
|
||||
|
||||
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())
|
||||
|
||||
return dict(dolby_digital_plus=list(ddp),
|
||||
dolby_atmos=list(atmos))
|
||||
return {"dolby_digital_plus": ddp, "dolby_atmos": atmos}
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .riff_parser import ListChunkDescriptor, parse_chunk
|
||||
|
||||
|
||||
class WavInfoChunkReader:
|
||||
def __init__(self, f, encoding):
|
||||
self.encoding = encoding
|
||||
|
||||
f.seek(0)
|
||||
parsed_chunks = parse_chunk(f)
|
||||
assert type(parsed_chunks) is ListChunkDescriptor
|
||||
|
||||
list_chunks = [
|
||||
chunk
|
||||
for chunk in parsed_chunks.children
|
||||
if type(chunk) is ListChunkDescriptor
|
||||
]
|
||||
|
||||
self.info_chunk = next(
|
||||
(chunk for chunk in list_chunks if chunk.signature == b"INFO"), None
|
||||
)
|
||||
|
||||
#: 'ICOP' Copyright
|
||||
self.copyright: str | None = self._get_field(f, b"ICOP")
|
||||
#: 'IPRD' Product
|
||||
self.product: str | None = self._get_field(f, b"IPRD")
|
||||
self.album: str | None = self.product
|
||||
#: 'IGNR' Genre
|
||||
self.genre: str | None = self._get_field(f, b"IGNR")
|
||||
#: 'ISBJ' Subject
|
||||
self.subject: str | None = self._get_field(f, b"ISBJ")
|
||||
#: 'IART' Artist, composer, author
|
||||
self.artist: str | None = self._get_field(f, b"IART")
|
||||
#: 'ICMT' Comment
|
||||
self.comment: str | None = self._get_field(f, b"ICMT")
|
||||
#: 'ISFT' Software, encoding application
|
||||
self.software: str | None = self._get_field(f, b"ISFT")
|
||||
#: 'ICRD' Created date
|
||||
self.created_date: str | None = self._get_field(f, b"ICRD")
|
||||
#: 'IENG' Engineer
|
||||
self.engineer: str | None = self._get_field(f, b"IENG")
|
||||
#: 'ITCH' Technician
|
||||
self.technician: str | None = self._get_field(f, b"ITCH")
|
||||
#: 'IKEY' Keywords, keyword list
|
||||
self.keywords: str | None = self._get_field(f, b"IKEY")
|
||||
#: 'INAM' Name, title
|
||||
self.title: str | None = self._get_field(f, b"INAM")
|
||||
#: 'ISRC' Source
|
||||
self.source: str | None = self._get_field(f, b"ISRC")
|
||||
#: 'TAPE' Tape
|
||||
self.tape: str | None = self._get_field(f, b"TAPE")
|
||||
#: 'IARL' Archival Location
|
||||
self.archival_location: str | None = self._get_field(f, b"IARL")
|
||||
#: 'ICSM' Commissioned
|
||||
self.commissioned: str | None = self._get_field(f, b"ICMS")
|
||||
|
||||
def _get_field(self, f, field_ident) -> str | None:
|
||||
search = next(
|
||||
(
|
||||
(chunk.start, chunk.length)
|
||||
for chunk in self.info_chunk.children
|
||||
if chunk.ident == field_ident
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if search is not None:
|
||||
f.seek(search[0])
|
||||
data = f.read(search[1])
|
||||
return data.decode(self.encoding).rstrip("\0")
|
||||
else:
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict: # FIXME should be asdict
|
||||
"""
|
||||
A dictionary with all of the key/values read from the INFO scope.
|
||||
"""
|
||||
return {
|
||||
"copyright": self.copyright,
|
||||
"product": self.product,
|
||||
"album": self.album,
|
||||
"genre": self.genre,
|
||||
"artist": self.artist,
|
||||
"comment": self.comment,
|
||||
"software": self.software,
|
||||
"created_date": self.created_date,
|
||||
"engineer": self.engineer,
|
||||
"keywords": self.keywords,
|
||||
"title": self.title,
|
||||
"source": self.source,
|
||||
"tape": self.tape,
|
||||
"commissioned": self.commissioned,
|
||||
"archival_location": self.archival_location,
|
||||
"subject": self.subject,
|
||||
"technician": self.technician,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return_val = self.to_dict()
|
||||
return_val.update({"encoding": self.encoding})
|
||||
return str(return_val)
|
||||
@@ -1,10 +1,19 @@
|
||||
from lxml import etree as ET
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from collections import namedtuple
|
||||
from typing import Optional
|
||||
from enum import IntEnum
|
||||
|
||||
IXMLTrack = namedtuple('IXMLTrack', ['channel_index', 'interleave_index', 'name', 'function'])
|
||||
# from collections import namedtuple
|
||||
from typing import NamedTuple
|
||||
|
||||
from lxml import etree as ET
|
||||
|
||||
|
||||
class IXMLTrack(NamedTuple):
|
||||
channel_index: int
|
||||
interleave_index: int
|
||||
name: str
|
||||
function: str
|
||||
|
||||
|
||||
class SteinbergMetadata:
|
||||
@@ -16,6 +25,7 @@ class SteinbergMetadata:
|
||||
"""
|
||||
Steinberg speaker format enumeration.
|
||||
"""
|
||||
|
||||
MONO = 0
|
||||
STEREO = 1
|
||||
LRC = 10
|
||||
@@ -29,7 +39,7 @@ class SteinbergMetadata:
|
||||
CINE_71 = 27
|
||||
SDDS_70 = 24
|
||||
SDDS_71 = 26
|
||||
MUSIC_60 = 21 #??
|
||||
MUSIC_60 = 21 # ??
|
||||
MUSIC_61 = 23
|
||||
ATMOS_712 = 33
|
||||
ATMOS_504 = 35
|
||||
@@ -68,16 +78,18 @@ class SteinbergMetadata:
|
||||
self.parsed = xml.find(self.Steinberg_xpath)
|
||||
|
||||
@property
|
||||
def audio_speaker_arrangement(self) -> Optional[AudioSpeakerArrangement]:
|
||||
def audio_speaker_arrangement(self) -> AudioSpeakerArrangement | None:
|
||||
"""
|
||||
`AudioSpeakerArrangement` property
|
||||
"""
|
||||
val = self.parsed.find("./ATTR_LIST/ATTR[NAME = 'AudioSpeakerArrangement']/VALUE")
|
||||
val = self.parsed.find(
|
||||
"./ATTR_LIST/ATTR[NAME = 'AudioSpeakerArrangement']/VALUE"
|
||||
)
|
||||
if val is not None:
|
||||
return type(self).AudioSpeakerArrangement(int(val.text))
|
||||
|
||||
@property
|
||||
def sample_format_size(self) -> Optional[int]:
|
||||
def sample_format_size(self) -> int | None:
|
||||
"""
|
||||
AudioSampleFormatSize
|
||||
"""
|
||||
@@ -86,7 +98,7 @@ class SteinbergMetadata:
|
||||
return int(val.text)
|
||||
|
||||
@property
|
||||
def media_company(self) -> Optional[str]:
|
||||
def media_company(self) -> str | None:
|
||||
"""
|
||||
MediaCompany
|
||||
"""
|
||||
@@ -95,7 +107,7 @@ class SteinbergMetadata:
|
||||
return val.text
|
||||
|
||||
@property
|
||||
def media_drop_frames(self) -> Optional[bool]:
|
||||
def media_drop_frames(self) -> bool | None:
|
||||
"""
|
||||
MediaDropFrames
|
||||
"""
|
||||
@@ -104,7 +116,7 @@ class SteinbergMetadata:
|
||||
return val.text == "1"
|
||||
|
||||
@property
|
||||
def media_duration(self) -> Optional[float]:
|
||||
def media_duration(self) -> float | None:
|
||||
"""
|
||||
MediaDuration
|
||||
"""
|
||||
@@ -112,39 +124,36 @@ class SteinbergMetadata:
|
||||
if val is not None:
|
||||
return float(val.text)
|
||||
|
||||
@property
|
||||
def media_start_time(self) -> Optional[float]:
|
||||
"""
|
||||
MediaStartTime
|
||||
"""
|
||||
pass
|
||||
# @property
|
||||
# def media_start_time(self) -> float | None:
|
||||
# """
|
||||
# MediaStartTime
|
||||
# """
|
||||
|
||||
@property
|
||||
def media_track_title(self) -> Optional[str]:
|
||||
"""
|
||||
MediaTrackTitle
|
||||
"""
|
||||
pass
|
||||
# @property
|
||||
# def media_track_title(self) -> str | None:
|
||||
# """
|
||||
# MediaTrackTitle
|
||||
# """
|
||||
|
||||
@property
|
||||
def program_name(self) -> Optional[str]:
|
||||
"""
|
||||
ProgramName
|
||||
"""
|
||||
pass
|
||||
# @property
|
||||
# def program_name(self) -> str | None:
|
||||
# """
|
||||
# ProgramName
|
||||
# """
|
||||
|
||||
@property
|
||||
def program_version(self) -> Optional[str]:
|
||||
"""
|
||||
ProgramVersion
|
||||
"""
|
||||
pass
|
||||
# @property
|
||||
# def program_version(self) -> str | None:
|
||||
# """
|
||||
# ProgramVersion
|
||||
# """
|
||||
|
||||
|
||||
class WavIXMLFormat:
|
||||
"""
|
||||
iXML recorder metadata.
|
||||
"""
|
||||
|
||||
def __init__(self, xml):
|
||||
"""
|
||||
Parse iXML.
|
||||
@@ -153,13 +162,13 @@ class WavIXMLFormat:
|
||||
self.source = xml
|
||||
xml_bytes = io.BytesIO(xml)
|
||||
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) -> Optional[str]:
|
||||
def _get_text_value(self, xpath) -> str | None:
|
||||
e = self.parsed.find("./" + xpath)
|
||||
if e is not None:
|
||||
return e.text
|
||||
else:
|
||||
else:
|
||||
return None
|
||||
|
||||
def xml_str(self) -> str:
|
||||
@@ -180,57 +189,60 @@ class WavIXMLFormat:
|
||||
:yields: `IXMLTrack` for each track.
|
||||
"""
|
||||
for track in self.parsed.find("./TRACK_LIST").iter():
|
||||
if track.tag == 'TRACK':
|
||||
yield IXMLTrack(channel_index=track.xpath('string(CHANNEL_INDEX/text())'),
|
||||
interleave_index=track.xpath('string(INTERLEAVE_INDEX/text())'),
|
||||
name=track.xpath('string(NAME/text())'),
|
||||
function=track.xpath('string(FUNCTION/text())'))
|
||||
if track.tag == "TRACK":
|
||||
yield IXMLTrack(
|
||||
channel_index=track.xpath("string(CHANNEL_INDEX/text())"),
|
||||
interleave_index=track.xpath("string(INTERLEAVE_INDEX/text())"),
|
||||
name=track.xpath("string(NAME/text())"),
|
||||
function=track.xpath("string(FUNCTION/text())"),
|
||||
)
|
||||
|
||||
@property
|
||||
def project(self) -> Optional[str]:
|
||||
def project(self) -> str | None:
|
||||
"""
|
||||
The project/film name entered for the recording.
|
||||
"""
|
||||
return self._get_text_value("PROJECT")
|
||||
|
||||
@property
|
||||
def scene(self) -> Optional[str]:
|
||||
def scene(self) -> str | None:
|
||||
"""
|
||||
Scene/slate.
|
||||
"""
|
||||
return self._get_text_value("SCENE")
|
||||
|
||||
@property
|
||||
def take(self) -> Optional[str]:
|
||||
def take(self) -> str | None:
|
||||
"""
|
||||
Take number.
|
||||
"""
|
||||
return self._get_text_value("TAKE")
|
||||
|
||||
@property
|
||||
def tape(self) -> Optional[str]:
|
||||
def tape(self) -> str | None:
|
||||
"""
|
||||
Tape name.
|
||||
"""
|
||||
return self._get_text_value("TAPE")
|
||||
|
||||
@property
|
||||
def family_uid(self) -> Optional[str]:
|
||||
def family_uid(self) -> str | None:
|
||||
"""
|
||||
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 number.
|
||||
of a GUID, or an EBU Rec 9 source identifier, or some other dumb
|
||||
number.
|
||||
"""
|
||||
return self._get_text_value("FILE_SET/FAMILY_UID")
|
||||
|
||||
@property
|
||||
def family_name(self) -> Optional[str]:
|
||||
def family_name(self) -> str | None:
|
||||
"""
|
||||
The name of this file's file family.
|
||||
"""
|
||||
return self._get_text_value("FILE_SET/FAMILY_NAME")
|
||||
|
||||
@property
|
||||
def steinberg(self) -> Optional[SteinbergMetadata]:
|
||||
def steinberg(self) -> SteinbergMetadata | None:
|
||||
"""
|
||||
Steinberg vendor iXML metadata if present.
|
||||
"""
|
||||
@@ -240,11 +252,12 @@ class WavIXMLFormat:
|
||||
return None
|
||||
|
||||
def to_dict(self):
|
||||
return dict(track_list=list(map(lambda x: x._asdict(), self.track_list)),
|
||||
project=self.project,
|
||||
scene=self.scene,
|
||||
take=self.take,
|
||||
tape=self.tape,
|
||||
family_uid=self.family_uid,
|
||||
family_name=self.family_name
|
||||
)
|
||||
return {
|
||||
"track_list": [x._asdict() for x in self.track_list],
|
||||
"project": self.project,
|
||||
"scene": self.scene,
|
||||
"take": self.take,
|
||||
"tape": self.tape,
|
||||
"family_uid": self.family_uid,
|
||||
"family_name": self.family_name,
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import struct
|
||||
from typing import Any, Generator, NamedTuple
|
||||
|
||||
from .riff_parser import ChunkDescriptor, ListChunkDescriptor, parse_chunk
|
||||
from .wave_adm_reader import WavADMReader
|
||||
from .wave_bext_reader import WavBextReader
|
||||
from .wave_cues_reader import WavCuesReader
|
||||
from .wave_dbmd_reader import WavDolbyMetadataReader
|
||||
from .wave_info_reader import WavInfoChunkReader
|
||||
from .wave_ixml_reader import WavIXMLFormat
|
||||
from .wave_smpl_reader import WavSmplReader
|
||||
|
||||
#: Calculated statistics about the audio data.
|
||||
|
||||
|
||||
class WavDataDescriptor(NamedTuple):
|
||||
byte_count: int
|
||||
frame_count: int
|
||||
|
||||
|
||||
#: The format of the audio samples.
|
||||
class WavAudioFormat(NamedTuple):
|
||||
audio_format: int
|
||||
channel_count: int
|
||||
sample_rate: int
|
||||
byte_rate: int
|
||||
block_align: int
|
||||
bits_per_sample: int
|
||||
|
||||
|
||||
class WavInfoReader:
|
||||
"""
|
||||
Parse a WAV audio file for metadata.
|
||||
"""
|
||||
|
||||
def __init__(self, path, info_encoding="latin_1", bext_encoding="ascii"):
|
||||
"""
|
||||
Create a new reader object.
|
||||
|
||||
:param path:
|
||||
A pathlike object or IO to the wav file you wish to probe or a
|
||||
file handle to an open file.
|
||||
|
||||
:param info_encoding:
|
||||
The text encoding of the ``INFO``, ``LABL`` and other RIFF-defined
|
||||
metadata fields.
|
||||
|
||||
:param bext_encoding:
|
||||
The text encoding to use when decoding the string
|
||||
fields of the Broadcast-WAV extension. Per EBU 3285 this is ASCII
|
||||
but this parameter is available to you if you encounter a weirdo.
|
||||
"""
|
||||
|
||||
self.info_encoding = info_encoding
|
||||
self.bext_encoding = bext_encoding
|
||||
|
||||
#: Wave audio data format.
|
||||
self.fmt: WavAudioFormat | None = None
|
||||
|
||||
#: Statistics of the `data` section.
|
||||
self.data: WavDataDescriptor | None = None
|
||||
|
||||
#: Broadcast-Wave metadata.
|
||||
self.bext: WavBextReader | None = None
|
||||
|
||||
#: iXML metadata.
|
||||
self.ixml: WavIXMLFormat | None = None
|
||||
|
||||
#: ADM Audio Definiton Model metadata.
|
||||
self.adm: WavADMReader | None = None
|
||||
|
||||
#: Dolby bitstream metadata.
|
||||
self.dolby: WavDolbyMetadataReader | None = None
|
||||
|
||||
#: RIFF INFO metadata.
|
||||
self.info: WavInfoChunkReader | None = None
|
||||
|
||||
#: RIFF cues markers, labels, and notes.
|
||||
self.cues: WavCuesReader | None = None
|
||||
|
||||
#: Sampler `smpl` metadata
|
||||
self.smpl: WavSmplReader | None = None
|
||||
|
||||
if hasattr(path, "read"):
|
||||
self.get_wav_info(path)
|
||||
self.url = "about:blank"
|
||||
self.path = repr(path)
|
||||
|
||||
else:
|
||||
absolute_path = os.path.abspath(path)
|
||||
|
||||
#: `file://` url for the file.
|
||||
self.url: str = pathlib.Path(absolute_path).as_uri()
|
||||
|
||||
self.path = absolute_path
|
||||
|
||||
with open(path, "rb") as file:
|
||||
self.get_wav_info(file)
|
||||
|
||||
def get_wav_info(self, wavfile):
|
||||
chunks = parse_chunk(wavfile)
|
||||
assert type(chunks) is ListChunkDescriptor
|
||||
|
||||
self.main_list = chunks.children
|
||||
wavfile.seek(0)
|
||||
|
||||
self.fmt = self._get_format(wavfile)
|
||||
self.bext = self._get_bext(wavfile, encoding=self.bext_encoding)
|
||||
self.ixml = self._get_ixml(wavfile)
|
||||
self.adm = self._get_adm(wavfile)
|
||||
self.info = self._get_info(wavfile, encoding=self.info_encoding)
|
||||
self.dolby = self._get_dbmd(wavfile)
|
||||
self.cues = self._get_cue(wavfile)
|
||||
self.smpl = self._get_sampler_loops(wavfile)
|
||||
self.data = self._describe_data()
|
||||
|
||||
def _find_chunk_data(self, ident, from_stream, default_none=False) -> bytes | None:
|
||||
top_chunks = (
|
||||
chunk
|
||||
for chunk in self.main_list
|
||||
if type(chunk) is ChunkDescriptor and chunk.ident == ident
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
def _find_list_chunk(self, signature) -> ListChunkDescriptor | None:
|
||||
top_chunks = (
|
||||
chunk
|
||||
for chunk in self.main_list
|
||||
if type(chunk) is ListChunkDescriptor and chunk.signature == signature
|
||||
)
|
||||
|
||||
return next(top_chunks, None)
|
||||
|
||||
def _describe_data(self):
|
||||
data_chunk = next(
|
||||
c
|
||||
for c in self.main_list
|
||||
if type(c) is ChunkDescriptor and c.ident == b"data"
|
||||
)
|
||||
|
||||
assert isinstance(self.fmt, WavAudioFormat)
|
||||
return WavDataDescriptor(
|
||||
byte_count=data_chunk.length,
|
||||
frame_count=int(data_chunk.length / self.fmt.block_align),
|
||||
)
|
||||
|
||||
def _get_format(self, 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"
|
||||
|
||||
packstring = "<HHIIHH"
|
||||
rest_starts = struct.calcsize(packstring)
|
||||
|
||||
unpacked = struct.unpack(packstring, fmt_data[:rest_starts])
|
||||
|
||||
return WavAudioFormat(
|
||||
audio_format=unpacked[0],
|
||||
channel_count=unpacked[1],
|
||||
sample_rate=unpacked[2],
|
||||
byte_rate=unpacked[3],
|
||||
block_align=unpacked[4],
|
||||
bits_per_sample=unpacked[5],
|
||||
)
|
||||
|
||||
def _get_info(self, f, encoding):
|
||||
finder = (
|
||||
chunk.signature
|
||||
for chunk in self.main_list
|
||||
if type(chunk) is ListChunkDescriptor
|
||||
)
|
||||
|
||||
if b"INFO" in finder:
|
||||
return WavInfoChunkReader(f, encoding)
|
||||
|
||||
def _get_bext(self, f, encoding):
|
||||
bext_data = self._find_chunk_data(b"bext", f, default_none=True)
|
||||
return WavBextReader(bext_data, encoding) if bext_data else None
|
||||
|
||||
def _get_adm(self, f):
|
||||
axml = self._find_chunk_data(b"axml", 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
|
||||
|
||||
def _get_dbmd(self, f):
|
||||
dbmd_data = self._find_chunk_data(b"dbmd", f, default_none=True)
|
||||
return WavDolbyMetadataReader(dbmd_data=dbmd_data) if dbmd_data else None
|
||||
|
||||
def _get_ixml(self, f):
|
||||
ixml_data = self._find_chunk_data(b"iXML", f, default_none=True)
|
||||
return WavIXMLFormat(ixml_data.rstrip(b"\0")) if ixml_data else None
|
||||
|
||||
def _get_cue(self, f):
|
||||
cue = next(
|
||||
(
|
||||
cue_chunk
|
||||
for cue_chunk in self.main_list
|
||||
if type(cue_chunk) is ChunkDescriptor and cue_chunk.ident == b"cue "
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
adtl = self._find_list_chunk(b"adtl")
|
||||
labls = []
|
||||
ltxts = []
|
||||
notes = []
|
||||
if adtl is not None:
|
||||
labls = [
|
||||
c
|
||||
for c in adtl.children
|
||||
if type(c) is ChunkDescriptor and c.ident == b"labl"
|
||||
]
|
||||
ltxts = [
|
||||
c
|
||||
for c in adtl.children
|
||||
if type(c) is ChunkDescriptor and c.ident == b"ltxt"
|
||||
]
|
||||
notes = [
|
||||
c
|
||||
for c in adtl.children
|
||||
if type(c) is ChunkDescriptor and c.ident == b"note"
|
||||
]
|
||||
|
||||
return WavCuesReader.read_all(
|
||||
f, cue, labls, ltxts, notes, fallback_encoding=self.info_encoding
|
||||
)
|
||||
|
||||
def _get_sampler_loops(self, f):
|
||||
sampler_data = self._find_chunk_data(b"smpl", f, default_none=True)
|
||||
return WavSmplReader(sampler_data) if sampler_data else None
|
||||
|
||||
# FIXME: this should probably be named "iter()"
|
||||
def walk(self) -> Generator[str, str, Any]:
|
||||
"""
|
||||
Walk all of the available metadata fields.
|
||||
|
||||
:yields: tuples of the *scope*, *key*, and *value* of
|
||||
each metadatum. The *scope* value will be one of
|
||||
"fmt", "data", "ixml", "bext", "info", "dolby", "cues", "adm" or
|
||||
"smpl".
|
||||
"""
|
||||
|
||||
scopes = ("fmt", "data", "ixml", "bext", "info", "adm", "cues", "dolby", "smpl")
|
||||
|
||||
for scope in scopes:
|
||||
if scope in ["fmt", "data"]:
|
||||
attr = self.__getattribute__(scope)
|
||||
for field in attr._fields:
|
||||
yield scope, field, attr.__getattribute__(field)
|
||||
|
||||
else:
|
||||
mdict = (
|
||||
self.__getattribute__(scope).to_dict()
|
||||
if self.__getattribute__(scope)
|
||||
else {}
|
||||
)
|
||||
for key in mdict:
|
||||
yield scope, key, mdict[key]
|
||||
|
||||
def __repr__(self):
|
||||
return f"WavInfoReader({self.path}, {self.info_encoding}, {self.bext_encoding})"
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class WaveSmplLoop(NamedTuple):
|
||||
ident: int
|
||||
loop_type: int
|
||||
start: int
|
||||
end: int
|
||||
detune_cents: int
|
||||
repetition_count: int
|
||||
|
||||
def loop_type_desc(self):
|
||||
if self.loop_type == 0:
|
||||
return "FORWARD"
|
||||
elif self.loop_type == 1:
|
||||
return "FORWARD_BACKWARD"
|
||||
elif self.loop_type == 2:
|
||||
return "BACKWARD"
|
||||
elif 3 <= self.loop_type <= 31:
|
||||
return "RESERVED"
|
||||
else:
|
||||
return "VENDOR"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"ident": self.ident,
|
||||
"loop_type": self.loop_type,
|
||||
"loop_type_description": self.loop_type_desc(),
|
||||
"start_samples": self.start,
|
||||
"end_samples": self.end,
|
||||
"detune_cents": self.detune_cents,
|
||||
"repetition_count": self.repetition_count,
|
||||
}
|
||||
|
||||
|
||||
class WavSmplReader:
|
||||
def __init__(self, smpl_data: bytes):
|
||||
"""
|
||||
Read sampler metadata from smpl chunk.
|
||||
"""
|
||||
|
||||
header_field_fmt = "<IIIIiIbbbbII"
|
||||
loop_field_fmt = "<IIIIiI"
|
||||
header_size = struct.calcsize(header_field_fmt)
|
||||
loop_size = struct.calcsize(loop_field_fmt)
|
||||
|
||||
unpacked_data = struct.unpack(header_field_fmt, smpl_data[0:header_size])
|
||||
|
||||
#: The MIDI Manufacturer's Association code for the sampler
|
||||
#: manufactuer, or 0 if not specific.
|
||||
self.manufacturer: int = unpacked_data[0]
|
||||
|
||||
#: The manufacturer-assigned code for their specific sampler model, or
|
||||
#: 0 if not specific.
|
||||
self.product: int = unpacked_data[1]
|
||||
|
||||
#: The number of nanoseconds in one audio frame.
|
||||
self.sample_period_ns: int = unpacked_data[2]
|
||||
|
||||
#: The MIDI note number for the loops in this sample
|
||||
self.midi_note: int = unpacked_data[3]
|
||||
|
||||
#: The number of semitones above the MIDI note the loops tune for.
|
||||
self.midi_pitch_detune_cents: int = unpacked_data[4]
|
||||
|
||||
#: SMPTE timecode format, one of (0, 24, 25, 29, 30)
|
||||
self.smpte_format: int = unpacked_data[5]
|
||||
|
||||
#: The SMPTE offset to apply, as a tuple of four ints representing
|
||||
#: hh, mm, ss, ff
|
||||
self.smpte_offset: tuple[int, int, int, int] = unpacked_data[6:10]
|
||||
|
||||
loop_count = unpacked_data[10]
|
||||
sampler_udata_length = unpacked_data[11]
|
||||
|
||||
#: List of loops in the file.
|
||||
self.sample_loops: list[WaveSmplLoop] = []
|
||||
|
||||
loop_buffer = smpl_data[header_size : header_size + loop_size * loop_count]
|
||||
|
||||
for unpacked_loop in struct.iter_unpack(loop_field_fmt, loop_buffer):
|
||||
self.sample_loops.append(
|
||||
WaveSmplLoop(
|
||||
ident=unpacked_loop[0],
|
||||
loop_type=unpacked_loop[1],
|
||||
start=unpacked_loop[2],
|
||||
end=unpacked_loop[3],
|
||||
detune_cents=unpacked_loop[4],
|
||||
repetition_count=unpacked_loop[5],
|
||||
)
|
||||
)
|
||||
|
||||
#: Sampler-specific user data.
|
||||
self.sampler_udata: bytes | None = None
|
||||
|
||||
if sampler_udata_length > 0:
|
||||
self.sampler_udata = smpl_data[
|
||||
header_size + loop_size * loop_count : header_size
|
||||
+ loop_size * loop_count
|
||||
+ sampler_udata_length
|
||||
]
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"manufactuer": self.manufacturer,
|
||||
"product": self.product,
|
||||
"sample_period_ns": self.sample_period_ns,
|
||||
"midi_note": self.midi_note,
|
||||
"midi_pitch_detune_cents": self.midi_pitch_detune_cents,
|
||||
"smpte_format": self.smpte_format,
|
||||
"smpte_offset": "%02i:%02i:%02i:%02i" % self.smpte_offset,
|
||||
"loops": [x.to_dict() for x in self.sample_loops],
|
||||
"sampler_user_data": self.sampler_udata,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
wavfind.py
|
||||
|
||||
Under construction! Do Not Use!
|
||||
"""
|
||||
import sys
|
||||
from optparse import OptionGroup, OptionParser
|
||||
|
||||
|
||||
def main():
|
||||
parser = OptionParser()
|
||||
|
||||
parser.usage = "wavfind [--scene=SCENE] [--take=TAKE] [--desc=DESC] <PATH> +"
|
||||
|
||||
primaries = OptionGroup(
|
||||
parser,
|
||||
title="Search Predicates",
|
||||
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("--take", help="Search for this take", metavar="TAKE")
|
||||
|
||||
primaries.add_option("--desc", help="Search descriptions", metavar="DESC")
|
||||
|
||||
(_options, _args) = parser.parse_args(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,44 +0,0 @@
|
||||
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)
|
||||
|
||||
|
||||
|
||||
+13
-9
@@ -2,8 +2,8 @@ from unittest import TestCase
|
||||
|
||||
import wavinfo
|
||||
|
||||
class TestADMWave(TestCase):
|
||||
|
||||
class TestADMWave(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.protools_adm_wav = "tests/test_files/protools/Test_ADM_ProTools.wav"
|
||||
return super().setUp()
|
||||
@@ -15,37 +15,41 @@ class TestADMWave(TestCase):
|
||||
adm = info.adm
|
||||
self.assertIsNotNone(adm)
|
||||
|
||||
assert adm is not None
|
||||
self.assertEqual(len(adm.channel_uids), 14)
|
||||
|
||||
def test_to_dict(self):
|
||||
info = wavinfo.WavInfoReader(self.protools_adm_wav)
|
||||
adm = info.adm
|
||||
assert adm is not None
|
||||
dict = adm.to_dict()
|
||||
self.assertIsNotNone(dict)
|
||||
|
||||
|
||||
def test_programme(self):
|
||||
info = wavinfo.WavInfoReader(self.protools_adm_wav)
|
||||
adm = info.adm
|
||||
assert adm is not None
|
||||
pdict = adm.programme()
|
||||
self.assertIn("programme_id", pdict.keys())
|
||||
self.assertIn("programme_name", pdict.keys())
|
||||
self.assertEqual(pdict['programme_id'], 'APR_1001')
|
||||
self.assertEqual(pdict['programme_name'], 'Atmos_Master')
|
||||
self.assertEqual(pdict["programme_id"], "APR_1001")
|
||||
self.assertEqual(pdict["programme_name"], "Atmos_Master")
|
||||
self.assertIn("contents", pdict.keys())
|
||||
self.assertEqual(len(pdict["contents"]), 3)
|
||||
|
||||
def test_track_info(self):
|
||||
info = wavinfo.WavInfoReader(self.protools_adm_wav)
|
||||
adm = info.adm
|
||||
|
||||
assert adm is not None
|
||||
t1 = adm.track_info(0)
|
||||
self.assertTrue("channel_format_name" in t1.keys())
|
||||
assert t1 is not None
|
||||
self.assertTrue("channel_format_name" in t1)
|
||||
self.assertEqual("RoomCentricLeft", t1["channel_format_name"])
|
||||
|
||||
self.assertTrue("pack_format_name" in t1.keys())
|
||||
self.assertTrue("pack_format_name" in t1)
|
||||
self.assertEqual("AtmosCustomPackFormat1", t1["pack_format_name"])
|
||||
|
||||
t10 = adm.track_info(10)
|
||||
self.assertTrue("content_name" in t10.keys())
|
||||
assert t10 is not None
|
||||
self.assertTrue("content_name" in t10)
|
||||
self.assertEqual("Dialog", t10["content_name"])
|
||||
|
||||
|
||||
+16
-16
@@ -1,19 +1,21 @@
|
||||
from unittest import TestCase
|
||||
from glob import glob
|
||||
from unittest import TestCase
|
||||
|
||||
import wavinfo
|
||||
|
||||
|
||||
class TestCue(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.test_files = glob("tests/test_files/cue_chunks/*.wav")
|
||||
self.test_files = glob("tests/test_files/cue_chunks/*.wav")
|
||||
return super().setUp()
|
||||
|
||||
def test_enumerate(self):
|
||||
file1 = "tests/test_files/cue_chunks/STE-000.wav"
|
||||
w1 = wavinfo.WavInfoReader(file1)
|
||||
self.assertIsNotNone(w1.cues)
|
||||
assert w1.cues is not None
|
||||
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):
|
||||
file = "tests/test_files/cue_chunks/izotoperx_cues_test.wav"
|
||||
@@ -22,7 +24,7 @@ class TestCue(TestCase):
|
||||
assert w1.cues is not None
|
||||
|
||||
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)
|
||||
if name == 1:
|
||||
self.assertEqual("Marker 1", label)
|
||||
@@ -34,18 +36,20 @@ class TestCue(TestCase):
|
||||
self.assertIsNotNone(w1.cues)
|
||||
assert w1.cues is not None
|
||||
|
||||
self.assertEqual(w1.cues.range(3), 10000)
|
||||
self.assertEqual(w1.cues.range(3), 10000)
|
||||
|
||||
def test_encoding_fallback(self):
|
||||
"""
|
||||
Added this after I noticed that iZotope RX seems to just encode "notes"
|
||||
as utf-8 without bothering to dump this info into the ltxt or
|
||||
as utf-8 without bothering to dump this info into the ltxt or
|
||||
specifying an encoding by some other means.
|
||||
"""
|
||||
file = "tests/test_files/cue_chunks/izotoperx_cues_test.wav"
|
||||
w = wavinfo.WavInfoReader(file, info_encoding='utf-8')
|
||||
expected = ("Лорем ипсум долор сит амет, тимеам вивендум хас ет, "
|
||||
"цу адолесценс дефинитионес еам.")
|
||||
w = wavinfo.WavInfoReader(file, info_encoding="utf-8")
|
||||
expected = (
|
||||
"Лорем ипсум долор сит амет, тимеам вивендум хас ет, "
|
||||
"цу адолесценс дефинитионес еам."
|
||||
)
|
||||
|
||||
assert w.cues is not None
|
||||
note = [n for n in w.cues.notes if n.name == 3]
|
||||
@@ -55,20 +59,16 @@ class TestCue(TestCase):
|
||||
def test_label(self):
|
||||
file = "tests/test_files/cue_chunks/izotoperx_cues_test.wav"
|
||||
w = wavinfo.WavInfoReader(file)
|
||||
|
||||
|
||||
self.assertIsNotNone(w.cues)
|
||||
assert w.cues is not None
|
||||
assert w.cues is not None
|
||||
|
||||
self.assertEqual(len(w.cues.labels), 3)
|
||||
for label in w.cues.labels:
|
||||
self.assertIn(label.name, [1,2,3])
|
||||
self.assertIn(label.name, [1, 2, 3])
|
||||
if label.name == 1:
|
||||
self.assertEqual(label.text, "Marker 1")
|
||||
elif label.name == 2:
|
||||
self.assertEqual(label.text, "Marker 2")
|
||||
elif label.name == 3:
|
||||
self.assertEqual(label.text, "Marker 3")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+19
-10
@@ -1,7 +1,8 @@
|
||||
from unittest import TestCase
|
||||
|
||||
import wavinfo
|
||||
from wavinfo.wave_dbmd_reader import SegmentType, DolbyAtmosMetadata, DolbyDigitalPlusMetadata
|
||||
from wavinfo.wave_dbmd_reader import DolbyDigitalPlusMetadata, SegmentType
|
||||
|
||||
|
||||
class TestDolby(TestCase):
|
||||
def setUp(self):
|
||||
@@ -10,9 +11,9 @@ class TestDolby(TestCase):
|
||||
def test_version(self):
|
||||
t1 = wavinfo.WavInfoReader(self.test_file)
|
||||
d = t1.dolby
|
||||
|
||||
|
||||
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):
|
||||
t1 = wavinfo.WavInfoReader(self.test_file)
|
||||
@@ -38,15 +39,23 @@ class TestDolby(TestCase):
|
||||
d = t1.dolby
|
||||
assert d is not None
|
||||
ddp = d.dolby_digital_plus()
|
||||
self.assertEqual(len(ddp), 1, "Failed to find exactly one Dolby Digital Plus metadata segment")
|
||||
self.assertTrue( ddp[0].audio_coding_mode, DolbyDigitalPlusMetadata.AudioCodingMode.CH_ORD_3_2 )
|
||||
self.assertTrue( ddp[0].lfe_on)
|
||||
|
||||
self.assertEqual(
|
||||
len(ddp),
|
||||
1,
|
||||
("Failed to find exactly one Dolby Digital Plus metadata segment"),
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
ddp[0].audio_coding_mode,
|
||||
DolbyDigitalPlusMetadata.AudioCodingMode.CH_ORD_3_2,
|
||||
)
|
||||
self.assertTrue(ddp[0].lfe_on)
|
||||
|
||||
def test_atmos(self):
|
||||
t1 = wavinfo.WavInfoReader(self.test_file)
|
||||
d = t1.dolby
|
||||
assert d is not None
|
||||
atmos = d.dolby_atmos()
|
||||
self.assertEqual(len(atmos), 1, "Failed to find exactly one Atmos metadata segment")
|
||||
|
||||
|
||||
self.assertEqual(
|
||||
len(atmos), 1, "Failed to find exactly one Atmos metadata segment"
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
-21
@@ -1,33 +1,23 @@
|
||||
import glob
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from wavinfo.__main__ import main
|
||||
|
||||
import sys
|
||||
import glob
|
||||
|
||||
class MainTest(unittest.TestCase):
|
||||
|
||||
def test_empty_argv(self):
|
||||
with patch.object(sys, 'argv', []):
|
||||
try:
|
||||
main()
|
||||
except:
|
||||
self.fail("main() throwing an exception")
|
||||
def test_empty_argv(self):
|
||||
with patch.object(sys, "argv", []):
|
||||
main()
|
||||
|
||||
def test_a_file(self):
|
||||
for path in glob.glob("tests/test_files/**/*.wav"):
|
||||
with patch.object(sys, 'argv', ["TEST", path]):
|
||||
try:
|
||||
main()
|
||||
except:
|
||||
self.fail("main() throwing an exception")
|
||||
with patch.object(sys, "argv", ["TEST", path]):
|
||||
main()
|
||||
|
||||
def test_ixml(self):
|
||||
with patch.object(sys, 'argv',
|
||||
['TEST', '--ixml', 'tests/test_files/sounddevices/A101_1.WAV']):
|
||||
try:
|
||||
main()
|
||||
except:
|
||||
self.fail("main() throwing an exception")
|
||||
with patch.object(
|
||||
sys, "argv", ["TEST", "--ixml", "tests/test_files/sounddevices/A101_1.WAV"]
|
||||
):
|
||||
main()
|
||||
|
||||
+5
-10
@@ -1,25 +1,20 @@
|
||||
# import os.path
|
||||
import gzip
|
||||
from glob import glob
|
||||
# from typing import Dict, Any, cast
|
||||
|
||||
# from typing import Dict, Any, cast
|
||||
from unittest import TestCase
|
||||
|
||||
# from .utils import all_files, ffprobe
|
||||
|
||||
import wavinfo
|
||||
|
||||
class TestRf64(TestCase):
|
||||
|
||||
class TestRf64(TestCase):
|
||||
def setUp(self) -> None:
|
||||
return super().setUp()
|
||||
|
||||
def test_open(self):
|
||||
|
||||
for path in glob("tests/test_files/rf64/*.wav.gz"):
|
||||
gz = gzip.open(path)
|
||||
wav_info = wavinfo.WavInfoReader(gz)
|
||||
|
||||
self.assertIsNotNone(wav_info)
|
||||
# self.assertIsNotNone(wav_info.bext)
|
||||
|
||||
with gzip.open(path) as gz:
|
||||
wav_info = wavinfo.WavInfoReader(gz)
|
||||
self.assertIsNotNone(wav_info)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from glob import glob
|
||||
from unittest import TestCase
|
||||
|
||||
import wavinfo
|
||||
|
||||
|
||||
class TestSmpl(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.test_files = glob("tests/test_files/smpl/*.wav")
|
||||
return super().setUp()
|
||||
|
||||
def test_each(self):
|
||||
for file in self.test_files:
|
||||
w = wavinfo.WavInfoReader(file)
|
||||
d = w.walk()
|
||||
self.assertIsNotNone(d)
|
||||
+14
-16
@@ -1,37 +1,35 @@
|
||||
import glob
|
||||
import unittest
|
||||
|
||||
import wavinfo
|
||||
|
||||
import glob
|
||||
|
||||
class TestWalk(unittest.TestCase):
|
||||
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)
|
||||
|
||||
tested_data , tested_format = False, False
|
||||
tested_data, tested_format = False, False
|
||||
for scope, key, value in info.walk():
|
||||
if scope == 'fmt':
|
||||
if key == 'channel_count':
|
||||
if scope == "fmt" and key == "channel_count":
|
||||
tested_format = True
|
||||
self.assertEqual(value, 2)
|
||||
if scope == 'data':
|
||||
if key == 'frame_count':
|
||||
if scope == "data" and key == "frame_count":
|
||||
tested_data = True
|
||||
self.assertEqual(value, 144140)
|
||||
|
||||
self.assertTrue(tested_data and tested_format)
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
for _, _, _ in info.walk():
|
||||
pass
|
||||
except:
|
||||
self.fail(f"Failed to walk metadata in file {file}")
|
||||
|
||||
|
||||
# try:
|
||||
for _, _, _ in info.walk():
|
||||
pass
|
||||
# except Exception as e:
|
||||
# self.fail(f"Failed to walk metadata in file {file}: {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+118
-61
@@ -1,19 +1,21 @@
|
||||
import os.path
|
||||
from glob import glob
|
||||
from typing import Dict, Any, cast
|
||||
|
||||
from typing import Any, Dict, cast
|
||||
from unittest import TestCase
|
||||
|
||||
from .utils import all_files, ffprobe
|
||||
|
||||
import wavinfo
|
||||
|
||||
from .utils import all_files, ffprobe
|
||||
|
||||
|
||||
class TestWaveInfo(TestCase):
|
||||
def test_sanity(self):
|
||||
for wav_file in all_files():
|
||||
info = wavinfo.WavInfoReader(wav_file)
|
||||
self.assertEqual(info.__repr__(), 'WavInfoReader({}, latin_1, ascii)'.format(os.path.abspath(wav_file)))
|
||||
self.assertEqual(
|
||||
info.__repr__(),
|
||||
f"WavInfoReader({os.path.abspath(wav_file)}, latin_1, ascii)",
|
||||
)
|
||||
self.assertIsNotNone(info)
|
||||
|
||||
def test_fmt_against_ffprobe(self):
|
||||
@@ -24,23 +26,39 @@ class TestWaveInfo(TestCase):
|
||||
assert info.fmt is not None
|
||||
assert ffprobe_info is not None
|
||||
|
||||
self.assertEqual(info.fmt.channel_count, ffprobe_info['streams'][0]['channels'])
|
||||
self.assertEqual(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.channel_count, ffprobe_info["streams"][0]["channels"]
|
||||
)
|
||||
self.assertEqual(
|
||||
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"]),
|
||||
)
|
||||
|
||||
if info.fmt.audio_format == 1:
|
||||
self.assertTrue(ffprobe_info['streams'][0]['codec_name'].startswith('pcm'))
|
||||
streams = ffprobe_info['streams'][0]
|
||||
byte_rate = int(streams['sample_rate']) * streams['channels'] * int(streams['bits_per_sample']) / 8
|
||||
self.assertTrue(
|
||||
ffprobe_info["streams"][0]["codec_name"].startswith("pcm")
|
||||
)
|
||||
streams = ffprobe_info["streams"][0]
|
||||
byte_rate = (
|
||||
int(streams["sample_rate"])
|
||||
* streams["channels"]
|
||||
* int(streams["bits_per_sample"])
|
||||
/ 8
|
||||
)
|
||||
self.assertEqual(info.fmt.byte_rate, byte_rate)
|
||||
|
||||
def test_data_against_ffprobe(self):
|
||||
for wav_file in all_files():
|
||||
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 info.data is not None
|
||||
self.assertEqual(info.data.frame_count, int(ffprobe_info['streams'][0]['duration_ts']))
|
||||
self.assertEqual(
|
||||
info.data.frame_count, int(ffprobe_info["streams"][0]["duration_ts"])
|
||||
)
|
||||
|
||||
def test_bext_against_ffprobe(self):
|
||||
for wav_file in all_files():
|
||||
@@ -49,41 +67,78 @@ class TestWaveInfo(TestCase):
|
||||
assert ffprobe_info is not None
|
||||
|
||||
if info.bext:
|
||||
if 'comment' in ffprobe_info['format']['tags']:
|
||||
self.assertEqual(info.bext.description, ffprobe_info['format']['tags']['comment'])
|
||||
if "comment" in ffprobe_info["format"]["tags"]:
|
||||
self.assertEqual(
|
||||
info.bext.description, ffprobe_info["format"]["tags"]["comment"]
|
||||
)
|
||||
else:
|
||||
self.assertEqual(info.bext.description, '')
|
||||
self.assertEqual(info.bext.description, "")
|
||||
|
||||
if 'encoded_by' in ffprobe_info['format']['tags']:
|
||||
self.assertEqual(info.bext.originator, ffprobe_info['format']['tags']['encoded_by'])
|
||||
if "encoded_by" in ffprobe_info["format"]["tags"]:
|
||||
self.assertEqual(
|
||||
info.bext.originator,
|
||||
ffprobe_info["format"]["tags"]["encoded_by"],
|
||||
)
|
||||
else:
|
||||
self.assertEqual(info.bext.originator, '')
|
||||
self.assertEqual(info.bext.originator, "")
|
||||
|
||||
if 'originator_reference' in ffprobe_info['format']['tags']:
|
||||
self.assertEqual(info.bext.originator_ref, ffprobe_info['format']['tags']['originator_reference'])
|
||||
if "originator_reference" in ffprobe_info["format"]["tags"]:
|
||||
self.assertEqual(
|
||||
info.bext.originator_ref,
|
||||
ffprobe_info["format"]["tags"]["originator_reference"],
|
||||
)
|
||||
else:
|
||||
self.assertEqual(info.bext.originator_ref, '')
|
||||
self.assertEqual(info.bext.originator_ref, "")
|
||||
|
||||
# these don't always reflect the bext info
|
||||
# self.assertEqual(info.bext.originator_date, ffprobe_info['format']['tags']['date'])
|
||||
# self.assertEqual(info.bext.originator_time, ffprobe_info['format']['tags']['creation_time'])
|
||||
self.assertEqual(info.bext.time_reference, int(ffprobe_info['format']['tags']['time_reference']))
|
||||
# self.assertEqual(info.bext.originator_date,
|
||||
# ffprobe_info['format']['tags']['date'])
|
||||
# self.assertEqual(info.bext.originator_time,
|
||||
# ffprobe_info['format']['tags']['creation_time'])
|
||||
self.assertEqual(
|
||||
info.bext.time_reference,
|
||||
int(ffprobe_info["format"]["tags"]["time_reference"]),
|
||||
)
|
||||
|
||||
if 'coding_history' in ffprobe_info['format']['tags']:
|
||||
self.assertEqual(info.bext.coding_history, ffprobe_info['format']['tags']['coding_history'])
|
||||
if "coding_history" in ffprobe_info["format"]["tags"]:
|
||||
self.assertEqual(
|
||||
info.bext.coding_history,
|
||||
ffprobe_info["format"]["tags"]["coding_history"],
|
||||
)
|
||||
else:
|
||||
self.assertEqual(info.bext.coding_history, '')
|
||||
self.assertEqual(info.bext.coding_history, "")
|
||||
|
||||
def test_ixml(self):
|
||||
expected = {'A101_4.WAV': {'project': 'BMH', 'scene': 'A101', 'take': '4',
|
||||
'tape': '18Y12M31', 'family_uid': 'USSDVGR1112089007124015008231000'},
|
||||
'A101_3.WAV': {'project': 'BMH', 'scene': 'A101', 'take': '3',
|
||||
'tape': '18Y12M31', 'family_uid': 'USSDVGR1112089007124014008228300'},
|
||||
'A101_2.WAV': {'project': 'BMH', 'scene': 'A101', 'take': '2',
|
||||
'tape': '18Y12M31', 'family_uid': 'USSDVGR1112089007124004008218600'},
|
||||
'A101_1.WAV': {'project': 'BMH', 'scene': 'A101', 'take': '1',
|
||||
'tape': '18Y12M31', 'family_uid': 'USSDVGR1112089007124001008206300'},
|
||||
}
|
||||
expected = {
|
||||
"A101_4.WAV": {
|
||||
"project": "BMH",
|
||||
"scene": "A101",
|
||||
"take": "4",
|
||||
"tape": "18Y12M31",
|
||||
"family_uid": "USSDVGR1112089007124015008231000",
|
||||
},
|
||||
"A101_3.WAV": {
|
||||
"project": "BMH",
|
||||
"scene": "A101",
|
||||
"take": "3",
|
||||
"tape": "18Y12M31",
|
||||
"family_uid": "USSDVGR1112089007124014008228300",
|
||||
},
|
||||
"A101_2.WAV": {
|
||||
"project": "BMH",
|
||||
"scene": "A101",
|
||||
"take": "2",
|
||||
"tape": "18Y12M31",
|
||||
"family_uid": "USSDVGR1112089007124004008218600",
|
||||
},
|
||||
"A101_1.WAV": {
|
||||
"project": "BMH",
|
||||
"scene": "A101",
|
||||
"take": "1",
|
||||
"tape": "18Y12M31",
|
||||
"family_uid": "USSDVGR1112089007124001008206300",
|
||||
},
|
||||
}
|
||||
|
||||
for wav_file in all_files():
|
||||
basename = os.path.basename(wav_file)
|
||||
@@ -91,28 +146,31 @@ class TestWaveInfo(TestCase):
|
||||
info = wavinfo.WavInfoReader(wav_file)
|
||||
e = expected[basename]
|
||||
self.assertIsNotNone(info.ixml)
|
||||
assert info.ixml is not None
|
||||
self.assertEqual(e['project'], info.ixml.project)
|
||||
self.assertEqual(e['scene'], info.ixml.scene)
|
||||
self.assertEqual(e['take'], info.ixml.take)
|
||||
self.assertEqual(e['tape'], info.ixml.tape)
|
||||
self.assertEqual(e['family_uid'], info.ixml.family_uid)
|
||||
assert info.ixml is not None
|
||||
self.assertEqual(e["project"], info.ixml.project)
|
||||
self.assertEqual(e["scene"], info.ixml.scene)
|
||||
self.assertEqual(e["take"], info.ixml.take)
|
||||
self.assertEqual(e["tape"], info.ixml.tape)
|
||||
self.assertEqual(e["family_uid"], info.ixml.family_uid)
|
||||
|
||||
for track in info.ixml.track_list:
|
||||
self.assertIsNotNone(track.channel_index)
|
||||
if basename == 'A101_4.WAV' and track.channel_index == '1':
|
||||
self.assertEqual(track.name, 'MKH516 A')
|
||||
if basename == "A101_4.WAV" and track.channel_index == "1":
|
||||
self.assertEqual(track.name, "MKH516 A")
|
||||
|
||||
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):
|
||||
info = wavinfo.WavInfoReader(file)
|
||||
assert info.ixml is not None
|
||||
assert info.ixml is not None
|
||||
self.assertIsNotNone(info.ixml.steinberg)
|
||||
assert info.ixml.steinberg is not None
|
||||
self.assertIsNotNone(info.ixml.steinberg.audio_speaker_arrangement)
|
||||
self.assertEqual(info.ixml.steinberg.sample_format_size, 3)
|
||||
self.assertEqual(info.ixml.steinberg.media_company, "https://github.com/iluvcapra/wavinfo")
|
||||
self.assertEqual(info.ixml.steinberg.sample_format_size, 3)
|
||||
self.assertEqual(
|
||||
info.ixml.steinberg.media_company,
|
||||
"https://github.com/iluvcapra/wavinfo",
|
||||
)
|
||||
self.assertFalse(info.ixml.steinberg.media_drop_frames)
|
||||
self.assertEqual(info.ixml.steinberg.media_duration, 1200.0)
|
||||
|
||||
@@ -124,22 +182,21 @@ class TestWaveInfo(TestCase):
|
||||
self.assertIsNone(info.ixml.steinberg)
|
||||
|
||||
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))
|
||||
info = wavinfo.WavInfoReader(file_with_metadata).info
|
||||
|
||||
assert info is not None
|
||||
self.assertEqual(info.title, 'camera bumb 1')
|
||||
self.assertEqual(info.artist, 'Jamie Hardt')
|
||||
self.assertEqual(info.copyright, '© 2010 Jamie Hardt')
|
||||
self.assertEqual(info.product, 'Test Sounds') # album
|
||||
self.assertEqual(info.title, "camera bumb 1")
|
||||
self.assertEqual(info.artist, "Jamie Hardt")
|
||||
self.assertEqual(info.copyright, "© 2010 Jamie Hardt")
|
||||
self.assertEqual(info.product, "Test Sounds") # album
|
||||
self.assertEqual(info.album, info.product)
|
||||
self.assertEqual(info.comment, 'Comments')
|
||||
self.assertEqual(info.software, 'Sound Grinder Pro')
|
||||
self.assertEqual(info.created_date, '2010-12-28')
|
||||
self.assertEqual(info.engineer, 'JPH')
|
||||
self.assertEqual(info.keywords, 'Sound Effect, movement, microphone, bump')
|
||||
self.assertEqual(info.title, 'camera bumb 1')
|
||||
self.assertEqual(info.comment, "Comments")
|
||||
self.assertEqual(info.software, "Sound Grinder Pro")
|
||||
self.assertEqual(info.created_date, "2010-12-28")
|
||||
self.assertEqual(info.engineer, "JPH")
|
||||
self.assertEqual(info.keywords, "Sound Effect, movement, microphone, bump")
|
||||
self.assertEqual(info.title, "camera bumb 1")
|
||||
self.assertEqual(type(info.to_dict()), dict)
|
||||
self.assertEqual(type(info.__repr__()), str)
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import os.path
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
from subprocess import PIPE
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
import wavinfo
|
||||
# import wavinfo
|
||||
|
||||
|
||||
class TestZoomF8(TestCase):
|
||||
|
||||
+11
-24
@@ -1,36 +1,23 @@
|
||||
import os.path
|
||||
import sys
|
||||
import subprocess
|
||||
from subprocess import PIPE
|
||||
import json
|
||||
import os.path
|
||||
import subprocess
|
||||
|
||||
FFPROBE = 'ffprobe'
|
||||
FFPROBE = "ffprobe"
|
||||
|
||||
|
||||
def ffprobe(path):
|
||||
arguments = [FFPROBE, "-of", "json", "-show_format", "-show_streams", path]
|
||||
if int(sys.version[0]) < 3:
|
||||
process = subprocess.Popen(arguments, stdout=PIPE)
|
||||
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:
|
||||
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
|
||||
process = subprocess.run(arguments, check=True, capture_output=True)
|
||||
if process.returncode == 0:
|
||||
output_str = process.stdout.decode("utf-8")
|
||||
return json.loads(output_str)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
_, ext = os.path.splitext(filename)
|
||||
if ext in ['.wav', '.WAV']:
|
||||
if ext in [".wav", ".WAV"]:
|
||||
yield os.path.join(dirpath, filename)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
from optparse import OptionParser, OptionGroup
|
||||
import datetime
|
||||
from . import WavInfoReader
|
||||
from . import __version__
|
||||
import sys
|
||||
import json
|
||||
from enum import Enum
|
||||
|
||||
class MyJSONEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
if isinstance(o, Enum):
|
||||
return o._name_
|
||||
else:
|
||||
return super().default(o)
|
||||
|
||||
class MissingDataError(RuntimeError):
|
||||
pass
|
||||
|
||||
def main():
|
||||
parser = OptionParser()
|
||||
|
||||
parser.usage = 'wavinfo (--adm | --ixml) <FILE> +'
|
||||
|
||||
# parser.add_option('-f', dest='output_format', help='Set the output format',
|
||||
# default='json',
|
||||
# metavar='FORMAT')
|
||||
|
||||
parser.add_option('--adm', dest='adm', help='Output ADM XML',
|
||||
default=False, action='store_true')
|
||||
|
||||
parser.add_option('--ixml', dest='ixml', help='Output iXML',
|
||||
default=False, action='store_true')
|
||||
|
||||
(options, args) = parser.parse_args(sys.argv)
|
||||
for arg in args[1:]:
|
||||
try:
|
||||
this_file = WavInfoReader(path=arg)
|
||||
if options.adm:
|
||||
if this_file.adm:
|
||||
sys.stdout.write(this_file.adm.xml_str())
|
||||
else:
|
||||
raise MissingDataError("adm")
|
||||
elif options.ixml:
|
||||
if this_file.ixml:
|
||||
sys.stdout.write(this_file.ixml.xml_str())
|
||||
else:
|
||||
raise MissingDataError("ixml")
|
||||
else:
|
||||
ret_dict = {
|
||||
'filename': arg,
|
||||
'run_date': datetime.datetime.now().isoformat() ,
|
||||
'application': "wavinfo " + __version__,
|
||||
'scopes': {}
|
||||
}
|
||||
for scope, name, value in this_file.walk():
|
||||
if scope not in ret_dict['scopes'].keys():
|
||||
ret_dict['scopes'][scope] = {}
|
||||
|
||||
ret_dict['scopes'][scope][name] = value
|
||||
|
||||
json.dump(ret_dict, cls=MyJSONEncoder, fp=sys.stdout, indent=2)
|
||||
except MissingDataError as e:
|
||||
print("MissingDataError: Missing metadata (%s) in file %s" % (e, arg), file=sys.stderr)
|
||||
continue
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,42 +0,0 @@
|
||||
import struct
|
||||
from collections import namedtuple
|
||||
|
||||
from . import riff_parser
|
||||
|
||||
RF64Context = namedtuple('RF64Context','sample_count bigchunk_table')
|
||||
|
||||
|
||||
def parse_rf64(stream, signature = b'RF64') -> RF64Context:
|
||||
start = stream.tell()
|
||||
assert( stream.read(4) == b'WAVE' )
|
||||
|
||||
ds64_chunk = riff_parser.parse_chunk(stream)
|
||||
assert type(ds64_chunk) is riff_parser.ChunkDescriptor, \
|
||||
f"Expected ds64 chunk here, found {type(ds64_chunk)}"
|
||||
|
||||
ds64_field_spec = "<QQQI"
|
||||
ds64_fields_size = struct.calcsize(ds64_field_spec)
|
||||
assert(ds64_chunk.ident == b'ds64')
|
||||
|
||||
ds64_data = ds64_chunk.read_data(stream)
|
||||
assert(len(ds64_data) >= ds64_fields_size)
|
||||
|
||||
riff_size, data_size, sample_count, length_lookup_table = struct.unpack(
|
||||
ds64_field_spec, ds64_data[0:ds64_fields_size])
|
||||
|
||||
bigchunk_table = {}
|
||||
chunksize64format = "<4sL"
|
||||
# chunksize64size = struct.calcsize(chunksize64format)
|
||||
|
||||
for _ in range(length_lookup_table):
|
||||
bigname, bigsize = struct.unpack_from(chunksize64format, ds64_data,
|
||||
offset= ds64_fields_size)
|
||||
bigchunk_table[bigname] = bigsize
|
||||
|
||||
bigchunk_table[b'data'] = data_size
|
||||
bigchunk_table[signature] = riff_size
|
||||
|
||||
stream.seek(start, 0)
|
||||
return RF64Context( sample_count=sample_count,
|
||||
bigchunk_table=bigchunk_table)
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# from functools import reduce
|
||||
|
||||
|
||||
# def binary_to_string(binary_value):
|
||||
# return reduce(lambda val, el: val + "{:02x}".format(el), binary_value, '')
|
||||
|
||||
|
||||
# class UMIDParser:
|
||||
# """
|
||||
# Parse a raw binary SMPTE 330M Universal Materials Identifier
|
||||
#
|
||||
# This implementation is based on SMPTE ST 330:2011
|
||||
# """
|
||||
# def __init__(self, raw_umid: bytes):
|
||||
# self.raw_umid = raw_umid
|
||||
#
|
||||
# @property
|
||||
# def universal_label(self) -> bytearray:
|
||||
# return self.raw_umid[0:12]
|
||||
#
|
||||
# @property
|
||||
# def basic_umid(self):
|
||||
# return self.raw_umid[0:32]
|
||||
|
||||
# def basic_umid_to_str(self):
|
||||
# return binary_to_string(self.raw_umid[0:32])
|
||||
#
|
||||
# @property
|
||||
# def universal_label_is_valid(self) -> bool:
|
||||
# valid_preamble = b'\x06\x0a\x2b\x34\x01\x01\x01\x05\x01\x01'
|
||||
# return self.universal_label[0:len(valid_preamble)] == valid_preamble
|
||||
#
|
||||
# @property
|
||||
# def material_type(self) -> str:
|
||||
# material_byte = self.raw_umid[10]
|
||||
# if material_byte == 0x1:
|
||||
# return 'picture'
|
||||
# elif material_byte == 0x2:
|
||||
# return 'audio'
|
||||
# elif material_byte == 0x3:
|
||||
# return 'data'
|
||||
# elif material_byte == 0x4:
|
||||
# return 'other'
|
||||
# elif material_byte == 0x5:
|
||||
# return 'picture_single_component'
|
||||
# elif material_byte == 0x6:
|
||||
# return 'picture_multiple_component'
|
||||
# elif material_byte == 0x7:
|
||||
# return 'audio_single_component'
|
||||
# elif material_byte == 0x9:
|
||||
# return 'audio_multiple_component'
|
||||
# elif material_byte == 0xb:
|
||||
# return 'auxiliary_single_component'
|
||||
# elif material_byte == 0xc:
|
||||
# return 'auxiliary_multiple_component'
|
||||
# elif material_byte == 0xd:
|
||||
# return 'mixed_components'
|
||||
# elif material_byte == 0xf:
|
||||
# return 'not_identified'
|
||||
# else:
|
||||
# return 'not_recognized'
|
||||
#
|
||||
# @property
|
||||
# def material_number_creation_method(self) -> str:
|
||||
# method_byte = self.raw_umid[11]
|
||||
# method_byte = (method_byte << 4) & 0xf
|
||||
# if method_byte == 0x0:
|
||||
# return 'undefined'
|
||||
# elif method_byte == 0x1:
|
||||
# return 'smpte'
|
||||
# elif method_byte == 0x2:
|
||||
# return 'uuid'
|
||||
# elif method_byte == 0x3:
|
||||
# return 'masked'
|
||||
# elif method_byte == 0x4:
|
||||
# return 'ieee1394'
|
||||
# elif 0x5 <= method_byte <= 0x7:
|
||||
# return 'reserved_undefined'
|
||||
# else:
|
||||
# return 'unrecognized'
|
||||
#
|
||||
# @property
|
||||
# def instance_number_creation_method(self) -> str:
|
||||
# method_byte = self.raw_umid[11]
|
||||
# method_byte = method_byte & 0xf
|
||||
# if method_byte == 0x0:
|
||||
# return 'undefined'
|
||||
# elif method_byte == 0x01:
|
||||
# return 'local_registration'
|
||||
# elif method_byte == 0x02:
|
||||
# return '24_bit_prs'
|
||||
# elif method_byte == 0x03:
|
||||
# return 'copy_number_and_16_bit_prs'
|
||||
# elif 0x04 <= method_byte <= 0x0e:
|
||||
# return 'reserved_undefined'
|
||||
# elif method_byte == 0x0f:
|
||||
# return 'live_stream'
|
||||
# else:
|
||||
# return 'unrecognized'
|
||||
#
|
||||
# @property
|
||||
# def indicated_length(self) -> str:
|
||||
# if self.raw_umid[12] == 0x13:
|
||||
# return 'basic'
|
||||
# elif self.raw_umid[12] == 0x33:
|
||||
# return 'extended'
|
||||
#
|
||||
# @property
|
||||
# def instance_number(self) -> bytearray:
|
||||
# return self.raw_umid[13:3]
|
||||
#
|
||||
# @property
|
||||
# def material_number(self) -> bytearray:
|
||||
# return self.raw_umid[16:16]
|
||||
#
|
||||
# @property
|
||||
# def source_pack(self) -> Union[bytearray, None]:
|
||||
# if self.indicated_length == 'extended':
|
||||
# return self.raw_umid[32:32]
|
||||
# else:
|
||||
# return None
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
ADM Reader
|
||||
"""
|
||||
|
||||
from struct import unpack, unpack_from, calcsize
|
||||
from io import BytesIO
|
||||
from collections import namedtuple
|
||||
from typing import Iterable, Tuple
|
||||
|
||||
from lxml import etree as ET
|
||||
|
||||
ChannelEntry = namedtuple('ChannelEntry', "track_index uid track_ref pack_ref")
|
||||
|
||||
class WavADMReader:
|
||||
"""
|
||||
Reads XML data from an EBU ADM (Audio Definiton Model) WAV File.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, axml_data: bytes, chna_data: bytes):
|
||||
header_fmt = "<HH"
|
||||
uid_fmt = "<H12s14s11sx"
|
||||
|
||||
#: An :mod:`lxml.etree` of the ADM XML document
|
||||
self.axml = ET.parse(BytesIO(axml_data))
|
||||
|
||||
_, uid_count = unpack(header_fmt, chna_data[0:4])
|
||||
|
||||
#: A list of :class:`ChannelEntry` objects parsed from the
|
||||
#: `chna` metadata chunk.
|
||||
#:
|
||||
#: .. note::
|
||||
#: In-file, the `chna` track indexes start at 1. However, this interface
|
||||
#: numbers the first track 0, in order to maintain consistency with other
|
||||
#: libraries.
|
||||
self.channel_uids = []
|
||||
|
||||
offset = calcsize(header_fmt)
|
||||
for _ in range(uid_count):
|
||||
|
||||
track_index, uid, track_ref, pack_ref = unpack_from(uid_fmt, chna_data, offset)
|
||||
|
||||
# these values are either ascii or all null
|
||||
|
||||
self.channel_uids.append(ChannelEntry(track_index - 1,
|
||||
uid.decode('ascii') , track_ref.decode('ascii'), pack_ref.decode('ascii')))
|
||||
|
||||
offset += calcsize(uid_fmt)
|
||||
|
||||
def xml_str(self) -> str:
|
||||
"""ADM XML as a string"""
|
||||
return ET.tostring(self.axml).decode("utf-8")
|
||||
|
||||
def programme(self) -> dict:
|
||||
"""
|
||||
Read the ADM `audioProgramme` data structure and some of its reference properties.
|
||||
"""
|
||||
ret_dict = dict()
|
||||
|
||||
nsmap = self.axml.getroot().nsmap
|
||||
|
||||
afext = self.axml.find(".//audioFormatExtended", namespaces=nsmap)
|
||||
|
||||
program = afext.find("audioProgramme", namespaces=nsmap)
|
||||
ret_dict['programme_id'] = program.get("audioProgrammeID")
|
||||
ret_dict['programme_name'] = program.get("audioProgrammeName")
|
||||
ret_dict['programme_start'] = program.get("start")
|
||||
ret_dict['programme_end'] = program.get("end")
|
||||
ret_dict['contents'] = []
|
||||
|
||||
for content_ref in program.findall("audioContentIDRef", namespaces=nsmap):
|
||||
content_dict = dict()
|
||||
content_dict['content_id'] = cid = content_ref.text
|
||||
content = afext.find("audioContent[@audioContentID='%s']" % cid, namespaces=nsmap)
|
||||
content_dict['content_name'] = content.get("audioContentName")
|
||||
content_dict['objects'] = []
|
||||
|
||||
for object_ref in content.findall("audioObjectIDRef", namespaces=nsmap):
|
||||
object_dict = dict()
|
||||
object_dict['object_id'] = oid = object_ref.text
|
||||
object = afext.find("audioObject[@audioObjectID='%s']" % oid, namespaces=nsmap)
|
||||
pack = object.find("audioPackFormatIDRef", namespaces=nsmap)
|
||||
object_dict['object_name'] = object.get("audioObjectName")
|
||||
object_dict['object_start'] = object.get("start")
|
||||
object_dict['object_duration'] = object.get("duration")
|
||||
object_dict['pack_id'] = pack.text
|
||||
track_uid_list = []
|
||||
for t in object.findall("audioTrackUIDRef", namespaces=nsmap):
|
||||
track_uid_list.append(t.text)
|
||||
|
||||
object_dict['track_uids'] = track_uid_list
|
||||
content_dict['objects'].append(object_dict)
|
||||
|
||||
ret_dict['contents'].append(content_dict)
|
||||
|
||||
return ret_dict
|
||||
|
||||
def track_info(self, index) -> dict:
|
||||
"""
|
||||
Information about a track in the WAV file.
|
||||
|
||||
:param index: index of audio track (indexed from zero)
|
||||
:returns: a dictionary with *content_name*, *content_id*, *object_name*, *object_id*,
|
||||
*pack_format_name*, *pack_type*, *channel_format_name*
|
||||
"""
|
||||
channel_info = next((x for x in self.channel_uids if x.track_index == index), None)
|
||||
|
||||
if channel_info is None:
|
||||
return None
|
||||
|
||||
ret_dict = {}
|
||||
|
||||
nsmap = self.axml.getroot().nsmap
|
||||
|
||||
afext = self.axml.find(".//audioFormatExtended", namespaces=nsmap)
|
||||
|
||||
trackformat_elem = afext.find("audioTrackFormat[@audioTrackFormatID='%s']" % channel_info.track_ref,
|
||||
namespaces=nsmap)
|
||||
|
||||
stream_id = trackformat_elem[0].text
|
||||
|
||||
channelformatref_elem = afext.find("audioStreamFormat[@audioStreamFormatID='%s']/audioChannelFormatIDRef" % stream_id,
|
||||
namespaces=nsmap)
|
||||
channelformat_id = channelformatref_elem.text
|
||||
|
||||
packformatref_elem = afext.find("audioStreamFormat[@audioStreamFormatID='%s']/audioPackFormatIDRef" % stream_id,
|
||||
namespaces=nsmap)
|
||||
packformat_id = packformatref_elem.text
|
||||
|
||||
channelformat_elem = afext.find("audioChannelFormat[@audioChannelFormatID='%s']" % channelformat_id,
|
||||
namespaces=nsmap)
|
||||
ret_dict['channel_format_name'] = channelformat_elem.get("audioChannelFormatName")
|
||||
|
||||
packformat_elem = afext.find("audioPackFormat[@audioPackFormatID='%s']" % packformat_id,
|
||||
namespaces=nsmap)
|
||||
ret_dict['pack_type'] = packformat_elem.get("typeDefinition")
|
||||
ret_dict['pack_format_name'] = packformat_elem.get("audioPackFormatName")
|
||||
|
||||
object_elem = afext.find("audioObject[audioPackFormatIDRef = '%s']" % packformat_id,
|
||||
namespaces=nsmap)
|
||||
|
||||
ret_dict['audio_object_name'] = object_elem.get("audioObjectName")
|
||||
object_id = object_elem.get("audioObjectID")
|
||||
ret_dict['object_id'] = object_id
|
||||
|
||||
content_elem = afext.find("audioContent/[audioObjectIDRef = '%s']" % object_id,
|
||||
namespaces=nsmap)
|
||||
|
||||
ret_dict['content_name'] = content_elem.get("audioContentName")
|
||||
ret_dict['content_id'] = content_elem.get("audioContentID")
|
||||
|
||||
return ret_dict
|
||||
|
||||
def to_dict(self) -> dict: #FIXME should be "asdict"
|
||||
"""
|
||||
Get ADM metadata as a dictionary.
|
||||
"""
|
||||
|
||||
def make_entry(channel_uid_rec):
|
||||
rd = channel_uid_rec._asdict()
|
||||
rd.update(self.track_info(channel_uid_rec.track_index))
|
||||
return rd
|
||||
|
||||
return dict(channel_entries=list(map(lambda z: make_entry(z), self.channel_uids)),
|
||||
programme=self.programme())
|
||||
@@ -1,104 +0,0 @@
|
||||
import struct
|
||||
# from .umid_parser import UMIDParser
|
||||
|
||||
from typing import Optional
|
||||
|
||||
class WavBextReader:
|
||||
def __init__(self, bext_data, encoding):
|
||||
"""
|
||||
Read Broadcast-WAV extended metadata.
|
||||
:param bext_data: The bytes-like data.
|
||||
:param encoding: The encoding to use when decoding the text fields of the
|
||||
BEXT metadata scope. According to EBU Rec 3285 this shall be ASCII.
|
||||
"""
|
||||
packstring = "<256s" + "32s" + "32s" + "10s" + "8s" + "QH" + "64s" + "hhhhh" + "180s"
|
||||
|
||||
rest_starts = struct.calcsize(packstring)
|
||||
unpacked = struct.unpack(packstring, bext_data[:rest_starts])
|
||||
|
||||
def sanitize_bytes(b : bytes) -> str:
|
||||
# honestly can't remember why I'm stripping nulls this way
|
||||
first_null = next((index for index, byte in enumerate(b) if byte == 0), None)
|
||||
trimmed = b if first_null is None else b[:first_null]
|
||||
decoded = trimmed.decode(encoding)
|
||||
return decoded
|
||||
|
||||
#: Description. A free-text field up to 256 characters long.
|
||||
self.description : str = sanitize_bytes(unpacked[0])
|
||||
|
||||
#: Originator. Usually the name of the encoding application, sometimes
|
||||
#: an artist name.
|
||||
self.originator : str = sanitize_bytes(unpacked[1])
|
||||
|
||||
#: A unique identifier for the file, a serial number.
|
||||
self.originator_ref : str = sanitize_bytes(unpacked[2])
|
||||
|
||||
#: Date of the recording, in the format YYYY-MM-DD.
|
||||
self.originator_date : str = sanitize_bytes(unpacked[3])
|
||||
|
||||
#: Time of the recording, in the format HH:MM:SS.
|
||||
self.originator_time : str = sanitize_bytes(unpacked[4])
|
||||
|
||||
#: The sample offset of the start, usually relative
|
||||
#: to midnight.
|
||||
self.time_reference : int = unpacked[5]
|
||||
|
||||
#: A variable-length text field containing a list of processes and
|
||||
#: and conversions performed on the file.
|
||||
self.coding_history : str = sanitize_bytes(bext_data[rest_starts:])
|
||||
|
||||
#: BEXT version.
|
||||
self.version : int = unpacked[6]
|
||||
|
||||
#: SMPTE 330M UMID of this audio file, 64 bytes are allocated though the UMID
|
||||
#: may only be 32 bytes long.
|
||||
self.umid : Optional[bytes] = None
|
||||
|
||||
#: EBU R128 Integrated loudness, in LUFS.
|
||||
self.loudness_value : Optional[float] = None
|
||||
|
||||
#: EBU R128 Loudness range, in LUFS.
|
||||
self.loudness_range : Optional[float] = None
|
||||
|
||||
#: True peak level, in dBFS TP
|
||||
self.max_true_peak : Optional[float] = None
|
||||
|
||||
#: EBU R128 Maximum momentary loudness, in LUFS
|
||||
self.max_momentary_loudness : Optional[float] = None
|
||||
|
||||
#: EBU R128 Maximum short-term loudness, in LUFS.
|
||||
self.max_shortterm_loudness : Optional[float] = None
|
||||
|
||||
if self.version > 0:
|
||||
self.umid = unpacked[7]
|
||||
|
||||
if self.version > 1:
|
||||
self.loudness_value = unpacked[8] / 100.0
|
||||
self.loudness_range = unpacked[9] / 100.0
|
||||
self.max_true_peak = unpacked[10] / 100.0
|
||||
self.max_momentary_loudness = unpacked[11] / 100.0
|
||||
self.max_shortterm_loudness = unpacked[12] / 100.0
|
||||
|
||||
def to_dict(self):
|
||||
# if self.umid is not None:
|
||||
# umid_parsed = UMIDParser(self.umid)
|
||||
# umid_str = umid_parsed.basic_umid_to_str()
|
||||
# else:
|
||||
|
||||
umid_str = None
|
||||
|
||||
return {'description': self.description,
|
||||
'originator': self.originator,
|
||||
'originator_ref': self.originator_ref,
|
||||
'originator_date': self.originator_date,
|
||||
'originator_time': self.originator_time,
|
||||
'time_reference': self.time_reference,
|
||||
'version': self.version,
|
||||
'umid': umid_str,
|
||||
'coding_history': self.coding_history,
|
||||
'loudness_value': self.loudness_value,
|
||||
'loudness_range': self.loudness_range,
|
||||
'max_true_peak': self.max_true_peak,
|
||||
'max_momentary_loudness': self.max_momentary_loudness,
|
||||
'max_shortterm_loudness': self.max_shortterm_loudness
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
from .riff_parser import parse_chunk, ListChunkDescriptor
|
||||
|
||||
from typing import Optional
|
||||
|
||||
class WavInfoChunkReader:
|
||||
|
||||
def __init__(self, f, encoding):
|
||||
self.encoding = encoding
|
||||
|
||||
f.seek(0)
|
||||
parsed_chunks = parse_chunk(f)
|
||||
|
||||
list_chunks = [chunk for chunk in parsed_chunks.children if type(chunk) is ListChunkDescriptor]
|
||||
|
||||
self.info_chunk = next((chunk for chunk in list_chunks if chunk.signature == b'INFO'), None)
|
||||
|
||||
#: 'ICOP' Copyright
|
||||
self.copyright : Optional[str] = self._get_field(f, b'ICOP')
|
||||
#: 'IPRD' Product
|
||||
self.product : Optional[str]= self._get_field(f, b'IPRD')
|
||||
self.album : Optional[str] = self.product
|
||||
#: 'IGNR' Genre
|
||||
self.genre : Optional[str] = self._get_field(f, b'IGNR')
|
||||
#: 'ISBJ' Subject
|
||||
self.subject : Optional[str] = self._get_field(f, b'ISBJ')
|
||||
#: 'IART' Artist, composer, author
|
||||
self.artist : Optional[str] = self._get_field(f, b'IART')
|
||||
#: 'ICMT' Comment
|
||||
self.comment : Optional[str] = self._get_field(f, b'ICMT')
|
||||
#: 'ISFT' Software, encoding application
|
||||
self.software : Optional[str] = self._get_field(f, b'ISFT')
|
||||
#: 'ICRD' Created date
|
||||
self.created_date : Optional[str] = self._get_field(f, b'ICRD')
|
||||
#: 'IENG' Engineer
|
||||
self.engineer : Optional[str] = self._get_field(f, b'IENG')
|
||||
#: 'ITCH' Technician
|
||||
self.technician : Optional[str] = self._get_field(f, b'ITCH')
|
||||
#: 'IKEY' Keywords, keyword list
|
||||
self.keywords : Optional[str] = self._get_field(f, b'IKEY')
|
||||
#: 'INAM' Name, title
|
||||
self.title : Optional[str] = self._get_field(f, b'INAM')
|
||||
#: 'ISRC' Source
|
||||
self.source : Optional[str] = self._get_field(f, b'ISRC')
|
||||
#: 'TAPE' Tape
|
||||
self.tape : Optional[str] = self._get_field(f, b'TAPE')
|
||||
#: 'IARL' Archival Location
|
||||
self.archival_location : Optional[str] = self._get_field(f, b'IARL')
|
||||
#: 'ICSM' Commissioned
|
||||
self.commissioned : Optional[str] = self._get_field(f, b'ICMS')
|
||||
|
||||
def _get_field(self, f, field_ident) -> Optional[str]:
|
||||
search = next(((chunk.start, chunk.length) for chunk in self.info_chunk.children if chunk.ident == field_ident),
|
||||
None)
|
||||
|
||||
if search is not None:
|
||||
f.seek(search[0])
|
||||
data = f.read(search[1])
|
||||
return data.decode(self.encoding).rstrip('\0')
|
||||
else:
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict: #FIXME should be asdict
|
||||
"""
|
||||
A dictionary with all of the key/values read from the INFO scope.
|
||||
"""
|
||||
return {'copyright': self.copyright,
|
||||
'product': self.product,
|
||||
'album': self.album,
|
||||
'genre': self.genre,
|
||||
'artist': self.artist,
|
||||
'comment': self.comment,
|
||||
'software': self.software,
|
||||
'created_date': self.created_date,
|
||||
'engineer': self.engineer,
|
||||
'keywords': self.keywords,
|
||||
'title': self.title,
|
||||
'source': self.source,
|
||||
'tape': self.tape,
|
||||
'commissioned': self.commissioned,
|
||||
'archival_location': self.archival_location,
|
||||
'subject': self.subject,
|
||||
'technician': self.technician
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return_val = self.to_dict()
|
||||
return_val.update({'encoding': self.encoding})
|
||||
return str(return_val)
|
||||
@@ -1,219 +0,0 @@
|
||||
#-*- coding: utf-8 -*-
|
||||
import struct
|
||||
import os
|
||||
from collections import namedtuple
|
||||
|
||||
from typing import Optional, Generator, Any
|
||||
|
||||
import pathlib
|
||||
|
||||
from .riff_parser import parse_chunk, ChunkDescriptor, ListChunkDescriptor
|
||||
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
|
||||
|
||||
#: Calculated statistics about the audio data.
|
||||
WavDataDescriptor = namedtuple('WavDataDescriptor', 'byte_count frame_count')
|
||||
|
||||
#: The format of the audio samples.
|
||||
WavAudioFormat = namedtuple('WavAudioFormat',
|
||||
['audio_format', 'channel_count', 'sample_rate',
|
||||
'byte_rate', 'block_align', 'bits_per_sample'])
|
||||
|
||||
|
||||
class WavInfoReader:
|
||||
"""
|
||||
Parse a WAV audio file for metadata.
|
||||
"""
|
||||
|
||||
def __init__(self, path, info_encoding='latin_1', bext_encoding='ascii'):
|
||||
"""
|
||||
Create a new reader object.
|
||||
|
||||
:param path:
|
||||
A pathlike object or IO to the wav file you wish to probe or a
|
||||
file handle to an open file.
|
||||
|
||||
:param info_encoding:
|
||||
The text encoding of the ``INFO``, ``LABL`` and other RIFF-defined
|
||||
metadata fields.
|
||||
|
||||
:param bext_encoding:
|
||||
The text encoding to use when decoding the string
|
||||
fields of the Broadcast-WAV extension. Per EBU 3285 this is ASCII
|
||||
but this parameter is available to you if you encounter a weirdo.
|
||||
"""
|
||||
|
||||
self.info_encoding = info_encoding
|
||||
self.bext_encoding = bext_encoding
|
||||
|
||||
#: Wave audio data format.
|
||||
self.fmt :Optional[WavAudioFormat] = None
|
||||
|
||||
#: Statistics of the `data` section.
|
||||
self.data :Optional[WavDataDescriptor] = None
|
||||
|
||||
#: Broadcast-Wave metadata.
|
||||
self.bext :Optional[WavBextReader] = None
|
||||
|
||||
#: iXML metadata.
|
||||
self.ixml :Optional[WavIXMLFormat] = None
|
||||
|
||||
#: ADM Audio Definiton Model metadata.
|
||||
self.adm :Optional[WavADMReader]= None
|
||||
|
||||
#: Dolby bitstream metadata.
|
||||
self.dolby :Optional[WavDolbyMetadataReader] = None
|
||||
|
||||
#: RIFF INFO metadata.
|
||||
self.info :Optional[WavInfoChunkReader]= None
|
||||
|
||||
#: RIFF cues markers, labels, and notes.
|
||||
self.cues :Optional[WavCuesReader] = None
|
||||
|
||||
if hasattr(path, 'read'):
|
||||
self.get_wav_info(path)
|
||||
self.url = 'about:blank'
|
||||
self.path = repr(path)
|
||||
|
||||
else:
|
||||
absolute_path = os.path.abspath(path)
|
||||
|
||||
#: `file://` url for the file.
|
||||
self.url: str = pathlib.Path(absolute_path).as_uri()
|
||||
|
||||
self.path = absolute_path
|
||||
|
||||
with open(path, 'rb') as f:
|
||||
self.get_wav_info(f)
|
||||
|
||||
def get_wav_info(self, wavfile):
|
||||
chunks = parse_chunk(wavfile)
|
||||
assert type(chunks) is ListChunkDescriptor
|
||||
|
||||
self.main_list = chunks.children
|
||||
wavfile.seek(0)
|
||||
|
||||
self.fmt = self._get_format(wavfile)
|
||||
self.bext = self._get_bext(wavfile, encoding=self.bext_encoding)
|
||||
self.ixml = self._get_ixml(wavfile)
|
||||
self.adm = self._get_adm(wavfile)
|
||||
self.info = self._get_info(wavfile, encoding=self.info_encoding)
|
||||
self.dolby = self._get_dbmd(wavfile)
|
||||
self.cues = self._get_cue(wavfile)
|
||||
self.data = self._describe_data()
|
||||
|
||||
def _find_chunk_data(self, ident, from_stream, default_none=False) -> Optional[bytes]:
|
||||
top_chunks = (chunk for chunk in self.main_list \
|
||||
if type(chunk) is ChunkDescriptor and chunk.ident == ident)
|
||||
|
||||
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
|
||||
|
||||
def _find_list_chunk(self, signature) -> Optional[ListChunkDescriptor]:
|
||||
top_chunks = (chunk for chunk in self.main_list \
|
||||
if type(chunk) is ListChunkDescriptor and \
|
||||
chunk.signature == signature)
|
||||
|
||||
return next(top_chunks, None)
|
||||
|
||||
def _describe_data(self):
|
||||
data_chunk = next(c for c in self.main_list \
|
||||
if type(c) is ChunkDescriptor and c.ident == b'data')
|
||||
|
||||
assert isinstance(self.fmt, WavAudioFormat)
|
||||
return WavDataDescriptor(
|
||||
byte_count=data_chunk.length,
|
||||
frame_count=int(data_chunk.length / self.fmt.block_align))
|
||||
|
||||
def _get_format(self, 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"
|
||||
|
||||
packstring = "<HHIIHH"
|
||||
rest_starts = struct.calcsize(packstring)
|
||||
|
||||
unpacked = struct.unpack(packstring, fmt_data[:rest_starts])
|
||||
|
||||
return WavAudioFormat(audio_format=unpacked[0],
|
||||
channel_count=unpacked[1],
|
||||
sample_rate=unpacked[2],
|
||||
byte_rate=unpacked[3],
|
||||
block_align=unpacked[4],
|
||||
bits_per_sample=unpacked[5]
|
||||
)
|
||||
|
||||
def _get_info(self, f, encoding):
|
||||
finder = (chunk.signature for chunk in self.main_list \
|
||||
if type(chunk) is ListChunkDescriptor)
|
||||
|
||||
if b'INFO' in finder:
|
||||
return WavInfoChunkReader(f, encoding)
|
||||
|
||||
def _get_bext(self, f, encoding):
|
||||
bext_data = self._find_chunk_data(b'bext', f, default_none=True)
|
||||
return WavBextReader(bext_data, encoding) if bext_data else None
|
||||
|
||||
def _get_adm(self, f):
|
||||
axml = self._find_chunk_data(b'axml', 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
|
||||
|
||||
def _get_dbmd(self, f):
|
||||
dbmd_data = self._find_chunk_data(b'dbmd', f, default_none=True)
|
||||
return WavDolbyMetadataReader(dbmd_data=dbmd_data) \
|
||||
if dbmd_data else None
|
||||
|
||||
def _get_ixml(self, f):
|
||||
ixml_data = self._find_chunk_data(b'iXML', f, default_none=True)
|
||||
return WavIXMLFormat(ixml_data.rstrip(b'\0')) if ixml_data else None
|
||||
|
||||
def _get_cue(self, f):
|
||||
cue = next((cue_chunk for cue_chunk in self.main_list if \
|
||||
type(cue_chunk) is ChunkDescriptor and \
|
||||
cue_chunk.ident == b'cue '), None)
|
||||
|
||||
adtl = self._find_list_chunk(b'adtl')
|
||||
labls = []
|
||||
ltxts = []
|
||||
notes = []
|
||||
if adtl is not None:
|
||||
labls = [c for c in adtl.children if c.ident == b'labl']
|
||||
ltxts = [c for c in adtl.children if c.ident == b'ltxt']
|
||||
notes = [c for c in adtl.children if c.ident == b'note']
|
||||
|
||||
return WavCuesReader.read_all(f, cue, labls, ltxts, notes,
|
||||
fallback_encoding=self.info_encoding)
|
||||
|
||||
def walk(self) -> Generator[str,str,Any]: #FIXME: this should probably be named "iter()"
|
||||
"""
|
||||
Walk all of the available metadata fields.
|
||||
|
||||
:yields: tuples of the *scope*, *key*, and *value* of
|
||||
each metadatum. The *scope* value will be one of
|
||||
"fmt", "data", "ixml", "bext", "info", "dolby", "cues" or "adm".
|
||||
"""
|
||||
|
||||
scopes = ('fmt', 'data', 'ixml', 'bext', 'info', 'adm', 'cues',
|
||||
'dolby')
|
||||
|
||||
for scope in scopes:
|
||||
if scope in ['fmt', 'data']:
|
||||
attr = self.__getattribute__(scope)
|
||||
for field in attr._fields:
|
||||
yield scope, field, attr.__getattribute__(field)
|
||||
|
||||
else:
|
||||
dict = self.__getattribute__(scope).to_dict() if self.__getattribute__(scope) else {}
|
||||
for key in dict.keys():
|
||||
yield scope, key, dict[key]
|
||||
|
||||
def __repr__(self):
|
||||
return 'WavInfoReader({}, {}, {})'.format(self.path, self.info_encoding, self.bext_encoding)
|
||||
@@ -1,34 +0,0 @@
|
||||
"""
|
||||
|
||||
"""
|
||||
|
||||
from optparse import OptionParser, OptionGroup
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
parser = OptionParser()
|
||||
|
||||
parser.usage = "wavfind [--scene=SCENE] [--take=TAKE] [--desc=DESC] <PATH> +"
|
||||
|
||||
primaries = OptionGroup(parser, title="Search Predicates",
|
||||
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("--take",
|
||||
help='Search for this take',
|
||||
metavar='TAKE')
|
||||
|
||||
primaries.add_option("--desc",
|
||||
help='Search descriptions',
|
||||
metavar='DESC')
|
||||
|
||||
|
||||
(options, args) = parser.parse_args(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user