21 Commits

Author SHA1 Message Date
Jamie Hardt
016e504f65 Merge branch 'feature-smpl' into maint-poetry 2024-11-24 14:31:50 -08:00
Jamie Hardt
bf536f66ec Tests for smpl 2024-11-24 14:28:32 -08:00
Jamie Hardt
2ab9e940ab Added "smpl" to the list of supported scopes 2024-11-24 13:36:27 -08:00
Jamie Hardt
7104f3c18a Merge branch 'feature-smpl' into maint-poetry 2024-11-24 13:31:52 -08:00
Jamie Hardt
f04c563fe2 Removed extraneous import 2024-11-24 13:26:26 -08:00
Jamie Hardt
06fa3cc422 autopep8 2024-11-24 13:25:29 -08:00
Jamie Hardt
83a44de492 Integrated smpl metadata reading
Now reads from command line and WavInfoReader interface.
2024-11-24 13:24:00 -08:00
Jamie Hardt
d8f57c8607 autopep8 2024-11-24 12:49:27 -08:00
Jamie Hardt
7c3ae745b7 Lints 2024-11-24 12:48:06 -08:00
Jamie Hardt
dc18b4eb99 autopep8 2024-11-24 12:47:09 -08:00
Jamie Hardt
259994d514 Implementation of WaveSmplReader 2024-11-24 12:44:09 -08:00
Jamie Hardt
9c51a6d146 Added test file with smpl metadata from #34 2024-11-24 12:01:30 -08:00
Jamie Hardt
28e0532994 Made the man opening code cleaner 2024-11-23 21:22:06 -08:00
Jamie Hardt
29ca62b970 Autopep8 2024-11-23 21:02:40 -08:00
Jamie Hardt
77ce1e3bc0 Removing "--install-manpages" for now 2024-11-23 21:00:05 -08:00
Jamie Hardt
82129cee07 Clarified a man item 2024-11-23 20:59:15 -08:00
Jamie Hardt
c249ce058d Reorganized man files to fall inside module 2024-11-23 20:56:20 -08:00
Jamie Hardt
a66049b425 Added poetry.lock to gitignore 2024-11-23 20:23:52 -08:00
Jamie Hardt
e60723afcf Added version detection back to output 2024-11-23 20:20:19 -08:00
Jamie Hardt
8b402f310c Changes for poetry 2024-11-23 19:15:16 -08:00
Jamie Hardt
c3c8ba2908 Updated pyproject.toml to poetry 2024-11-23 18:47:20 -08:00
16 changed files with 216 additions and 45 deletions

2
.gitignore vendored
View File

@@ -110,3 +110,5 @@ venv_docs/
.DS_Store .DS_Store
.vscode/ .vscode/
poetry.lock

View File

@@ -36,6 +36,11 @@ iXML
* `Gallery Software iXML Specification <http://www.gallery.co.uk/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 RIFF Metadata
------------- -------------
* `1991. Multimedia Programming Interface and Data Specifications 1.0 <https://www.aelius.com/njh/wavemetatools/doc/riffmci.pdf>`_ * `1991. Multimedia Programming Interface and Data Specifications 1.0 <https://www.aelius.com/njh/wavemetatools/doc/riffmci.pdf>`_

View File

@@ -46,6 +46,7 @@
" * `adm`: EBU Audio Defintion Model metadata, as used by Dolby Atmos.\n", " * `adm`: EBU Audio Defintion Model metadata, as used by Dolby Atmos.\n",
" * `cues`: Cue marker metadata, including labels and notes \n", " * `cues`: Cue marker metadata, including labels and notes \n",
" * `dolby`: Dolby recorder and playback metadata\n", " * `dolby`: Dolby recorder and playback metadata\n",
" * `smpl`: Sampler midi note and loop metadata\n",
"\n", "\n",
"Each of these is an attribute of a `WavInfoReader` object.\n", "Each of these is an attribute of a `WavInfoReader` object.\n",
"\n", "\n",
@@ -304,7 +305,7 @@
"name": "python", "name": "python",
"nbconvert_exporter": "python", "nbconvert_exporter": "python",
"pygments_lexer": "ipython3", "pygments_lexer": "ipython3",
"version": "3.11.5" "version": "3.12.5"
} }
}, },
"nbformat": 4, "nbformat": 4,

View File

