diff --git a/.gitignore b/.gitignore index a9c6ddc..186fd5f 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,5 @@ venv_docs/ .DS_Store .vscode/ + +poetry.lock diff --git a/docs/source/references.rst b/docs/source/references.rst index aa388c1..4006cb2 100644 --- a/docs/source/references.rst +++ b/docs/source/references.rst @@ -36,6 +36,11 @@ iXML * `Gallery Software iXML Specification `_ +Sampler Metadata +---------------- + +* `RecordingBlogs.com — Sample chunk (of a Wave file)`_ + RIFF Metadata ------------- * `1991. Multimedia Programming Interface and Data Specifications 1.0 `_ diff --git a/examples/demo.ipynb b/examples/demo.ipynb index f05ac2b..7eaf2b6 100644 --- a/examples/demo.ipynb +++ b/examples/demo.ipynb @@ -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", @@ -304,7 +305,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.5" + "version": "3.12.5" } }, "nbformat": 4, diff --git a/pyproject.toml b/pyproject.toml index 500d65e..19e7eda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,16 @@ -[build-system] -requires = ["flit_core >=3.2,<4"] -build-backend = "flit_core.buildapi" +# https://python-poetry.org/docs/pyproject/ -[project] +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] name = "wavinfo" -authors = [{name = "Jamie Hardt", email = "jamiehardt@me.com"}] +version = "3.0.1" +description = "Probe WAVE files for all metadata" +authors = ["Jamie Hardt "] +license = "MIT" readme = "README.md" -dynamic = ["version", "description"] -requires-python = "~=3.8" classifiers = [ 'Development Status :: 5 - Production/Stable', 'License :: OSI Approved :: MIT License', @@ -20,9 +23,10 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13" ] -dependencies = [ - "lxml ~= 5.3.0" -] +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', @@ -35,29 +39,17 @@ keywords = [ 'broadcast' ] -[tool.flit.module] -name = "wavinfo" +[tool.poetry.extras] +doc = ['sphinx', 'sphinx_rtd_theme'] -[project.optional-dependencies] -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] +[tool.poetry.scripts] wavinfo = 'wavinfo.__main__:main' -[project.scripts] -wavinfo = "wavinfo.__main__:main" - -[tool.flit.external-data] -directory = "data" +[tool.poetry.dependencies] +python = "^3.8" +lxml = "~= 5.3.0" +sphinx_rtd_theme = {version= '>= 1.1.1', optional=true} +sphinx = {version= '>= 5.3.0', optional=true} [tool.pyright] typeCheckingMode = "basic" diff --git a/tests/test_files/smpl/alarm_citizen_loop1.wav b/tests/test_files/smpl/alarm_citizen_loop1.wav new file mode 100644 index 0000000..9b61e91 Binary files /dev/null and b/tests/test_files/smpl/alarm_citizen_loop1.wav differ diff --git a/tests/test_files/smpl/alarm_citizen_loop1_fr.wav b/tests/test_files/smpl/alarm_citizen_loop1_fr.wav new file mode 100644 index 0000000..8de35b6 Binary files /dev/null and b/tests/test_files/smpl/alarm_citizen_loop1_fr.wav differ diff --git a/tests/test_files/smpl/alarm_citizen_loop1_res3.wav b/tests/test_files/smpl/alarm_citizen_loop1_res3.wav new file mode 100644 index 0000000..4b55dc9 Binary files /dev/null and b/tests/test_files/smpl/alarm_citizen_loop1_res3.wav differ diff --git a/tests/test_files/smpl/alarm_citizen_loop1_rev.wav b/tests/test_files/smpl/alarm_citizen_loop1_rev.wav new file mode 100644 index 0000000..b54cf29 Binary files /dev/null and b/tests/test_files/smpl/alarm_citizen_loop1_rev.wav differ diff --git a/tests/test_files/smpl/alarm_citizen_loop1_vendFF.wav b/tests/test_files/smpl/alarm_citizen_loop1_vendFF.wav new file mode 100644 index 0000000..a9c7cdf Binary files /dev/null and b/tests/test_files/smpl/alarm_citizen_loop1_vendFF.wav differ diff --git a/tests/test_smpl.py b/tests/test_smpl.py new file mode 100644 index 0000000..0529440 --- /dev/null +++ b/tests/test_smpl.py @@ -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) diff --git a/wavinfo/__init__.py b/wavinfo/__init__.py index f8d7de0..d5bbfaf 100644 --- a/wavinfo/__init__.py +++ b/wavinfo/__init__.py @@ -4,6 +4,3 @@ Probe WAVE Files for iXML, Broadcast-WAVE and other metadata. from .wave_reader import WavInfoReader from .riff_parser import WavInfoEOFError - -__version__ = '3.0.0' -__short_version__ = '3.0.0' diff --git a/wavinfo/__main__.py b/wavinfo/__main__.py index 0fd7428..5d444b7 100644 --- a/wavinfo/__main__.py +++ b/wavinfo/__main__.py @@ -1,17 +1,21 @@ -import datetime from . import WavInfoReader -from . import __version__ +import datetime from optparse import OptionParser import sys +import os import json from enum import Enum +import importlib.metadata +from base64 import b64encode class MyJSONEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, Enum): return o._name_ + elif isinstance(o, bytes): + return b64encode(o).decode('ascii') else: return super().default(o) @@ -21,10 +25,22 @@ class MissingDataError(RuntimeError): def main(): + version = importlib.metadata.version('wavinfo') + manpath = os.path.dirname(__file__) + "/man" parser = OptionParser() parser.usage = 'wavinfo (--adm | --ixml) +' + # 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, @@ -36,6 +52,26 @@ def main(): action='store_true') (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:]: try: this_file = WavInfoReader(path=arg) @@ -53,9 +89,9 @@ def main(): ret_dict = { 'filename': arg, 'run_date': datetime.datetime.now().isoformat(), - 'application': "wavinfo " + __version__, + 'application': f"wavinfo {version}", 'scopes': {} - } + } for scope, name, value in this_file.walk(): if scope not in ret_dict['scopes'].keys(): ret_dict['scopes'][scope] = {} diff --git a/data/share/man/man1/wavinfo.1 b/wavinfo/man/man1/wavinfo.1 similarity index 97% rename from data/share/man/man1/wavinfo.1 rename to wavinfo/man/man1/wavinfo.1 index 58fd7d6..0fe5382 100644 --- a/data/share/man/man1/wavinfo.1 +++ b/wavinfo/man/man1/wavinfo.1 @@ -17,7 +17,7 @@ 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 diff --git a/data/share/man/man7/wavinfo.7 b/wavinfo/man/man7/wavinfo.7 similarity index 100% rename from data/share/man/man7/wavinfo.7 rename to wavinfo/man/man7/wavinfo.7 diff --git a/wavinfo/wave_reader.py b/wavinfo/wave_reader.py index 6c7c506..ca41c80 100644 --- a/wavinfo/wave_reader.py +++ b/wavinfo/wave_reader.py @@ -5,6 +5,7 @@ from typing import Optional, Generator, Any, NamedTuple import pathlib + from .riff_parser import parse_chunk, ChunkDescriptor, ListChunkDescriptor from .wave_ixml_reader import WavIXMLFormat from .wave_bext_reader import WavBextReader @@ -12,9 +13,11 @@ from .wave_info_reader import WavInfoChunkReader from .wave_adm_reader import WavADMReader from .wave_dbmd_reader import WavDolbyMetadataReader from .wave_cues_reader import WavCuesReader - +from .wave_smpl_reader import WavSmplReader #: Calculated statistics about the audio data. + + class WavDataDescriptor(NamedTuple): byte_count: int frame_count: int @@ -80,6 +83,9 @@ class WavInfoReader: #: RIFF cues markers, labels, and notes. self.cues: Optional[WavCuesReader] = None + #: Sampler `smpl` metadata + self.smpl: Optional[WavSmplReader] = None + if hasattr(path, 'read'): self.get_wav_info(path) self.url = 'about:blank' @@ -110,6 +116,7 @@ class WavInfoReader: 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, @@ -203,6 +210,10 @@ class WavInfoReader: 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]: """ @@ -210,11 +221,12 @@ class WavInfoReader: :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". + "fmt", "data", "ixml", "bext", "info", "dolby", "cues", "adm" or + "smpl". """ scopes = ('fmt', 'data', 'ixml', 'bext', 'info', 'adm', 'cues', - 'dolby') + 'dolby', 'smpl') for scope in scopes: if scope in ['fmt', 'data']: @@ -223,10 +235,10 @@ class WavInfoReader: yield scope, field, attr.__getattribute__(field) else: - dict = self.__getattribute__(scope).to_dict( + mdict = self.__getattribute__(scope).to_dict( ) if self.__getattribute__(scope) else {} - for key in dict.keys(): - yield scope, key, dict[key] + for key in mdict.keys(): + yield scope, key, mdict[key] def __repr__(self): return 'WavInfoReader({}, {}, {})'.format(self.path, diff --git a/wavinfo/wave_smpl_reader.py b/wavinfo/wave_smpl_reader.py new file mode 100644 index 0000000..746689d --- /dev/null +++ b/wavinfo/wave_smpl_reader.py @@ -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 = "