@@ -1,13 +1,16 @@
[build-system] # https://python-poetry.org/docs/pyproject/
requires = ["flit_core >=3.2,<4"]
build-backend = "flit_core.buildapi"
[project] [build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "wavinfo" name = "wavinfo"
authors = [{name = "Jamie Hardt", email = "jamiehardt@me.com"}] version = "3.0.1"
description = "Probe WAVE files for all metadata"
authors = ["Jamie Hardt <jamiehardt@me.com>"]
license = "MIT"
readme = "README.md" readme = "README.md"
dynamic = ["version", "description"]
requires-python = "~=3.8"
classifiers = [ classifiers = [
'Development Status :: 5 - Production/Stable', 'Development Status :: 5 - Production/Stable',
'License :: OSI Approved :: MIT License', 'License :: OSI Approved :: MIT License',
@@ -20,9 +23,10 @@ classifiers = [
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13" "Programming Language :: Python :: 3.13"
] ]
dependencies = [ homepage = "https://github.com/iluvcapra/wavinfo"
"lxml ~= 5.3.0" repository = "https://github.com/iluvcapra/wavinfo.git"
] documentation = "https://wavinfo.readthedocs.io/"
urls.Tracker = 'https://github.com/iluvcapra/wavinfo/issues'
keywords = [ keywords = [
'waveform', 'waveform',
'metadata', 'metadata',
@@ -35,29 +39,17 @@ keywords = [
'broadcast' 'broadcast'
] ]
[tool.flit.module] [tool.poetry.extras]
name = "wavinfo" doc = ['sphinx', 'sphinx_rtd_theme']
[project.optional-dependencies] [tool.poetry.scripts]
doc = [
'sphinx >= 5.3.0',
'sphinx_rtd_theme >= 1.1.1',
]
[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' wavinfo = 'wavinfo.__main__:main'
[project.scripts] [tool.poetry.dependencies]
wavinfo = "wavinfo.__main__:main" python = "^3.8"
lxml = "~= 5.3.0"
[tool.flit.external-data] sphinx_rtd_theme = {version= '>= 1.1.1', optional=true}
directory = "data" sphinx = {version= '>= 5.3.0', optional=true}
[tool.pyright] [tool.pyright]
typeCheckingMode = "basic" typeCheckingMode = "basic"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

15
tests/test_smpl.py Normal file
View File

@@ -0,0 +1,15 @@
from unittest import TestCase
from glob import glob
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)

View File

@@ -4,6 +4,3 @@ Probe WAVE Files for iXML, Broadcast-WAVE and other metadata.
from .wave_reader import WavInfoReader from .wave_reader import WavInfoReader
from .riff_parser import WavInfoEOFError from .riff_parser import WavInfoEOFError
__version__ = '3.0.0'
__short_version__ = '3.0.0'

View File

@@ -1,17 +1,21 @@
import datetime
from . import WavInfoReader from . import WavInfoReader
from . import __version__
import datetime
from optparse import OptionParser from optparse import OptionParser
import sys import sys
import os
import json import json
from enum import Enum from enum import Enum
import importlib.metadata
from base64 import b64encode
class MyJSONEncoder(json.JSONEncoder): class MyJSONEncoder(json.JSONEncoder):
def default(self, o): def default(self, o):
if isinstance(o, Enum): if isinstance(o, Enum):
return o._name_ return o._name_
elif isinstance(o, bytes):
return b64encode(o).decode('ascii')
else: else:
return super().default(o) return super().default(o)
@@ -21,10 +25,22 @@ class MissingDataError(RuntimeError):
def main(): def main():
version = importlib.metadata.version('wavinfo')
manpath = os.path.dirname(__file__) + "/man"
parser = OptionParser() parser = OptionParser()
parser.usage = 'wavinfo (--adm | --ixml) <FILE> +' parser.usage = 'wavinfo (--adm | --ixml) <FILE> +'
# parser.add_option('--install-manpages',
# 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', parser.add_option('--adm', dest='adm',
help='Output ADM XML', help='Output ADM XML',
default=False, default=False,
@@ -36,6 +52,26 @@ def main():
action='store_true') action='store_true')
(options, args) = parser.parse_args(sys.argv) (options, args) = parser.parse_args(sys.argv)
# 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:]: for arg in args[1:]:
try: try:
this_file = WavInfoReader(path=arg) this_file = WavInfoReader(path=arg)
@@ -53,9 +89,9 @@ def main():
ret_dict = { ret_dict = {
'filename': arg, 'filename': arg,
'run_date': datetime.datetime.now().isoformat(), 'run_date': datetime.datetime.now().isoformat(),
'application': "wavinfo " + __version__, 'application': f"wavinfo {version}",
'scopes': {} 'scopes': {}
} }
for scope, name, value in this_file.walk(): for scope, name, value in this_file.walk():
if scope not in ret_dict['scopes'].keys(): if scope not in ret_dict['scopes'].keys():
ret_dict['scopes'][scope] = {} ret_dict['scopes'][scope] = {}

View File

@@ -17,7 +17,7 @@ With no options,
will emit a JSON (Javascript Object Notation) object containing all will emit a JSON (Javascript Object Notation) object containing all
detected metadata. detected metadata.
.IP "\-\-adm" .IP "\-\-adm"
Output any Audio Definition Model (ADM) metadata in Output Audio Definition Model (ADM) XML metadata in
.BR FILE . .BR FILE .
.IP "\-\-ixml" .IP "\-\-ixml"
Output any iXML metdata in Output any iXML metdata in

View File

@@ -5,6 +5,7 @@ from typing import Optional, Generator, Any, NamedTuple
import pathlib import pathlib
from .riff_parser import parse_chunk, ChunkDescriptor, ListChunkDescriptor from .riff_parser import parse_chunk, ChunkDescriptor, ListChunkDescriptor
from .wave_ixml_reader import WavIXMLFormat from .wave_ixml_reader import WavIXMLFormat
from .wave_bext_reader import WavBextReader from .wave_bext_reader import WavBextReader
@@ -12,9 +13,11 @@ from .wave_info_reader import WavInfoChunkReader
from .wave_adm_reader import WavADMReader from .wave_adm_reader import WavADMReader
from .wave_dbmd_reader import WavDolbyMetadataReader from .wave_dbmd_reader import WavDolbyMetadataReader
from .wave_cues_reader import WavCuesReader from .wave_cues_reader import WavCuesReader
from .wave_smpl_reader import WavSmplReader
#: Calculated statistics about the audio data. #: Calculated statistics about the audio data.
class WavDataDescriptor(NamedTuple): class WavDataDescriptor(NamedTuple):
byte_count: int byte_count: int
frame_count: int frame_count: int
@@ -80,6 +83,9 @@ class WavInfoReader:
#: RIFF cues markers, labels, and notes. #: RIFF cues markers, labels, and notes.
self.cues: Optional[WavCuesReader] = None self.cues: Optional[WavCuesReader] = None
#: Sampler `smpl` metadata
self.smpl: Optional[WavSmplReader] = None
if hasattr(path, 'read'): if hasattr(path, 'read'):
self.get_wav_info(path) self.get_wav_info(path)
self.url = 'about:blank' self.url = 'about:blank'
@@ -110,6 +116,7 @@ class WavInfoReader:
self.info = self._get_info(wavfile, encoding=self.info_encoding) self.info = self._get_info(wavfile, encoding=self.info_encoding)
self.dolby = self._get_dbmd(wavfile) self.dolby = self._get_dbmd(wavfile)
self.cues = self._get_cue(wavfile) self.cues = self._get_cue(wavfile)
self.smpl = self._get_sampler_loops(wavfile)
self.data = self._describe_data() self.data = self._describe_data()
def _find_chunk_data(self, ident, from_stream, def _find_chunk_data(self, ident, from_stream,
@@ -203,6 +210,10 @@ class WavInfoReader:
return WavCuesReader.read_all(f, cue, labls, ltxts, notes, return WavCuesReader.read_all(f, cue, labls, ltxts, notes,
fallback_encoding=self.info_encoding) 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()" # FIXME: this should probably be named "iter()"
def walk(self) -> Generator[str, str, Any]: def walk(self) -> Generator[str, str, Any]:
""" """
@@ -210,11 +221,12 @@ class WavInfoReader:
:yields: tuples of the *scope*, *key*, and *value* of :yields: tuples of the *scope*, *key*, and *value* of
each metadatum. The *scope* value will be one of each metadatum. The *scope* value will be one of
"fmt", "data", "ixml", "bext", "info", "dolby", "cues" or "adm". "fmt", "data", "ixml", "bext", "info", "dolby", "cues", "adm" or
"smpl".
""" """
scopes = ('fmt', 'data', 'ixml', 'bext', 'info', 'adm', 'cues', scopes = ('fmt', 'data', 'ixml', 'bext', 'info', 'adm', 'cues',
'dolby') 'dolby', 'smpl')
for scope in scopes: for scope in scopes:
if scope in ['fmt', 'data']: if scope in ['fmt', 'data']:
@@ -223,10 +235,10 @@ class WavInfoReader:
yield scope, field, attr.__getattribute__(field) yield scope, field, attr.__getattribute__(field)
else: else:
dict = self.__getattribute__(scope).to_dict( mdict = self.__getattribute__(scope).to_dict(
) if self.__getattribute__(scope) else {} ) if self.__getattribute__(scope) else {}
for key in dict.keys(): for key in mdict.keys():
yield scope, key, dict[key] yield scope, key, mdict[key]
def __repr__(self): def __repr__(self):
return 'WavInfoReader({}, {}, {})'.format(self.path, return 'WavInfoReader({}, {}, {})'.format(self.path,

111
wavinfo/wave_smpl_reader.py Normal file
View File

@@ -0,0 +1,111 @@
import struct
from typing import Tuple, NamedTuple, List
class WaveSmplLoop(NamedTuple):
ident: int
loop_type: int
start: int
end: int
fraction: 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,
'fraction': self.fraction,
'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_fraction_semis: 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],
fraction=unpacked_loop[4],
repetition_count=unpacked_loop[5]))
#: Sampler-specific user data.
self.sampler_udata: bytes = 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_fraction_semis': self.midi_pitch_fraction_semis,
'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,
}