diff --git a/conformance/virtualize/HARNESS.md b/conformance/virtualize/HARNESS.md index c3e7f79f..1b6ba84a 100644 --- a/conformance/virtualize/HARNESS.md +++ b/conformance/virtualize/HARNESS.md @@ -146,14 +146,14 @@ with the first's: files, and SAFE directories and Zarr v2, OME-Zarr and N5 stores, it runs the frozen Python profiles in `conformance/virtualize/reference/refkv_reference/` (what `refkv.virtualize` shipped before these formats moved to the IR, unchanged but for their import - paths); for every other profile, the shipped code. MRC and VSI were never - implemented outside the core, so they have no reference: their inputs - (`no-ref` in the corpus) compare `py` with `web` alone. + paths); for every other profile, the shipped code. MRC, VSI and MP3 were + never implemented outside the core, so they have no reference: their + inputs (`no-ref` in the corpus) compare `py` with `web` alone. - `py`: `python -m refkv.virtualize --allow-private-hosts`. - `web`: the browser code under Node, `js/conformance/virtualize.ts --allow-private-hosts`. -For TIFF (NDPI included), ND2, CZI, DICOM, NIfTI, MRC, IMS, VSI and SAFE files, and SAFE directories and Zarr v2, OME-Zarr and N5 stores, `py` and `web` are one implementation: the Rust core +For TIFF (NDPI included), ND2, CZI, DICOM, NIfTI, MRC, IMS, VSI, MP3 and SAFE files, and SAFE directories and Zarr v2, OME-Zarr and N5 stores, `py` and `web` are one implementation: the Rust core `rust/refkv-ir` (its parsers, schemas, read planner, projections and mirror), built natively for Python and as wasm32 for the browser, each with a thin host (the I/O, and the archive writer). Their `refkv_source` is the IR's mirror, diff --git a/conformance/virtualize/compare.py b/conformance/virtualize/compare.py index b54ae78b..6273c9b4 100644 --- a/conformance/virtualize/compare.py +++ b/conformance/virtualize/compare.py @@ -456,7 +456,7 @@ def check(item: tuple, impls: dict, out_dir: Path) -> dict: name, url = item[:2] if len(item) > 2 and item[2] == "py-only": # past the browser's limit: every implementation but web impls = {k: v for k, v in impls.items() if k != "web"} - if len(item) > 2 and item[2] == "no-ref": # a profile the frozen reference never had (MRC, VSI): py and web alone + if len(item) > 2 and item[2] == "no-ref": # a profile the frozen reference never had (MRC, VSI, MP3): py and web alone impls = {k: v for k, v in impls.items() if k != "ref"} results = {n: run(impl, url, out_dir / f"{name}.{n}.out") for n, impl in impls.items()} ref_name = next(iter(impls)) @@ -575,7 +575,7 @@ def corpus(proxy: Proxy, fixtures: Path, quick: bool, local_only: bool, large: b stores = store_fixtures(fixtures) files = [p for p in sorted(p for s in FILE_SUFFIXES for p in fixtures.rglob(f"*{s}")) if not any(p.is_relative_to(s) for s in stores)] - # a profile the frozen reference never had (MRC): its files compare py with web alone + # a profile the frozen reference never had (MRC, VSI, MP3): its files compare py with web alone items = [(f"fixture-{p.stem}", proxy.local(p.relative_to(fixtures).as_posix()), *(["no-ref"] if FILE_SUFFIXES[p.suffix]["reference"] is None else [])) for p in files] diff --git a/conformance/virtualize/corpus_mp3.txt b/conformance/virtualize/corpus_mp3.txt new file mode 100644 index 00000000..83b334ea --- /dev/null +++ b/conformance/virtualize/corpus_mp3.txt @@ -0,0 +1,5 @@ +# url|name|no-ref of the public MP3 files in the corpus (the frozen reference has no MP3 profile, so each +# compares py with web alone): a LibriVox recording on the Internet Archive (public domain): chapter 20 +# of "Public Opinion" by Walter Lippmann, MPEG-2 mono at 22.05 kHz, 64 kbps, with an Info frame and tags +# (archive.org redirects downloads to a mirror) +https://archive.org/download/public_opinion_1312_librivox/publicopinion_20_lippmann_64kb.mp3|librivox_lippmann_20|no-ref diff --git a/demo/index.html b/demo/index.html index 6c8371cb..58fe3b90 100644 --- a/demo/index.html +++ b/demo/index.html @@ -58,7 +58,7 @@

Image files to Zarr in the browser

Paste the URL of an image file, a Zarr or N5 store, or a .vzip archive. - TIFF, OME-TIFF, Aperio SVS, Hamamatsu NDPI, Nikon ND2, DICOM, NIfTI-1 and NIfTI-2, Imaris IMS, Zeiss CZI, MRC2014, Olympus VSI and Akoya QPTIFF files, N5, Zarr v2 and OME-Zarr 0.4 stores, and Sentinel-2 SAFE (Level-1C, Level-2A) products are supported; + TIFF, OME-TIFF, Aperio SVS, Hamamatsu NDPI, Nikon ND2, DICOM, NIfTI-1 and NIfTI-2, Imaris IMS, Zeiss CZI, MRC2014, Olympus VSI, Akoya QPTIFF and MP3 (MPEG Layer III audio) files, N5, Zarr v2 and OME-Zarr 0.4 stores, and Sentinel-2 SAFE (Level-1C, Level-2A) products are supported; the format is detected from the file's first bytes, or the store's metadata. A service worker reads the input's structure with a few range diff --git a/docs/virtualize/README.md b/docs/virtualize/README.md index 27bba978..d4423523 100644 --- a/docs/virtualize/README.md +++ b/docs/virtualize/README.md @@ -33,6 +33,7 @@ who each one is for, what you get, and how to run it. | MRC | MRC2014 maps, image stacks, volumes and volume stacks (`.mrc`, `.map`) | [mrc](mrc/README.md) | [§14](../../spec/virtualize/mrc.md#part-2-the-profile) | | VSI | Olympus cellSens whole-slide images (`.vsi` with its tile files) | [vsi](vsi/README.md) | [§15](../../spec/virtualize/vsi.md#part-2-the-profile) | | QPTIFF | Akoya (PerkinElmer) Vectra multiplex scans and inForm component data | [qptiff](qptiff/README.md) | [§16](../../spec/virtualize/qptiff.md#part-2-the-profile) | +| MP3 | MP3 audio files, as an array of samples (`.mp3`) | [mp3](mp3/README.md) | [§17](../../spec/virtualize/mp3.md#part-2-the-profile) | A format that reads a file takes a URL, or a local path. One that reads a @@ -40,7 +41,9 @@ store takes a URL ending in `/` that answers the S3 `ListObjectsV2` operation anonymously, such as a public S3 bucket. SAFE reads either: a product's `.SAFE` directory as a store, or its `.SAFE.zip` as a file. VSI is named by its `.vsi` file and reads the tile files beside it by name, without listing -the directory. +the directory. MP3 is the one format that is not an image: its archive holds +a plain Zarr array of audio samples, decoded from the file's frames when a +chunk is read ([mp3](mp3/README.md)). SAFE and CZI have no user-story page yet; their conventions describe the output. A Sentinel-2 product becomes a GeoZarr hierarchy (one group per @@ -235,6 +238,11 @@ each format's metadata is: metadata tree as JSON, the list of stacks and the file's size; each image keeps its stack's properties (scale, origin, channels, creation time, magnification) and its tile file's headers. +- **MP3:** the IR mirror on `refkv_source`: the ID3v2 tags, the Xing or + Info frame, every audio frame, and the ID3v1 and APE tags, so that the + file is rebuilt from the archive. The root keeps the stream's parameters + (version, sample rate, channels, frames, bitrate), the info frame's + counts and LAME fields, and the ID3v1 tag's fields. - **N5, Zarr v2 and OME-Zarr:** each node's own attributes and the layout members its `zarr.json` does not reproduce, on the node, as `{"attributes": ..., "metadata": ...}` (with `"unversioned"` for an diff --git a/docs/virtualize/mp3/README.md b/docs/virtualize/mp3/README.md new file mode 100644 index 00000000..b7695a8d --- /dev/null +++ b/docs/virtualize/mp3/README.md @@ -0,0 +1,161 @@ +# MP3 + +refkv turns a file into a Zarr dataset by writing a small archive of +references to the file's bytes, without copying its content +([overview](../README.md)). This page is about MP3 files: the one format +refkv virtualizes that is not an image. + +## Who this is for + +MP3 (`.mp3`: MPEG-1, MPEG-2 and MPEG-2.5 Layer III audio) is how a great +deal of recorded sound is stored and shared: speech corpora, field +recordings, podcasts, audiobooks, music. Someone who wants to read a +minute of a long recording lazily over HTTP as a NumPy array, put a +collection of recordings next to other arrays in a Zarr store, or run an +array pipeline over audio without first decoding every file to PCM on disk +has to convert it today. + +An MP3 file is a sequence of frames, each decoding to 1152 (MPEG-1) or 576 +samples per channel, with tags before and after them. refkv reads the file +once, finds every frame, and writes an archive in which every Zarr chunk is +a run of 256 frames, referenced together with the few frames before it that +a decoder needs (MP3 frames borrow bits from their predecessors). The +archive's `mp3` codec decodes a chunk when it is read, so the samples are +never stored: they stay in the file as MPEG frames. + +## What you get + +A group at the archive root with one array, `samples`. + +- **Shape:** `[samples, channels]`, with the dimension names `time` and + `channel`: one row per sample, one column per channel (1 or 2). +- **Data type:** `int16`, the decoder's 16-bit output. The array holds + every sample the frames decode to, the encoder's delay and padding + included (their counts are in the metadata, for a reader that wants to + trim them, as players do). +- **Chunks:** 256 frames each (294912 samples for MPEG-1, 147456 for + MPEG-2 and 2.5; 6.7 seconds at 44.1 kHz, 6.7 at 22.05), the last one + shorter. Reading a chunk reads its frames and a few before them (the + bit reservoir) in one request, and decodes them in one pass. +- **Codecs:** `mp3`, which refkv's `zarr` module registers and which + decodes with miniaudio (the `audio` extra). No other codec. +- **Metadata:** under `refkv_virtualized.mp3` on the root: the stream's + version, sample rate, channel count and mode, frame and sample counts, + duration and bitrate (constant or variable), whether frames carry CRCs; + the Xing or Info frame's frame and byte counts, encoder (`LAME3.99r`), + delay and padding; the ID3v2 tags' versions and sizes; the ID3v1 tag's + title, artist, album, year, comment, track and genre; an APE tag's + version and item count. The whole file (the tags, the info frame, every + frame and any bytes between them) is described on `refkv_source` by the + Rust core's IR mirror, from which it is rebuilt byte for byte. +- **No `ome` metadata:** the hierarchy is not an image, so OME-Zarr + viewers do not open it; zarr-python, xarray and any Zarr v3 reader with + the codec do. + +**What is not decoded.** The ID3v2 tag's frames (title, cover art, lyrics) +and an APE tag's items are kept whole in the mirror, not translated; the +samples a decoder gives are not bit-exact across decoders (the standards +allow a difference of a few units); a stream whose frames borrow bits from +frames that are not there (two files joined) decodes as the reader's +decoder has it. [spec/virtualize/mp3.md §7](../../../spec/virtualize/mp3.md#7-what-the-layout-does-not-express) +lists them. + +## Try it + +A LibriVox recording from the Internet Archive (chapter 20 of Walter +Lippmann's *Public Opinion*, public domain, 3.9 MB), from +[corpus_mp3.txt](../../../conformance/virtualize/corpus_mp3.txt): + +```bash +uv run python -m refkv.virtualize https://archive.org/download/public_opinion_1312_librivox/publicopinion_20_lippmann_64kb.mp3 lippmann_20.vzip +``` + +``` +{"format": "mp3", "version": "MPEG-2", "sampleRate": 22050, "channels": 1, "frames": 18576, "samples": 10699776, "duration": 485.250612244898, "bitrate": 64, "chunkFrames": 256, "chunks": 73, "info": "Info", "tags": ["id3v2", "id3v1"], "strayBytes": 0, ...} +``` + +The archive is 17 KB: 73 chunk references and the mirror's table of 18576 +frames. Read the recording: + +```python +import zarr +import refkv.zarr + +g = zarr.open_group(refkv.zarr.open("lippmann_20.vzip"), mode="r") +m = g.attrs["refkv_virtualized"]["mp3"] +print({k: m[k] for k in ("version", "sample_rate", "channels", "duration")}) +print(m["info"]) +print(m["id3v1"]) +a = g["samples"] +print(a.shape, a.dtype, a.chunks) +print(a[22050 * 60:22050 * 60 + 8, 0]) +``` + +``` +{'version': 'MPEG-2', 'sample_rate': 22050, 'channels': 1, 'duration': 485.250612244898} +{'tag': 'Info', 'frames': 18576, 'bytes': 3882212, 'quality': 57, 'encoder': 'LAME3.99r', 'delay': 576, 'padding': 1128} +{'title': '20 - A New Image', 'artist': 'Walter Lippmann', 'album': 'Public Opinion', 'year': '', 'comment': 'http://archive.org/details/p', 'track': 20, 'genre': 101} +(10699776, 1) int16 (147456, 1) +[11 -2 6 2 1 15 20 19] +``` + +The last line reads the samples one minute in: one request for the chunk's +frames (about 54 KB), decoded on the fly. The same samples, as +`ffmpeg -i publicopinion_20_lippmann_64kb.mp3 -f s16le -` gives them after +its gapless trimming, start `delay + 529` samples later in its output. + +**In the browser.** The service worker serves a virtualized MP3 as a Zarr +hierarchy like any other; the demo page does not display audio. + +## Supported and not supported + +Supported: + +- MPEG-1, MPEG-2 and MPEG-2.5 Layer III streams, mono and stereo (joint, + dual channel), constant and variable bitrates, with or without CRCs, + with or without the bit reservoir. +- Xing, Info and VBRI frames (read, not counted as audio), LAME tags, + ID3v2 tags (several, with footers), ID3v1 and APE tags. +- Junk between frames, a truncated last frame and bytes after the last + frame: skipped, kept in the mirror, counted in `stray_bytes`. + +Rejected: + +- Layer I and Layer II streams (`MPEG-2 Layer II (the frame at 0) is not + supported: only Layer III is`), and free-format bitrates. +- A stream whose frames change version, sample rate or channel count + (`the frame at 2016 changes the sample rate from 16000 to 22050`): two + files concatenated, for instance. +- A file with no audio frames, and an ID3v2 tag that runs past the file + or whose size is not syncsafe. + +## Performance + +- Virtualizing reads the whole file (an MP3 has no index), in one or a few + large range requests after one request for the size. The 3.9 MB file + above takes under a second over HTTP. +- Reading a chunk is one request of the chunk's frames and their lead-in + (two frames, plus the fewest frames holding 511 bytes of main data for + MPEG-1 or 255 for MPEG-2: about 3 to 8 frames, a few percent of the + chunk), then decoding them. +- The archive has one entry per chunk, and the mirror's table has a row + per frame, folded into one run when the frames are all of one length + (the file above: 17 KB in all), a few bytes per frame otherwise. + +## How it's verified + +`js/test/mp3/verify.py` virtualizes the synthetic MP3 files (including +inputs to reject) with the browser code, reads them back through +`python/src/refkv`, zarr-python and the `mp3` codec, and compares every +sample with miniaudio's decoding of the whole stream (exactly) and with +ffmpeg's (within a few units, where the generator stored it). +`python/tests/ir/test_mp3.py` does the same through Python, with chunks of +4 frames so that every file has several chunks and lead-ins. `compare.py` +checks that the Python and browser outputs are equivalent on those files +and on the public file of +[corpus_mp3.txt](../../../conformance/virtualize/corpus_mp3.txt). + +- Profile: [spec/virtualize/mp3.md](../../../spec/virtualize/mp3.md#part-2-the-profile) +- Rust core: [rust/refkv-ir/src/mp3.rs](../../../rust/refkv-ir/src/mp3.rs) +- Codec: [python/src/refkv/zarr/codecs.py](../../../python/src/refkv/zarr/codecs.py) +- Fixtures: [fixtures/mp3/](../../../fixtures/mp3/) diff --git a/fixtures/generators/mp3/write_fixtures.py b/fixtures/generators/mp3/write_fixtures.py new file mode 100644 index 00000000..d53d4511 --- /dev/null +++ b/fixtures/generators/mp3/write_fixtures.py @@ -0,0 +1,302 @@ +"""Writes synthetic MP3 files to fixtures/mp3/, covering the rules of the MP3 profile +(spec/virtualize/mp3.md §17 +): MPEG-1, MPEG-2 +and MPEG-2.5 streams, mono and stereo, constant and variable bitrates, CRC protection, the bit +reservoir on and off, Xing, Info and LAME tags, ID3v2, ID3v1 and APE tags, junk between and +around frames, a truncated last frame, and the inputs the profile rejects (`mp3_reject_*`). + +The audio is a deterministic signal (tones, a sweep and seeded noise, so that frames use the +bit reservoir) encoded by LAME (`lame`, with ffmpeg's libmp3lame for one file, and ffmpeg's +Layer II encoder for a rejected one): both must be on PATH. Some files are then edited byte by +byte (tags added, frames cut, junk inserted). For each accepted file without junk, the +expected samples are ffmpeg's decoding of its audio frames (an independent decoder) in +`fixtures/mp3/expected/.npy`, int16 of shape [samples, channels]; the tests compare +the virtualized array with them within a small tolerance, and with miniaudio's sequential +decoding (the codec's own decoder) exactly. The files with junk have no expected samples: +decoders differ on what they do after junk. + +`just fixtures` does not run this generator (it needs lame and ffmpeg, as NDPI's needs the +network): `just fixtures-of mp3` does. + +Usage: uv run python fixtures/generators/mp3/write_fixtures.py +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import numpy as np + +OUT = Path(__file__).parents[2] / "mp3" +EXPECTED = OUT / "expected" + +# MPEG audio frame headers (spec/virtualize/mp3.md §2 +# ): version bits -> +# name; sample rates by version then index; Layer III bitrates (kbps) by version then index. +VERSIONS = {0: "MPEG-2.5", 2: "MPEG-2", 3: "MPEG-1"} +RATES = {3: (44100, 48000, 32000), 2: (22050, 24000, 16000), 0: (11025, 12000, 8000)} +BITRATES = { + 3: (0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320), + 2: (0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160), +} +BITRATES[0] = BITRATES[2] + + +def header(b: bytes) -> dict | None: + """The fields of a Layer III frame header, or None when `b` does not start with one (a sync, + a version that is not reserved, Layer III, a bitrate index from 1 to 14, a sample rate + index from 0 to 2).""" + if len(b) < 4 or b[0] != 0xFF or b[1] & 0xE0 != 0xE0: + return None + version, layer = (b[1] >> 3) & 3, (b[1] >> 1) & 3 + bitrate, rate, padding = b[2] >> 4, (b[2] >> 2) & 3, (b[2] >> 1) & 1 + if version == 1 or layer != 1 or bitrate in (0, 15) or rate == 3: + return None + crc = not b[1] & 1 + mode = b[3] >> 6 + channels = 1 if mode == 3 else 2 + kbps, sr = BITRATES[version][bitrate], RATES[version][rate] + per = 144 if version == 3 else 72 + length = per * kbps * 1000 // sr + padding + side = (17 if channels == 1 else 32) if version == 3 else (9 if channels == 1 else 17) + return {"version": version, "rate": sr, "channels": channels, "crc": crc, "length": length, + "side": side, "kbps": kbps, "mode": mode} + + +def id3v2_size(data: bytes, at: int = 0) -> int: + """The whole size of the ID3v2 tag at `at`, footer included, or 0 if there is none.""" + if data[at:at + 3] != b"ID3" or len(data) < at + 10: + return 0 + size = 0 + for c in data[at + 6:at + 10]: + size = size << 7 | (c & 0x7F) + return 10 + size + (10 if data[at + 5] & 0x10 else 0) + + +def frames_of(data: bytes) -> list[tuple[int, int, dict]]: + """The chain of frames of an encoder's output: (offset, length, header) of each, from the + first header after the ID3v2 tags, until the chain breaks or the file ends.""" + p = 0 + while (n := id3v2_size(data, p)): + p += n + out = [] + while (h := header(data[p:p + 4])) and p + h["length"] <= len(data): + out.append((p, h["length"], h)) + p += h["length"] + return out + + +def is_info(data: bytes, offset: int, h: dict) -> bool: + """Whether the frame at `offset` is a Xing, Info or VBRI frame (spec/virtualize/mp3.md §2 + ).""" + tag = offset + 4 + (2 if h["crc"] else 0) + h["side"] + return data[tag:tag + 4] in (b"Xing", b"Info") or data[offset + 36:offset + 40] == b"VBRI" + + +def audio_region(data: bytes) -> tuple[int, int, int]: + """(start, end, channels) of the audio frames: the chain's frames after an info frame.""" + frames = frames_of(data) + if not frames: + raise ValueError("no frames") + if is_info(data, frames[0][0], frames[0][2]): + frames = frames[1:] + return frames[0][0], frames[-1][0] + frames[-1][1], frames[0][2]["channels"] + + +# ---- signals and encoders + +def signal(rate: int, seconds: float, channels: int, seed: int) -> np.ndarray: + """Deterministic audio: a tone, a sweep and noise per channel (int16, [samples, channels]).""" + n = int(rate * seconds) + t = np.arange(n) / rate + rng = np.random.default_rng(seed) + out = np.zeros((n, channels), dtype=np.float64) + for c in range(channels): + f0 = 330.0 * (c + 1) + sweep = np.sin(2 * np.pi * (f0 * t + (rate / 8) * t * t / (2 * seconds))) + tone = np.sin(2 * np.pi * (f0 * 3.5) * t + c) + noise = rng.standard_normal(n) + out[:, c] = 9000 * sweep + 6000 * tone + 2500 * noise + return np.clip(out, -32768, 32767).astype(" bytes: + """LAME's encoding of raw little-endian int16 `pcm` at `rate` Hz with `args`.""" + channels = pcm.shape[1] + raw = Path(f"_{name or 'in'}.raw") + out = Path(f"_{name or 'out'}.mp3") + raw.write_bytes(pcm.tobytes()) + cmd = ["lame", "-r", "-s", f"{rate / 1000:g}", "--bitwidth", "16", "--signed", "--little-endian", + "-m", "m" if channels == 1 else "j", "--quiet", "--noreplaygain", *args, str(raw), str(out)] + subprocess.run(cmd, check=True) + data = out.read_bytes() + raw.unlink() + out.unlink() + return data + + +def ffmpeg_encode(pcm: np.ndarray, rate: int, codec: str, kbps: int) -> bytes: + """ffmpeg's encoding of `pcm` with `codec` (libmp3lame writes an ID3v2 tag and an Info + frame with a Lavc LAME tag, the frame only to a file it can seek back in; mp2 is Layer II).""" + out = Path("_ffmpeg.mp3") + cmd = ["ffmpeg", "-v", "error", "-y", "-f", "s16le", "-ar", str(rate), "-ac", str(pcm.shape[1]), "-i", "pipe:", + "-c:a", codec, "-b:a", f"{kbps}k", "-f", "mp3" if codec == "libmp3lame" else "mp2", str(out)] + subprocess.run(cmd, input=pcm.tobytes(), capture_output=True, check=True) + data = out.read_bytes() + out.unlink() + return data + + +def ffmpeg_decode(audio: bytes, channels: int) -> np.ndarray: + """ffmpeg's decoding of a run of MPEG audio frames (no tags: nothing is trimmed) to int16.""" + cmd = ["ffmpeg", "-v", "error", "-f", "mp3", "-i", "pipe:", "-f", "s16le", "-c:a", "pcm_s16le", "pipe:"] + pcm = subprocess.run(cmd, input=audio, capture_output=True, check=True).stdout + return np.frombuffer(pcm, dtype=" bytes: + return bytes([(n >> 21) & 0x7F, (n >> 14) & 0x7F, (n >> 7) & 0x7F, n & 0x7F]) + + +def id3v2(body: bytes, footer: bool = False, version: int = 4) -> bytes: + """An ID3v2 tag holding `body` (padding is fine), with a footer when asked.""" + flags = 0x10 if footer else 0 + tag = b"ID3" + bytes([version, 0, flags]) + syncsafe(len(body)) + body + if footer: + tag += b"3DI" + bytes([version, 0, flags]) + syncsafe(len(body)) + return tag + + +def id3v1(title: str, artist: str, album: str, year: str, comment: str, track: int, genre: int) -> bytes: + text = lambda s, n: s.encode("latin-1").ljust(n, b"\0")[:n] # noqa: E731 + return (b"TAG" + text(title, 30) + text(artist, 30) + text(album, 30) + text(year, 4) + + text(comment, 28) + bytes([0, track, genre])) + + +def ape(items: dict[str, str], with_header: bool = True) -> bytes: + """An APEv2 tag with a footer and, when asked, a header.""" + body = b"".join(len(v.encode()).to_bytes(4, "little") + bytes(4) + k.encode() + b"\0" + v.encode() + for k, v in items.items()) + size = len(body) + 32 + + def block(is_header: bool) -> bytes: + flags = (0x80000000 if with_header else 0) | (0x20000000 if is_header else 0) + return (b"APETAGEX" + (2000).to_bytes(4, "little") + size.to_bytes(4, "little") + + len(items).to_bytes(4, "little") + flags.to_bytes(4, "little") + bytes(8)) + + return (block(True) if with_header else b"") + body + block(False) + + +def raw_header(version: int, layer: int, bitrate: int, rate: int, channels: int, crc: bool = False, + padding: int = 0) -> bytes: + """A frame header from its fields (layer bits: 1 III, 2 II, 3 I).""" + b1 = 0xE0 | version << 3 | layer << 1 | (0 if crc else 1) + b2 = bitrate << 4 | rate << 2 | padding << 1 + b3 = (0xC0 if channels == 1 else 0x40) + return bytes([0xFF, b1, b2, b3]) + + +# ---- the files + +def write(name: str, data: bytes, expected: bool = True) -> None: + (OUT / f"{name}.mp3").write_bytes(data) + if expected and not name.startswith("mp3_reject"): + start, end, channels = audio_region(data) + np.save(EXPECTED / f"{name}.npy", ffmpeg_decode(data[start:end], channels)) + print(f"{name}.mp3: {len(data)} bytes") + + +def main() -> None: + for tool in ("lame", "ffmpeg"): + if shutil.which(tool) is None: + sys.exit(f"{tool} is not on PATH") + OUT.mkdir(exist_ok=True) + EXPECTED.mkdir(exist_ok=True) + for old in list(OUT.glob("*.mp3")) + list(EXPECTED.glob("*.npy")): + old.unlink() + + # MPEG-1 at 44.1 kHz: a stereo CBR stream without any tag, and a mono VBR one with LAME's + # Xing frame (its frames differ in length) + write("mp3_mpeg1_stereo_cbr", lame(signal(44100, 0.4, 2, 1), 44100, "-b", "128", "-t")) + write("mp3_mpeg1_mono_vbr", lame(signal(44100, 0.5, 1, 2), 44100, "-V", "5")) + # MPEG-2 at 22.05 and 16 kHz: CRC-protected stereo frames, and a mono stream whose encoder + # did not use the bit reservoir + crc = lame(signal(22050, 0.6, 2, 3), 22050, "-b", "48", "-p", "-t") + write("mp3_mpeg2_stereo_crc", crc) + write("mp3_mpeg2_mono_nores", lame(signal(16000, 0.7, 1, 4), 16000, "-b", "32", "--nores", "-t")) + # MPEG-2.5 at 8 kHz: a short mono stream, and a long one of more than 256 frames (the + # chunk size), whose expected samples would be large and which the tests decode themselves + mono8 = lame(signal(8000, 1.0, 1, 5), 8000, "-b", "16", "-t") + write("mp3_mpeg25_mono_8k", mono8) + write("mp3_mpeg25_long", lame(signal(8000, 24.0, 1, 6), 8000, "-b", "8", "-t"), expected=False) + # tags: LAME's ID3v2 and ID3v1, ID3v1 alone, an APE tag, ffmpeg's ID3v2 and Info frame, and + # LAME's ABR Xing frame + plain = lame(signal(16000, 0.7, 1, 7), 16000, "-b", "32", "-t") + write("mp3_mpeg2_mono_16k", plain) + text = ["--tt", "A title", "--ta", "An artist", "--tl", "An album", "--ty", "2021", "--tc", "Some comment", + "--tn", "7", "--tg", "Blues"] + # (the tag variants hold the frames of mp3_mpeg2_mono_16k, whose expected samples serve them) + write("mp3_tags_id3v2_id3v1", lame(signal(16000, 0.7, 1, 7), 16000, "-b", "32", "-t", "--add-id3v2", *text), + expected=False) + write("mp3_tags_id3v1_only", lame(signal(16000, 0.7, 1, 7), 16000, "-b", "32", "-t", "--id3v1-only", *text), + expected=False) + write("mp3_tags_ape", plain + ape({"Title": "Ape title", "Artist": "Ape artist"}), expected=False) + write("mp3_tags_ape_id3v1", plain + ape({"Title": "Ape title"}, with_header=False) + + id3v1("V1 title", "V1 artist", "V1 album", "1999", "comment", 3, 17), expected=False) + write("mp3_info_lavc", ffmpeg_encode(signal(16000, 0.7, 1, 8), 16000, "libmp3lame", 32)) + write("mp3_abr_xing", lame(signal(16000, 0.7, 1, 9), 16000, "--abr", "40")) + # hand-made ID3v2 tags: one with a footer, two in a row, and padding after the tag + write("mp3_id3v2_footer", id3v2(b"\0" * 40, footer=True) + plain, expected=False) + write("mp3_id3v2_twice", id3v2(b"\0" * 16, version=3) + id3v2(b"\0" * 24) + plain, expected=False) + write("mp3_id3v2_padding", id3v2(b"\0" * 16) + b"\0" * 64 + plain, expected=False) + # the frames alone: one frame (which ffmpeg does not decode from a pipe), and CRC-protected + # frames followed by plain ones, from two encodes of one signal without the bit reservoir + # (so that no frame reaches into the other encode's) + frames = frames_of(plain) + write("mp3_single_frame", plain[frames[0][0]:frames[0][0] + frames[0][1]], expected=False) + with_crc = lame(signal(22050, 0.6, 2, 3), 22050, "-b", "48", "-p", "--nores", "-t") + without = lame(signal(22050, 0.6, 2, 3), 22050, "-b", "48", "--nores", "-t") + crc_frames, plain_frames = frames_of(with_crc), frames_of(without) + mixed = b"".join(with_crc[o:o + n] for o, n, _ in crc_frames[:5]) + b"".join(without[o:o + n] for o, n, _ in plain_frames[5:]) + write("mp3_crc_mixed", mixed) + # junk: between frames, after the last frame, and a false sync (a header-like byte pair + # followed by garbage) between frames; a truncated last frame + o5, n5, _ = frames[5] + junk = bytes([0x00, 0x01, 0x02, 0x7F, 0x10]) * 7 + write("mp3_junk_between_frames", plain[:o5 + n5] + junk + plain[o5 + n5:] + b"\x55" * 100, expected=False) + false = b"\x00\x00" + raw_header(2, 1, 5, 2, 1) + b"\xaa" * 20 + write("mp3_junk_false_sync", plain[:o5 + n5] + false + plain[o5 + n5:], expected=False) + last_o, last_n, _ = frames[-1] + write("mp3_truncated_last_frame", plain[:last_o + last_n // 2], expected=False) + write("mp3_id3v1_after_truncated", plain[:last_o + last_n // 2] + id3v1("t", "a", "b", "2000", "c", 1, 2), + expected=False) + + # rejected: other layers, free format, a parameter change confirmed by the chain, no + # frames, and bad ID3v2 tags + write("mp3_reject_layer2", ffmpeg_encode(signal(16000, 0.5, 1, 10), 16000, "mp2", 32)) + layer1 = raw_header(3, 3, 4, 0, 2) # MPEG-1 Layer I, 128 kbps, 44.1 kHz: 12 * 128000 / 44100 * 4 = 136 bytes + write("mp3_reject_layer1", (layer1 + bytes(132)) * 3) + write("mp3_reject_free_format", (raw_header(3, 1, 0, 0, 2) + bytes(412)) * 3) + lo = lame(signal(16000, 0.5, 1, 11), 16000, "-b", "32", "-t") + hi = lame(signal(22050, 0.5, 1, 11), 22050, "-b", "32", "-t") + st = lame(signal(16000, 0.5, 2, 11), 16000, "-b", "48", "-t") + m1 = lame(signal(32000, 0.5, 1, 11), 32000, "-b", "48", "-t") + write("mp3_reject_sample_rate_change", lo + hi) + write("mp3_reject_channels_change", lo + st) + write("mp3_reject_version_change", lo + m1) + l2 = ffmpeg_encode(signal(16000, 0.5, 1, 10), 16000, "mp2", 32) + write("mp3_reject_layer_change", lo + l2) + write("mp3_reject_no_frames", id3v2(b"\0" * 32) + bytes(200)) + write("mp3_reject_only_id3v1", id3v1("t", "a", "b", "2000", "c", 1, 2)) + write("mp3_reject_id3v2_outside", b"ID3\x04\x00\x00" + syncsafe(100000) + bytes(50)) + write("mp3_reject_id3v2_syncsafe", b"ID3\x04\x00\x00" + bytes([0, 0, 0x80, 0]) + bytes(50)) + write("mp3_reject_short", b"ID3") + + +if __name__ == "__main__": + main() diff --git a/fixtures/mp3/expected/mp3_abr_xing.npy b/fixtures/mp3/expected/mp3_abr_xing.npy new file mode 100644 index 00000000..5fd8f385 Binary files /dev/null and b/fixtures/mp3/expected/mp3_abr_xing.npy differ diff --git a/fixtures/mp3/expected/mp3_crc_mixed.npy b/fixtures/mp3/expected/mp3_crc_mixed.npy new file mode 100644 index 00000000..f2f496a8 Binary files /dev/null and b/fixtures/mp3/expected/mp3_crc_mixed.npy differ diff --git a/fixtures/mp3/expected/mp3_info_lavc.npy b/fixtures/mp3/expected/mp3_info_lavc.npy new file mode 100644 index 00000000..722c1c4e Binary files /dev/null and b/fixtures/mp3/expected/mp3_info_lavc.npy differ diff --git a/fixtures/mp3/expected/mp3_mpeg1_mono_vbr.npy b/fixtures/mp3/expected/mp3_mpeg1_mono_vbr.npy new file mode 100644 index 00000000..55501b96 Binary files /dev/null and b/fixtures/mp3/expected/mp3_mpeg1_mono_vbr.npy differ diff --git a/fixtures/mp3/expected/mp3_mpeg1_stereo_cbr.npy b/fixtures/mp3/expected/mp3_mpeg1_stereo_cbr.npy new file mode 100644 index 00000000..90c28ef5 Binary files /dev/null and b/fixtures/mp3/expected/mp3_mpeg1_stereo_cbr.npy differ diff --git a/fixtures/mp3/expected/mp3_mpeg25_mono_8k.npy b/fixtures/mp3/expected/mp3_mpeg25_mono_8k.npy new file mode 100644 index 00000000..cfa41505 Binary files /dev/null and b/fixtures/mp3/expected/mp3_mpeg25_mono_8k.npy differ diff --git a/fixtures/mp3/expected/mp3_mpeg2_mono_16k.npy b/fixtures/mp3/expected/mp3_mpeg2_mono_16k.npy new file mode 100644 index 00000000..576eb0b3 Binary files /dev/null and b/fixtures/mp3/expected/mp3_mpeg2_mono_16k.npy differ diff --git a/fixtures/mp3/expected/mp3_mpeg2_mono_nores.npy b/fixtures/mp3/expected/mp3_mpeg2_mono_nores.npy new file mode 100644 index 00000000..37a80f42 Binary files /dev/null and b/fixtures/mp3/expected/mp3_mpeg2_mono_nores.npy differ diff --git a/fixtures/mp3/expected/mp3_mpeg2_stereo_crc.npy b/fixtures/mp3/expected/mp3_mpeg2_stereo_crc.npy new file mode 100644 index 00000000..9e11218d Binary files /dev/null and b/fixtures/mp3/expected/mp3_mpeg2_stereo_crc.npy differ diff --git a/fixtures/mp3/mp3_abr_xing.mp3 b/fixtures/mp3/mp3_abr_xing.mp3 new file mode 100644 index 00000000..14382592 Binary files /dev/null and b/fixtures/mp3/mp3_abr_xing.mp3 differ diff --git a/fixtures/mp3/mp3_crc_mixed.mp3 b/fixtures/mp3/mp3_crc_mixed.mp3 new file mode 100644 index 00000000..fa435caf Binary files /dev/null and b/fixtures/mp3/mp3_crc_mixed.mp3 differ diff --git a/fixtures/mp3/mp3_id3v1_after_truncated.mp3 b/fixtures/mp3/mp3_id3v1_after_truncated.mp3 new file mode 100644 index 00000000..a11c8a4f Binary files /dev/null and b/fixtures/mp3/mp3_id3v1_after_truncated.mp3 differ diff --git a/fixtures/mp3/mp3_id3v2_footer.mp3 b/fixtures/mp3/mp3_id3v2_footer.mp3 new file mode 100644 index 00000000..0fa9ab1e Binary files /dev/null and b/fixtures/mp3/mp3_id3v2_footer.mp3 differ diff --git a/fixtures/mp3/mp3_id3v2_padding.mp3 b/fixtures/mp3/mp3_id3v2_padding.mp3 new file mode 100644 index 00000000..1d3a587e Binary files /dev/null and b/fixtures/mp3/mp3_id3v2_padding.mp3 differ diff --git a/fixtures/mp3/mp3_id3v2_twice.mp3 b/fixtures/mp3/mp3_id3v2_twice.mp3 new file mode 100644 index 00000000..00d0727f Binary files /dev/null and b/fixtures/mp3/mp3_id3v2_twice.mp3 differ diff --git a/fixtures/mp3/mp3_info_lavc.mp3 b/fixtures/mp3/mp3_info_lavc.mp3 new file mode 100644 index 00000000..869448c9 Binary files /dev/null and b/fixtures/mp3/mp3_info_lavc.mp3 differ diff --git a/fixtures/mp3/mp3_junk_between_frames.mp3 b/fixtures/mp3/mp3_junk_between_frames.mp3 new file mode 100644 index 00000000..25d0fca2 Binary files /dev/null and b/fixtures/mp3/mp3_junk_between_frames.mp3 differ diff --git a/fixtures/mp3/mp3_junk_false_sync.mp3 b/fixtures/mp3/mp3_junk_false_sync.mp3 new file mode 100644 index 00000000..cc4ac823 Binary files /dev/null and b/fixtures/mp3/mp3_junk_false_sync.mp3 differ diff --git a/fixtures/mp3/mp3_mpeg1_mono_vbr.mp3 b/fixtures/mp3/mp3_mpeg1_mono_vbr.mp3 new file mode 100644 index 00000000..c72a02b5 Binary files /dev/null and b/fixtures/mp3/mp3_mpeg1_mono_vbr.mp3 differ diff --git a/fixtures/mp3/mp3_mpeg1_stereo_cbr.mp3 b/fixtures/mp3/mp3_mpeg1_stereo_cbr.mp3 new file mode 100644 index 00000000..7fbeb53c Binary files /dev/null and b/fixtures/mp3/mp3_mpeg1_stereo_cbr.mp3 differ diff --git a/fixtures/mp3/mp3_mpeg25_long.mp3 b/fixtures/mp3/mp3_mpeg25_long.mp3 new file mode 100644 index 00000000..61546ef7 Binary files /dev/null and b/fixtures/mp3/mp3_mpeg25_long.mp3 differ diff --git a/fixtures/mp3/mp3_mpeg25_mono_8k.mp3 b/fixtures/mp3/mp3_mpeg25_mono_8k.mp3 new file mode 100644 index 00000000..262b2d4c Binary files /dev/null and b/fixtures/mp3/mp3_mpeg25_mono_8k.mp3 differ diff --git a/fixtures/mp3/mp3_mpeg2_mono_16k.mp3 b/fixtures/mp3/mp3_mpeg2_mono_16k.mp3 new file mode 100644 index 00000000..594e6347 Binary files /dev/null and b/fixtures/mp3/mp3_mpeg2_mono_16k.mp3 differ diff --git a/fixtures/mp3/mp3_mpeg2_mono_nores.mp3 b/fixtures/mp3/mp3_mpeg2_mono_nores.mp3 new file mode 100644 index 00000000..c302584a Binary files /dev/null and b/fixtures/mp3/mp3_mpeg2_mono_nores.mp3 differ diff --git a/fixtures/mp3/mp3_mpeg2_stereo_crc.mp3 b/fixtures/mp3/mp3_mpeg2_stereo_crc.mp3 new file mode 100644 index 00000000..a6432516 Binary files /dev/null and b/fixtures/mp3/mp3_mpeg2_stereo_crc.mp3 differ diff --git a/fixtures/mp3/mp3_reject_channels_change.mp3 b/fixtures/mp3/mp3_reject_channels_change.mp3 new file mode 100644 index 00000000..974e9502 Binary files /dev/null and b/fixtures/mp3/mp3_reject_channels_change.mp3 differ diff --git a/fixtures/mp3/mp3_reject_free_format.mp3 b/fixtures/mp3/mp3_reject_free_format.mp3 new file mode 100644 index 00000000..0ee5f42f Binary files /dev/null and b/fixtures/mp3/mp3_reject_free_format.mp3 differ diff --git a/fixtures/mp3/mp3_reject_id3v2_outside.mp3 b/fixtures/mp3/mp3_reject_id3v2_outside.mp3 new file mode 100644 index 00000000..cc15e4ab Binary files /dev/null and b/fixtures/mp3/mp3_reject_id3v2_outside.mp3 differ diff --git a/fixtures/mp3/mp3_reject_id3v2_syncsafe.mp3 b/fixtures/mp3/mp3_reject_id3v2_syncsafe.mp3 new file mode 100644 index 00000000..14d374d6 Binary files /dev/null and b/fixtures/mp3/mp3_reject_id3v2_syncsafe.mp3 differ diff --git a/fixtures/mp3/mp3_reject_layer1.mp3 b/fixtures/mp3/mp3_reject_layer1.mp3 new file mode 100644 index 00000000..8817f7e4 Binary files /dev/null and b/fixtures/mp3/mp3_reject_layer1.mp3 differ diff --git a/fixtures/mp3/mp3_reject_layer2.mp3 b/fixtures/mp3/mp3_reject_layer2.mp3 new file mode 100644 index 00000000..d8b685d6 Binary files /dev/null and b/fixtures/mp3/mp3_reject_layer2.mp3 differ diff --git a/fixtures/mp3/mp3_reject_layer_change.mp3 b/fixtures/mp3/mp3_reject_layer_change.mp3 new file mode 100644 index 00000000..dc0bd6fb Binary files /dev/null and b/fixtures/mp3/mp3_reject_layer_change.mp3 differ diff --git a/fixtures/mp3/mp3_reject_no_frames.mp3 b/fixtures/mp3/mp3_reject_no_frames.mp3 new file mode 100644 index 00000000..a89ac1ff Binary files /dev/null and b/fixtures/mp3/mp3_reject_no_frames.mp3 differ diff --git a/fixtures/mp3/mp3_reject_only_id3v1.mp3 b/fixtures/mp3/mp3_reject_only_id3v1.mp3 new file mode 100644 index 00000000..0c601975 Binary files /dev/null and b/fixtures/mp3/mp3_reject_only_id3v1.mp3 differ diff --git a/fixtures/mp3/mp3_reject_sample_rate_change.mp3 b/fixtures/mp3/mp3_reject_sample_rate_change.mp3 new file mode 100644 index 00000000..13f2f2ff Binary files /dev/null and b/fixtures/mp3/mp3_reject_sample_rate_change.mp3 differ diff --git a/fixtures/mp3/mp3_reject_short.mp3 b/fixtures/mp3/mp3_reject_short.mp3 new file mode 100644 index 00000000..7df777e0 --- /dev/null +++ b/fixtures/mp3/mp3_reject_short.mp3 @@ -0,0 +1 @@ +ID3 \ No newline at end of file diff --git a/fixtures/mp3/mp3_reject_version_change.mp3 b/fixtures/mp3/mp3_reject_version_change.mp3 new file mode 100644 index 00000000..6e9c99c4 Binary files /dev/null and b/fixtures/mp3/mp3_reject_version_change.mp3 differ diff --git a/fixtures/mp3/mp3_single_frame.mp3 b/fixtures/mp3/mp3_single_frame.mp3 new file mode 100644 index 00000000..584b2873 Binary files /dev/null and b/fixtures/mp3/mp3_single_frame.mp3 differ diff --git a/fixtures/mp3/mp3_tags_ape.mp3 b/fixtures/mp3/mp3_tags_ape.mp3 new file mode 100644 index 00000000..d694a988 Binary files /dev/null and b/fixtures/mp3/mp3_tags_ape.mp3 differ diff --git a/fixtures/mp3/mp3_tags_ape_id3v1.mp3 b/fixtures/mp3/mp3_tags_ape_id3v1.mp3 new file mode 100644 index 00000000..97b7fca5 Binary files /dev/null and b/fixtures/mp3/mp3_tags_ape_id3v1.mp3 differ diff --git a/fixtures/mp3/mp3_tags_id3v1_only.mp3 b/fixtures/mp3/mp3_tags_id3v1_only.mp3 new file mode 100644 index 00000000..f17661f3 Binary files /dev/null and b/fixtures/mp3/mp3_tags_id3v1_only.mp3 differ diff --git a/fixtures/mp3/mp3_tags_id3v2_id3v1.mp3 b/fixtures/mp3/mp3_tags_id3v2_id3v1.mp3 new file mode 100644 index 00000000..064353b8 Binary files /dev/null and b/fixtures/mp3/mp3_tags_id3v2_id3v1.mp3 differ diff --git a/fixtures/mp3/mp3_truncated_last_frame.mp3 b/fixtures/mp3/mp3_truncated_last_frame.mp3 new file mode 100644 index 00000000..f235a0c3 Binary files /dev/null and b/fixtures/mp3/mp3_truncated_last_frame.mp3 differ diff --git a/js/README.md b/js/README.md index 1fc3197b..6f7487f7 100644 --- a/js/README.md +++ b/js/README.md @@ -196,6 +196,18 @@ MRC (MRC2014 files): Not supported, and refused with HTTP 422: gzipped maps (`.map.gz`), pre-2000 files without `MAP` at byte 208, and modes 3 and 101. +MP3 (MPEG-1, MPEG-2 and MPEG-2.5 Layer III audio): + +- The one format that is not an image: one array `samples` of int16, + `[time, channel]`, in chunks of 256 frames, each referencing the frames a + decoder needs before it (the bit reservoir); the `mp3` codec decodes a + chunk when it is read (in Python, with miniaudio). +- The stream's parameters, the Xing or Info frame's fields and the ID3v1 + tag under `refkv_virtualized`; the ID3v2 and APE tags in the mirror. + +Not supported, and refused with HTTP 422: Layer I and II streams, free-format +bitrates, and files whose frames change sample rate, version or channel count. + Olympus VSI (cellSens slides): - The `.vsi` file's URL names the slide; the tile files beside it @@ -274,6 +286,7 @@ uv run python js/test/dicom/verify.py # DICOM fixtures vs uv run python js/test/nifti/verify.py # NIfTI fixtures vs nibabel uv run python js/test/mrc/verify.py # MRC fixtures vs mrcfile uv run python js/test/vsi/verify.py # VSI fixtures vs their pixels (and Bio-Formats) +uv run python js/test/mp3/verify.py # MP3 fixtures vs miniaudio and ffmpeg uv run python js/test/ims/verify.py # IMS fixtures vs h5py uv run python js/test/czi/verify.py # CZI fixtures vs czifile (and libCZI) uv run python js/test/safe/verify.py # SAFE fixtures vs GDAL @@ -300,7 +313,9 @@ node demo/e2e_examples.mjs [example id …] # every demo example, their synthetic files, against pydicom, nibabel (unscaled data), mrcfile and h5py (cropped to the image size); `vsi/verify.py` serves each synthetic slide with its tile files, compares every stack with the pixels the generator wrote and, when a - JVM can be started, every level with Bio-Formats (bioio-bioformats). + JVM can be started, every level with Bio-Formats (bioio-bioformats); `mp3/verify.py` + reads each file's samples through the `mp3` codec and compares them with miniaudio's + decoding of the whole stream, and with ffmpeg's where the generator stored it. - `compare.py` runs three virtualizers (`ref`, the frozen reference, which a format added since the core, such as MRC, does not have; `py`, the Python command; `web`, this code under Node) on the synthetic files, every diff --git a/js/src/virtualize/index.ts b/js/src/virtualize/index.ts index 21ee89fc..a7e74ec2 100644 --- a/js/src/virtualize/index.ts +++ b/js/src/virtualize/index.ts @@ -76,7 +76,7 @@ export function isDicom(head: Uint8Array): boolean { const HDF5 = [0x89, 0x48, 0x44, 0x46, 0x0d, 0x0a, 0x1a, 0x0a]; -type ImageDesc = ArchiveDesc & { format: "tiff" | "ndpi" | "nd2" | "dicom" | "nifti" | "ims" | "czi" | "safe" | "mrc" | "vsi" | "qptiff"; summary: object }; +type ImageDesc = ArchiveDesc & { format: "tiff" | "ndpi" | "nd2" | "dicom" | "nifti" | "ims" | "czi" | "safe" | "mrc" | "vsi" | "qptiff" | "mp3"; summary: object }; /** `exact`, when given, reads exactly the ranges asked for, past `read`'s block cache: the * Rust core's requests, which it plans itself, are read with it. @@ -168,15 +168,28 @@ export function irFormat(head: Uint8Array): IrFormat | undefined { // `MAP` at 208: an MRC2014 file (spec/virtualize/mrc.md §14.1 // ) if (head.length >= 212 && head[208] === 0x4d && head[209] === 0x41 && head[210] === 0x50) return "mrc"; + // `ID3` at 0, or a valid MPEG audio frame header: an MP3 file (spec/virtualize/mp3.md §17.1 + // ) + if ((head[0] === 0x49 && head[1] === 0x44 && head[2] === 0x33) || isMpegHeader(head)) return "mp3"; return undefined; } +/** Whether `head` starts with an MPEG audio frame header (§1.2 + * ): the 11-bit sync, a + * version and a layer that are not reserved, and a sample rate index from 0 to 2, of any layer + * and bitrate index (the MP3 profile rejects Layers I and II, and a free-format bitrate, by name). */ +export function isMpegHeader(head: Uint8Array): boolean { + if (head.length < 4 || head[0] !== 0xff || (head[1] & 0xe0) !== 0xe0) return false; + const version = (head[1] >> 3) & 3, layer = (head[1] >> 1) & 3, rate = (head[2] >> 2) & 3; + return version !== 1 && layer !== 0 && rate !== 3; +} + async function virtualizeFile(url: string, { read, size: fileSize, exact, source }: Input): Promise { const head = await read(0, Math.min(552, fileSize)); if (isDicom(head)) return virtualizeIr("dicom", url, source); const ir = irFormat(head); if (ir === "tiff" || ir === "nd2") return virtualizeIr(ir, url, source); - if (ir === "nifti" || ir === "ims" || ir === "czi" || ir === "safe" || ir === "mrc") return virtualizeIr(ir, url, source); + if (ir === "nifti" || ir === "ims" || ir === "czi" || ir === "safe" || ir === "mrc" || ir === "mp3") return virtualizeIr(ir, url, source); throw new ImageError(NOT_SUPPORTED); } diff --git a/js/src/virtualize/ir/run.ts b/js/src/virtualize/ir/run.ts index eff85521..43ba07de 100644 --- a/js/src/virtualize/ir/run.ts +++ b/js/src/virtualize/ir/run.ts @@ -12,7 +12,7 @@ import type { RangeSource, Span } from "./source.ts"; import { ir, type IrExports, out, put } from "./wasm.ts"; /** The formats of the core's file runs (`Format::from_name` in rust/refkv-ir/src/run.rs). */ -export type IrFormat = "nd2" | "tiff" | "czi" | "dicom" | "nifti" | "ims" | "safe" | "ndpi" | "mrc" | "vsi" | "qptiff"; +export type IrFormat = "nd2" | "tiff" | "czi" | "dicom" | "nifti" | "ims" | "safe" | "ndpi" | "mrc" | "vsi" | "qptiff" | "mp3"; // (`vsi` is sniffed as a file but virtualized by the store run of vsi.ts; its file run only rejects.) export interface IrOutput extends ArchiveDesc { diff --git a/js/src/virtualize/profiles.ts b/js/src/virtualize/profiles.ts index 24daed76..ad250e54 100644 --- a/js/src/virtualize/profiles.ts +++ b/js/src/virtualize/profiles.ts @@ -1,6 +1,6 @@ // The profile registry: generated from spec/virtualize/profiles.json by spec/tools/profiles.py; edit the registry, not this. -export type Profile = "tiff" | "ndpi" | "nd2" | "dicom" | "nifti" | "mrc" | "mrc-dataset" | "ims" | "n5" | "zarr2" | "ome-zarr" | "safe" | "czi" | "vsi" | "qptiff"; +export type Profile = "tiff" | "ndpi" | "nd2" | "dicom" | "nifti" | "mrc" | "mrc-dataset" | "ims" | "n5" | "zarr2" | "ome-zarr" | "safe" | "czi" | "vsi" | "qptiff" | "mp3"; export interface ProfileEntry { name: Profile; @@ -32,4 +32,5 @@ export const REGISTRY: ProfileEntry[] = [ {"name": "czi", "section": 13, "title": "CZI", "uuid": "7a0733c7-d4be-4482-a64f-6904d9354ea5", "version": 0, "kind": "file", "suffixes": [".czi"], "reference": 20, "source": "Zeiss CZI", "inputs": "Zeiss CZI files (ZISRAW, file version 1), with uncompressed, JPEG, JPEG XR and Zstd subblocks, mosaics and pyramids", "for": "Zeiss CZI files"}, {"name": "vsi", "section": 15, "title": "VSI", "uuid": "c0a1d4e6-5b7f-4f3a-9c2d-8e1f6a7b3c45", "version": 0, "kind": "file", "suffixes": [".vsi"], "reference": null, "source": "Olympus VSI", "inputs": "Olympus cellSens VSI slides: a `.vsi` file and the tile files beside it, probed by key (uncompressed, JPEG and JPEG 2000 tiles)", "for": "Olympus cellSens whole-slide images (`.vsi` with its tile files)"}, {"name": "qptiff", "section": 16, "title": "QPTIFF", "uuid": "329ea136-2f55-4c27-b6f4-8e60ca2109b8", "version": 0, "kind": "file", "suffixes": [".qptiff"], "reference": null, "source": "Akoya QPTIFF", "inputs": "Akoya (PerkinElmer) Vectra QPTIFF slides and inForm component data, a TIFF with an XML description per image, in LZW, JPEG or uncompressed tiles or strips", "for": "Akoya (PerkinElmer) Vectra multiplex scans and inForm component data"}, + {"name": "mp3", "section": 17, "title": "MP3", "uuid": "b6cb0047-9448-4fdc-b03e-10dd3d6aa829", "version": 0, "kind": "file", "suffixes": [".mp3"], "reference": null, "source": "MP3 (MPEG Layer III audio)", "inputs": "MPEG-1, MPEG-2 and MPEG-2.5 Layer III audio files (`.mp3`), with their ID3v2, ID3v1 and APE tags; one array of samples, not an image", "for": "MP3 audio files, as an array of samples (`.mp3`)"}, ]; diff --git a/js/test/mp3/verify.py b/js/test/mp3/verify.py new file mode 100644 index 00000000..aa90d806 --- /dev/null +++ b/js/test/mp3/verify.py @@ -0,0 +1,112 @@ +"""Checks the browser MP3 virtualizer's output against miniaudio and ffmpeg. + +Each fixtures/mp3/*.mp3 is served over local HTTP and virtualized by +js/conformance/virtualize.ts (the browser code, run under Node). The array is read +through the reference reader, zarr-python and the `mp3` codec, and must equal miniaudio's +decoding of the audio frames in one pass, sample for sample (spec/virtualize/mp3.md §4.1 +), and ffmpeg's +(fixtures/mp3/expected, written by the generator) within a few units where the generator +stored it. The root's metadata must agree with the array, and the file must be rebuilt byte +for byte from the archive's IR mirror (refkv_source, §5.2 +). +`mp3_reject_*` files must be rejected. + +Usage: uv run python js/test/mp3/verify.py [

] +""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +from pathlib import Path + +import miniaudio +import numpy as np +import zarr + +HERE = Path(__file__).parent +ROOT = HERE.parents[2] +sys.path.insert(0, str(ROOT / "conformance" / "vzip")) +from http_server import Server # noqa: E402 + +from refkv.policy import Policy # noqa: E402 +import refkv.zarr # noqa: E402 (a vzip archive as a zarr store) + +sys.path.insert(0, str(HERE.parent)) +from ir_mirror import mirror_problems # noqa: E402 + +# The sources are served on 127.0.0.1, which spec §6.7 +# rule 3 refuses by +# default. +LOOPBACK_SOURCES = Policy(allow_private_hosts=True) +FFMPEG_MAX, FFMPEG_MEAN = 8, 1.0 + + +def audio_frames(path: Path, out: Path) -> bytes: + """The audio frames' bytes, one after the other, from the archive's mirror: its data + elements (spec/virtualize/mp3.md §5.3 + ).""" + from refkv.ir.cmirror import Archive, load + + data = path.read_bytes() + ir = load(Archive(str(out), lambda o, n: data[o:o + n])) + cols = ir.columns() + kind = np.frombuffer(cols["kind"], np.uint8) + start, length = np.frombuffer(cols["start"], " list[str]: + root = zarr.open_group(refkv.zarr.open(str(out), policy=LOOPBACK_SOURCES), mode="r", zarr_format=3) + meta = root.attrs["refkv_virtualized"]["mp3"] + got = root["samples"][...] + problems = [] + if got.dtype != np.int16 or got.shape != (meta["samples"], meta["channels"]): + problems.append(f"shape {got.shape} {got.dtype} is not {meta['samples']} x {meta['channels']} int16") + return problems + audio = audio_frames(path, out) + info = miniaudio.mp3_get_info(audio) + d = miniaudio.decode(audio, miniaudio.SampleFormat.SIGNED16, nchannels=meta["channels"], sample_rate=info.sample_rate) + want = np.frombuffer(d.samples, dtype=np.int16).reshape(-1, meta["channels"]) + if info.sample_rate != meta["sample_rate"]: + problems.append(f"sample rate {meta['sample_rate']} is not miniaudio's {info.sample_rate}") + if want.shape != got.shape or not np.array_equal(got, want): + problems.append(f"samples differ from miniaudio's ({got.shape} vs {want.shape})") + expected = path.parent / "expected" / f"{path.stem}.npy" + if expected.exists(): + w = np.load(expected).astype(np.int32) + diff = np.abs(got.astype(np.int32) - w) if w.shape == got.shape else None + if diff is None or diff.max() > FFMPEG_MAX or diff.mean() > FFMPEG_MEAN: + problems.append(f"samples differ from ffmpeg's ({'shape' if diff is None else (diff.max(), diff.mean())})") + return problems + mirror_problems(out, path) + + +def main(fixtures: Path) -> int: + server = Server(fixtures) + failures = 0 + with tempfile.TemporaryDirectory() as tmp: + for path in sorted(fixtures.glob("*.mp3")): + out = Path(tmp) / f"{path.stem}.vzip" + p = subprocess.run(["node", str(ROOT / "js" / "conformance" / "virtualize.ts"), "--allow-private-hosts", + server.base + path.name, str(out)], capture_output=True, text=True) + if path.stem.startswith("mp3_reject"): + ok = p.returncode == 3 and not out.exists() + failures += not ok + print(f"{path.name:40s} {'rejected: ' + p.stderr.strip()[-80:] if ok else 'NOT REJECTED ' + p.stderr[-200:]}") + continue + if p.returncode: + failures += 1 + print(f"{path.name:40s} FAILED: {p.stderr.strip()[-300:]}") + continue + problems = problems_of(path, out) + failures += bool(problems) + print(f"{path.name:40s} {'ok' if not problems else problems}") + print(f"\n{failures} failures") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main(Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "fixtures" / "mp3")) diff --git a/justfile b/justfile index 1fd0d1ac..537ed7c9 100644 --- a/justfile +++ b/justfile @@ -96,12 +96,13 @@ conformance out="conformance/results/latest": impls::build --impl rust=conformance/impls/rust/vzip --impl typescript=conformance/impls/typescript/vzip --impl python=conformance/impls/python/vzip # Regenerate the synthetic files and stores in fixtures/: every generator -# fixtures/generators//write_*.py (all but NDPI's, which needs the network: `just fixtures-of ndpi`) +# fixtures/generators//write_*.py (all but NDPI's, which needs the network, and MP3's, which +# needs lame and ffmpeg: `just fixtures-of ndpi`, `just fixtures-of mp3`) fixtures: #!/usr/bin/env bash set -euo pipefail for g in fixtures/generators/*/write_*.py; do - case "$g" in fixtures/generators/ndpi/*) continue ;; esac + case "$g" in fixtures/generators/ndpi/*|fixtures/generators/mp3/*) continue ;; esac uv run python "$g" done diff --git a/pyproject.toml b/pyproject.toml index 256f99e5..c955ce6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [] # HDF5 parser (h5py), and xarray reading netCDF files over HTTPS (h5netcdf, fsspec, aiohttp) dev = [ # the package (python/, a member of this workspace), built from this checkout, with its extras - "refkv[zarr,images,virtualizarr,obstore]", + "refkv[zarr,images,audio,virtualizarr,obstore]", "aiohttp>=3.14.3", "czifile>=2026.8.16", "fsspec>=2026.9.0", diff --git a/python/pyproject.toml b/python/pyproject.toml index 8ea724bf..4f90490c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -56,6 +56,11 @@ images = [ "refkv[zarr]", "imagecodecs>=2026.8.16", ] +# the MP3 decoder (miniaudio) of refkv.zarr's mp3 codec, which reads virtualized MP3 files +audio = [ + "refkv[zarr]", + "miniaudio>=1.59", +] # obstore's stores, such as S3Store, to pass as registry= for s3://, gs:// and az:// sources # (refkv takes any obspec store, and does not import obstore itself) obstore = [ @@ -92,4 +97,5 @@ cache-keys = [ { file = "../rust/refkv-ir/Cargo.toml" }, { file = "../rust/refkv-ir/src/**/*.rs" }, { file = "../rust/refkv-ir/schema/*.json" }, + { file = "../rust/refkv-ir/profiles.json" }, ] diff --git a/python/src/lib.rs b/python/src/lib.rs index 962b1a76..6a454b26 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -754,7 +754,7 @@ fn project<'py>(py: Python<'py>, format: &str, ir: &PyIr, facts: &str, url: &str out_py(py, &o) } -/// The mirror of an IR (for the profile "nd2", "tiff", "czi", "dicom", "nifti", "ims", "ndpi" or "mrc"): its entries and +/// The mirror of an IR (for the profile "nd2", "tiff", "czi", "dicom", "nifti", "ims", "ndpi", "mrc" or "mp3"): its entries and /// references under `refkv_source` (`out_py`'s form, the url empty), and what the /// table folded (JSON). #[pyfunction] @@ -810,6 +810,15 @@ fn _set_copy_threshold(n: Option) { refkv_ir::out::set_copy_threshold(n); } +/// For tests: the frames of an MP3 chunk (None: 256), so that small files have several chunks, +/// each with its lead-in (spec/virtualize/mp3.md §17.2 +/// ). +#[pyfunction] +#[pyo3(signature = (n=None))] +fn _set_chunk_frames(n: Option) { + refkv_ir::mp3::set_chunk_frames(n); +} + #[pymodule] #[pyo3(name = "_core")] fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { @@ -822,6 +831,7 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(rebuild_store, m)?)?; m.add_function(wrap_pyfunction!(plan_spans, m)?)?; m.add_function(wrap_pyfunction!(_set_copy_threshold, m)?)?; + m.add_function(wrap_pyfunction!(_set_chunk_frames, m)?)?; m.add_function(wrap_pyfunction!(project, m)?)?; m.add_function(wrap_pyfunction!(mirror, m)?)?; m.add_function(wrap_pyfunction!(canonical_problem, m)?)?; diff --git a/python/src/refkv/_extras.py b/python/src/refkv/_extras.py index d349fadd..3c47f659 100644 --- a/python/src/refkv/_extras.py +++ b/python/src/refkv/_extras.py @@ -8,6 +8,8 @@ archives of virtualized image files and stores use (`refkv.zarr.codecs`); - `images`: the JPEG, JPEG 2000 and JPEG XR decoders of those codecs (imagecodecs), with the `zarr` extra; +- `audio`: the MP3 decoder (miniaudio) of the `mp3` codec, which reads virtualized + MP3 files, with the `zarr` extra; - `obstore`: obstore, whose stores (`S3Store`, `GCSStore`, `AzureStore`, ...) an application passes as `registry=` to read `s3://`, `gs://` or `az://` sources. refkv does not import it: a registry takes any obspec store, and diff --git a/python/src/refkv/ir/__init__.py b/python/src/refkv/ir/__init__.py index f4185de5..03087d38 100644 --- a/python/src/refkv/ir/__init__.py +++ b/python/src/refkv/ir/__init__.py @@ -35,10 +35,22 @@ def nifti_version(head: bytes) -> int | None: return None +def mpeg_header(head: bytes) -> bool: + """Whether `head` starts with an MPEG audio frame header (spec/virtualize.md §1.2 + ): the 11-bit sync, a + version and a layer that are not reserved, and a sample rate index from 0 to 2, of any layer + and bitrate index (the MP3 profile rejects Layers I and II, and a free-format bitrate, by + name).""" + if len(head) < 4 or head[0] != 0xFF or head[1] & 0xE0 != 0xE0: + return False + version, layer, rate = (head[1] >> 3) & 3, (head[1] >> 1) & 3, (head[2] >> 2) & 3 + return version != 1 and layer != 0 and rate != 3 + + def sniff(head: bytes) -> str | None: """The IR format a file's first bytes give, if any (132 of them for DICOM, 348 for - NIfTI-1, 212 for MRC; DICOM first: a dual-personality file's preamble may hold a TIFF - header).""" + NIfTI-1, 212 for MRC, 4 for MP3; DICOM first: a dual-personality file's preamble may hold + a TIFF header).""" if head[128:132] == b"DICM": return "dicom" if head[:4] == ND2_MAGIC: @@ -57,6 +69,8 @@ def sniff(head: bytes) -> str | None: return "safe" if len(head) >= 212 and head[208:211] == b"MAP": return "mrc" + if head[:3] == b"ID3" or mpeg_header(head): + return "mp3" return None diff --git a/python/src/refkv/virtualize/profiles.py b/python/src/refkv/virtualize/profiles.py index f227d484..2cdd48cc 100644 --- a/python/src/refkv/virtualize/profiles.py +++ b/python/src/refkv/virtualize/profiles.py @@ -16,4 +16,5 @@ {'name': 'czi', 'section': 13, 'title': 'CZI', 'uuid': '7a0733c7-d4be-4482-a64f-6904d9354ea5', 'version': 0, 'kind': 'file', 'suffixes': ['.czi'], 'reference': 20, 'source': 'Zeiss CZI', 'inputs': 'Zeiss CZI files (ZISRAW, file version 1), with uncompressed, JPEG, JPEG XR and Zstd subblocks, mosaics and pyramids', 'for': 'Zeiss CZI files'}, {'name': 'vsi', 'section': 15, 'title': 'VSI', 'uuid': 'c0a1d4e6-5b7f-4f3a-9c2d-8e1f6a7b3c45', 'version': 0, 'kind': 'file', 'suffixes': ['.vsi'], 'reference': None, 'source': 'Olympus VSI', 'inputs': 'Olympus cellSens VSI slides: a `.vsi` file and the tile files beside it, probed by key (uncompressed, JPEG and JPEG 2000 tiles)', 'for': 'Olympus cellSens whole-slide images (`.vsi` with its tile files)'}, {'name': 'qptiff', 'section': 16, 'title': 'QPTIFF', 'uuid': '329ea136-2f55-4c27-b6f4-8e60ca2109b8', 'version': 0, 'kind': 'file', 'suffixes': ['.qptiff'], 'reference': None, 'source': 'Akoya QPTIFF', 'inputs': 'Akoya (PerkinElmer) Vectra QPTIFF slides and inForm component data, a TIFF with an XML description per image, in LZW, JPEG or uncompressed tiles or strips', 'for': 'Akoya (PerkinElmer) Vectra multiplex scans and inForm component data'}, + {'name': 'mp3', 'section': 17, 'title': 'MP3', 'uuid': 'b6cb0047-9448-4fdc-b03e-10dd3d6aa829', 'version': 0, 'kind': 'file', 'suffixes': ['.mp3'], 'reference': None, 'source': 'MP3 (MPEG Layer III audio)', 'inputs': 'MPEG-1, MPEG-2 and MPEG-2.5 Layer III audio files (`.mp3`), with their ID3v2, ID3v1 and APE tags; one array of samples, not an image', 'for': 'MP3 audio files, as an array of samples (`.mp3`)'}, ) diff --git a/python/src/refkv/zarr/__init__.py b/python/src/refkv/zarr/__init__.py index 0c1cd79c..ca064aec 100644 --- a/python/src/refkv/zarr/__init__.py +++ b/python/src/refkv/zarr/__init__.py @@ -10,9 +10,10 @@ ds = xr.open_zarr(refkv.zarr.open("data.vzip")) # a saved archive ds = xr.open_zarr(refkv.zarr.store(store)) # a refkv.ReferenceStore -Importing it also registers the codecs that archives of virtualized image files -and stores use (`refkv.zarr.codecs`): JPEG, JPEG 2000 and JPEG XR (whose decoders -need the `images` extra), zlib, and `n5_default`. +Importing it also registers the codecs that archives of virtualized files and +stores use (`refkv.zarr.codecs`): JPEG, JPEG 2000 and JPEG XR (whose decoders +need the `images` extra), `mp3` (whose decoder needs the `audio` extra), zlib, and +`n5_default`. """ from __future__ import annotations diff --git a/python/src/refkv/zarr/codecs.py b/python/src/refkv/zarr/codecs.py index 97c29734..ba9fb49b 100644 --- a/python/src/refkv/zarr/codecs.py +++ b/python/src/refkv/zarr/codecs.py @@ -21,9 +21,12 @@ ). `n5_default`: an N5 default-mode block, header included (zarr-extensions `codecs/n5_default`; spec/virtualize/n5.md). +`mp3`: a chunk of a virtualized MP3 file (spec/virtualize/mp3.md §4.1 +): the samples to +keep, then MPEG Layer III frames, decoded by miniaudio. The image codecs decode only; their encoders exist so zarr can create the -array metadata. +array metadata. The mp3 codec has no encoder. """ from __future__ import annotations @@ -44,6 +47,15 @@ from refkv._extras import missing +def _miniaudio(): + """miniaudio, which the mp3 codec needs, imported when a chunk is first decoded.""" + try: + import miniaudio + except ImportError as e: + raise missing("audio", "Reading MP3 chunks", e) from e + return miniaudio + + def _imagecodecs(): """imagecodecs, which the image codecs need, imported when a chunk is first coded.""" try: @@ -137,6 +149,55 @@ def compute_encoded_size(self, input_byte_length: int, chunk_spec: ArraySpec) -> register_codec("imagecodecs_jpegxr", JpegXrCodec) +@dataclass(frozen=True) +class Mp3Codec(ArrayBytesCodec): + """`mp3`: a chunk is a little-endian uint32, the samples per channel to keep, then MPEG + Layer III frames: the frames before the chunk's that a decoder needs (its lead-in), then + the chunk's own (spec/virtualize/mp3.md §4.1 + , §17.2 + ). The frames + decode, in one pass, to int16 of shape [samples, channels]; the chunk's samples are the + last `keep` of them, placed first and followed by zeros up to the chunk's shape. When the + decoder gives fewer (the stream's first frames, which have no lead-in, may not decode), + they end at `keep` and zeros precede them.""" + + is_fixed_size = False + + @classmethod + def from_dict(cls, data: dict) -> Self: + return cls() + + def to_dict(self) -> dict: + return {"name": "mp3"} + + async def _decode_single(self, chunk_bytes: Buffer, chunk_spec: ArraySpec) -> NDBuffer: + b = chunk_bytes.to_bytes() + if len(b) < 4: + raise ValueError("an mp3 chunk starts with the 4-byte count of its samples") + keep = int.from_bytes(b[:4], "little") + audio = b[4:] + ma = _miniaudio() + info = ma.mp3_get_info(audio) + decoded = ma.decode(audio, ma.SampleFormat.SIGNED16, nchannels=info.nchannels, sample_rate=info.sample_rate) + pcm = np.frombuffer(decoded.samples, dtype=np.int16).reshape(-1, info.nchannels) + n, c = chunk_spec.shape + if c != info.nchannels or keep > n: + raise ValueError(f"the frames hold {info.nchannels} channels and {keep} samples; the chunk is {n} x {c}") + out = np.zeros((n, c), dtype=np.int16) + tail = pcm[max(len(pcm) - keep, 0):] + out[keep - len(tail):keep] = tail + return chunk_spec.prototype.nd_buffer.from_ndarray_like(out.astype(chunk_spec.dtype.to_native_dtype(), copy=False)) + + async def _encode_single(self, chunk_array: NDBuffer, chunk_spec: ArraySpec) -> Buffer: + raise NotImplementedError("the mp3 codec decodes only") + + def compute_encoded_size(self, input_byte_length: int, chunk_spec: ArraySpec) -> int: + raise NotImplementedError + + +register_codec("mp3", Mp3Codec) + + @dataclass(frozen=True) class ZlibCodec(BytesBytesCodec): """`zlib`: each chunk is one zlib stream.""" diff --git a/python/tests/ir/test_mp3.py b/python/tests/ir/test_mp3.py new file mode 100644 index 00000000..dc2339fc --- /dev/null +++ b/python/tests/ir/test_mp3.py @@ -0,0 +1,251 @@ +"""The MP3 IR: the Rust parser and its schema, the projection (spec/virtualize/mp3.md) and the +`mp3` codec against two decoders on every synthetic file, the mirror's rebuild, the inputs the +profile rejects, and the lead-in of each chunk. MP3 has no frozen Python reference: miniaudio +(the codec's own decoder, run over the whole stream) and ffmpeg (an independent decoder, run by +the fixture generator and stored) are the readers it is compared with.""" + +import io +import json + +import miniaudio +import numpy as np +import pytest +import zarr + +import refkv.zarr +from ir_helpers import FIXTURES +from refkv import _core +from refkv.ir import virtualize as via_ir +from refkv.ir.cmirror import rebuild_from_archive +from refkv.virtualize import Rejected + +MP3S = sorted((FIXTURES / "mp3").glob("*.mp3")) +ACCEPTED = [p for p in MP3S if not p.name.startswith("mp3_reject")] +EXPECTED = FIXTURES / "mp3" / "expected" +# ffmpeg's decoding and miniaudio's differ by rounding (about half the samples of a joint +# stereo stream by one unit): the most a sample may differ, and the most the mean may +FFMPEG_MAX, FFMPEG_MEAN = 8, 1.0 + + +def virtualized(path, tmp_path, chunk_frames=None): + """(the output, the hierarchy's root group, read in memory from the file, the IR), with + `chunk_frames` frames a chunk (None: the profile's 256); the archive rebuilds the file.""" + data = path.read_bytes() + _core._set_chunk_frames(chunk_frames) + try: + fmt, out, ir = via_ir(str(path)) + finally: + _core._set_chunk_frames(None) + assert fmt == "mp3" + ir.check() + out.write(str(tmp_path / "a.vzip")) + buf = io.BytesIO() + rebuild_from_archive(str(tmp_path / "a.vzip"), lambda o, n: data[o:o + n], buf.write) + assert buf.getvalue() == data + root = zarr.open_group(refkv.zarr.store(out.to_store()), mode="r", zarr_format=3) + return out, root, ir + + +def chunk_parts(out, q): + """(keep, [(offset, length), ...]) of chunk q's reference: its literal and its ranges of + source 0 (spec/virtualize/mp3.md §17.2 + ).""" + lit, *ranges = out.refs[f"samples/c/{q}/0"] + assert isinstance(lit, bytes) and len(lit) == 4 + assert all(len(r) == 2 for r in ranges) + return int.from_bytes(lit, "little"), ranges + + +def frames_of(ir): + """(offset, length) of each audio frame, in file order: the IR's data elements + (spec/virtualize/mp3.md §5.3 + ).""" + cols = ir.columns() + kind = np.frombuffer(cols["kind"], np.uint8) + start, length = np.frombuffer(cols["start"], " np.ndarray: + """miniaudio's decoding of the audio frames in one pass, int16 [samples, channels].""" + info = miniaudio.mp3_get_info(audio) + assert info.nchannels == channels + d = miniaudio.decode(audio, miniaudio.SampleFormat.SIGNED16, nchannels=channels, sample_rate=info.sample_rate) + return np.frombuffer(d.samples, dtype=np.int16).reshape(-1, channels) + + +@pytest.mark.parametrize("path", ACCEPTED, ids=lambda p: p.name) +def test_chunks_decode_as_the_stream_does(path, tmp_path): + """With 4 frames a chunk (so that every file has several chunks, each with its lead-in), + the array read through the mp3 codec is miniaudio's decoding of the audio frames in one + pass, sample for sample (spec/virtualize/mp3.md §4.1 + , §17.2 + ); the root's + metadata and the summary agree with the array; the file is rebuilt from the mirror.""" + out, root, ir = virtualized(path, tmp_path, chunk_frames=4) + meta = root.attrs["refkv_virtualized"]["mp3"] + arr = root["samples"] + got = arr[...] + frames = frames_of(ir) + assert len(frames) == meta["frames"] + assert got.dtype == np.int16 and got.shape == (meta["samples"], meta["channels"]) + assert meta["samples"] == meta["frames"] * meta["samples_per_frame"] + assert arr.chunks == (4 * meta["samples_per_frame"], meta["channels"]) + chunks = -(-meta["frames"] // 4) + assert out.summary["chunks"] == chunks and out.summary["chunkFrames"] == 4 + assert set(root.attrs) == {"zarr_conventions", "refkv_virtualized"} + want = sequential(audio_frames(path, ir), meta["channels"]) + assert want.shape == got.shape + assert np.array_equal(got, want) + # each chunk keeps its own frames' samples; its ranges are the frames from its lead-in's + # first to its own last, runs of consecutive frames merged, and the first has no lead-in + for q in range(chunks): + a, b = 4 * q, min(4 * q + 4, len(frames)) + keep, ranges = chunk_parts(out, q) + assert keep == (b - a) * meta["samples_per_frame"] + l = next(j for j, (o, _) in enumerate(frames) if o == ranges[0][0]) + assert (l == 0) if q == 0 else (l < a) + assert ranges[-1][0] + ranges[-1][1] == frames[b - 1][0] + frames[b - 1][1] + assert sum(n for _, n in ranges) == sum(n for _, n in frames[l:b]) + assert all(ranges[k][0] > ranges[k - 1][0] + ranges[k - 1][1] for k in range(1, len(ranges))) + assert out.data == {} # no data sources + doc = json.loads(out.bytes_entries["samples/zarr.json"]) + assert doc["codecs"] == [{"name": "mp3"}] and doc["dimension_names"] == ["time", "channel"] + assert doc["fill_value"] == 0 and doc["data_type"] == "int16" + + +@pytest.mark.parametrize("path", [p for p in ACCEPTED if (EXPECTED / f"{p.stem}.npy").exists()], ids=lambda p: p.name) +@pytest.mark.parametrize("chunk_frames", [None, 4]) +def test_matches_ffmpeg(path, chunk_frames, tmp_path): + """The array is ffmpeg's decoding of the audio frames (fixtures/mp3/expected, written by + the generator) within a few units per sample, whole (256 frames a chunk) and in chunks of + 4 frames with their lead-ins.""" + _, root, _ = virtualized(path, tmp_path, chunk_frames) + got = root["samples"][...].astype(np.int32) + want = np.load(EXPECTED / f"{path.stem}.npy").astype(np.int32) + assert got.shape == want.shape + diff = np.abs(got - want) + assert diff.max() <= FFMPEG_MAX and diff.mean() <= FFMPEG_MEAN, (diff.max(), diff.mean()) + + +def test_a_long_stream_has_chunks_of_256_frames(tmp_path): + """mp3_mpeg25_long.mp3 (333 frames of 576 samples) has two chunks by default; the second's + lead-in starts before frame 256, and both decode as the stream does.""" + path = FIXTURES / "mp3" / "mp3_mpeg25_long.mp3" + out, root, ir = virtualized(path, tmp_path) + meta = root.attrs["refkv_virtualized"]["mp3"] + assert meta["frames"] > 256 and out.summary["chunks"] == 2 + arr = root["samples"] + assert arr.chunks == (256 * 576, 1) + keep1, ranges1 = chunk_parts(out, 1) + assert keep1 == (meta["frames"] - 256) * 576 + # frames of 72 bytes hold 59 bytes of main data (72 - 4 - 9): the lead-in is frames 254 + # and 255, and before them the 5 frames (295 bytes) that hold the 255-byte reservoir + frames = frames_of(ir) + assert ranges1 == [(frames[249][0], frames[-1][0] + frames[-1][1] - frames[249][0])] + got = arr[...] + assert np.array_equal(got, sequential(audio_frames(path, ir), 1)) + + +# (summary members, root metadata members) each file must have +CASES = { + "mp3_mpeg1_stereo_cbr": ({"version": "MPEG-1", "sampleRate": 44100, "channels": 2, "bitrate": 128, "info": None, "tags": [], + "strayBytes": 0}, + {"samples_per_frame": 1152, "channel_mode": "joint_stereo", "crc_frames": 0, "bitrate_mode": "constant"}), + "mp3_mpeg1_mono_vbr": ({"version": "MPEG-1", "channels": 1, "bitrate": "variable", "info": "Xing"}, + {"bitrate_mode": "variable", "bitrate_kbps": None}), + "mp3_mpeg2_stereo_crc": ({"version": "MPEG-2", "sampleRate": 22050, "channels": 2}, {"samples_per_frame": 576}), + "mp3_mpeg2_mono_nores": ({"version": "MPEG-2", "sampleRate": 16000, "bitrate": 32}, {}), + "mp3_mpeg25_mono_8k": ({"version": "MPEG-2.5", "sampleRate": 8000, "channels": 1, "bitrate": 16}, {"samples_per_frame": 576}), + "mp3_mpeg25_long": ({"version": "MPEG-2.5", "sampleRate": 8000, "bitrate": 8, "chunks": 2}, {}), + "mp3_mpeg2_mono_16k": ({"frames": 22, "samples": 12672, "duration": 0.792, "tags": []}, {"stray_bytes": 0}), + "mp3_tags_id3v2_id3v1": ({"tags": ["id3v2", "id3v1"]}, + {"id3v1": {"title": "A title", "artist": "An artist", "album": "An album", "year": "2021", + "comment": "Some comment", "track": 7, "genre": 0}}), + "mp3_tags_id3v1_only": ({"tags": ["id3v1"]}, {}), + "mp3_tags_ape": ({"tags": ["ape"]}, {"ape": {"version": 2000, "items": 2, "size": 32 + 32 + 2 * 8 + 6 + 9 + 7 + 10}}), + "mp3_tags_ape_id3v1": ({"tags": ["id3v1", "ape"]}, {"ape": {"version": 2000, "items": 1, "size": 32 + 8 + 6 + 9}}), + "mp3_info_lavc": ({"tags": ["id3v2"], "info": "Info"}, {}), + "mp3_abr_xing": ({"info": "Xing", "bitrate": "variable"}, {}), + "mp3_id3v2_footer": ({"tags": ["id3v2"]}, {"id3v2": [{"version": [4, 0], "flags": 16, "size": 40, "footer": True}]}), + "mp3_id3v2_twice": ({}, {"id3v2": [{"version": [3, 0], "flags": 0, "size": 16, "footer": False}, + {"version": [4, 0], "flags": 0, "size": 24, "footer": False}]}), + "mp3_id3v2_padding": ({"strayBytes": 64}, {}), + "mp3_single_frame": ({"frames": 1, "chunks": 1, "samples": 576}, {}), + "mp3_crc_mixed": ({}, {"crc_frames": 5}), + "mp3_junk_between_frames": ({"strayBytes": 35 + 100}, {}), + "mp3_junk_false_sync": ({"strayBytes": 26}, {}), + "mp3_truncated_last_frame": ({"strayBytes": 72}, {}), + "mp3_id3v1_after_truncated": ({"strayBytes": 72, "tags": ["id3v1"]}, {}), +} + + +def test_every_accepted_fixture_has_a_case(): + assert {p.stem for p in ACCEPTED} == set(CASES) + + +def test_virtualizes_the_synthetic_files(tmp_path): + for name, (summary, members) in CASES.items(): + out, root, _ = virtualized(FIXTURES / "mp3" / f"{name}.mp3", tmp_path) + assert {**out.summary, **summary} == out.summary, name + meta = root.attrs["refkv_virtualized"]["mp3"] + for k, v in members.items(): + assert meta.get(k) == v, (name, k) + keys = ["version", "layer", "sample_rate", "channels", "channel_mode", "samples_per_frame", "frames", "samples", + "duration", "bitrate_mode", "bitrate_kbps", "crc_frames"] + keys += [k for k in ("info", "id3v2", "id3v1", "ape") if k in meta] + ["stray_bytes"] + assert list(meta) == keys, name + assert set(root.attrs) == {"zarr_conventions", "refkv_virtualized"}, name + + +def test_the_info_frame_is_read_and_not_audio(tmp_path): + """A LAME Xing frame and an ffmpeg Info frame: their fields are the root's `info`, and + the first audio frame is the frame after them (the first chunk's range starts there).""" + out, root, _ = virtualized(FIXTURES / "mp3" / "mp3_abr_xing.mp3", tmp_path) + info = root.attrs["refkv_virtualized"]["mp3"]["info"] + assert info["tag"] == "Xing" and info["encoder"].startswith("LAME") and info["frames"] == out.summary["frames"] + assert isinstance(info["delay"], int) and isinstance(info["padding"], int) + _, ranges = chunk_parts(out, 0) + assert ranges[0][0] > 0 and out.summary["strayBytes"] == 0 + out, root, _ = virtualized(FIXTURES / "mp3" / "mp3_info_lavc.mp3", tmp_path) + info = root.attrs["refkv_virtualized"]["mp3"]["info"] + assert info["tag"] == "Info" and info["encoder"].startswith("Lavc") and info["frames"] == out.summary["frames"] + + +@pytest.mark.parametrize("name,message", [ + ("mp3_reject_layer2", "MPEG-2 Layer II (the frame at 0) is not supported: only Layer III is"), + ("mp3_reject_layer1", "MPEG-1 Layer I (the frame at 0) is not supported: only Layer III is"), + ("mp3_reject_free_format", "free-format bitrate (the frame header at 0) is not supported"), + ("mp3_reject_sample_rate_change", "changes the sample rate from 16000 to 22050"), + ("mp3_reject_channels_change", "changes the channel count from 1 to 2"), + ("mp3_reject_version_change", "changes the version from MPEG-2 to MPEG-1"), + ("mp3_reject_layer_change", "changes the layer from III to II"), + ("mp3_reject_no_frames", "no MPEG audio frames"), + ("mp3_reject_only_id3v1", "not a file of any supported format"), + ("mp3_reject_id3v2_outside", "the 100010-byte ID3v2 tag at 0 is outside the 60-byte file"), + ("mp3_reject_id3v2_syncsafe", "the ID3v2 tag at 0 has a size byte that is not syncsafe (128)"), + ("mp3_reject_short", "file too short for an MPEG audio frame header"), +]) +def test_rejects(name, message): + """Each rejected input, with its message (a parameter change names the frame's offset + before the message checked here; a file no row of spec/virtualize.md §1.2 + matches is rejected by + the host before the parser).""" + path = FIXTURES / "mp3" / f"{name}.mp3" + with pytest.raises(Rejected) as e: + via_ir(str(path), f"https://data.test/{name}.mp3") + assert message in str(e.value), str(e.value) + if "changes the" in message: + assert str(e.value).startswith("the frame at ") + + +def test_every_reject_fixture_has_a_test(): + assert {p.stem for p in MP3S if p.name.startswith("mp3_reject")} == { + name for name, _ in test_rejects.pytestmark[0].args[1]} diff --git a/python/tests/test_virtualize_refstore.py b/python/tests/test_virtualize_refstore.py index 9f781efc..ad23df9c 100644 --- a/python/tests/test_virtualize_refstore.py +++ b/python/tests/test_virtualize_refstore.py @@ -26,7 +26,7 @@ def proxy(tmp_path_factory): return Proxy(FIXTURES, tmp_path_factory.mktemp("cache")) -# (fixture, a store, checksums, format): a TIFF through the Rust core, NIfTI, MRC, a VSI slide (a +# (fixture, a store, checksums, format): a TIFF through the Rust core, NIfTI, MRC, an MP3, a VSI slide (a # file whose tile files make it a store output), Imaris IMS, # N5 stores (one with objects it ignores, one with an empty block), an OME-Zarr 0.4 store and a # Zarr v2 store (all the core's), and a SAFE product as a store (whose ranges start past byte 0, @@ -36,6 +36,7 @@ def proxy(tmp_path_factory): ("tiff/jpeg_gray.tif", False, True, "tiff"), ("nifti/nifti_block_x_split.nii", False, False, "nifti"), ("mrc/mrc_chunk_sections.mrc", False, True, "mrc"), + ("mp3/mp3_tags_id3v2_id3v1.mp3", False, False, "mp3"), ("vsi/vsi_rgb_jpeg.vsi", False, False, "vsi"), ("vsi/vsi_raw_tzc.vsi", False, True, "vsi"), ("ims/ims_latest_uint8.ims", False, False, "ims"), diff --git a/rust/refkv-ir/profiles.json b/rust/refkv-ir/profiles.json index 18ec3e38..31935747 100644 --- a/rust/refkv-ir/profiles.json +++ b/rust/refkv-ir/profiles.json @@ -215,5 +215,20 @@ "source": "Akoya QPTIFF", "inputs": "Akoya (PerkinElmer) Vectra QPTIFF slides and inForm component data, a TIFF with an XML description per image, in LZW, JPEG or uncompressed tiles or strips", "for": "Akoya (PerkinElmer) Vectra multiplex scans and inForm component data" + }, + { + "name": "mp3", + "section": 17, + "title": "MP3", + "uuid": "b6cb0047-9448-4fdc-b03e-10dd3d6aa829", + "version": 0, + "kind": "file", + "suffixes": [ + ".mp3" + ], + "reference": null, + "source": "MP3 (MPEG Layer III audio)", + "inputs": "MPEG-1, MPEG-2 and MPEG-2.5 Layer III audio files (`.mp3`), with their ID3v2, ID3v1 and APE tags; one array of samples, not an image", + "for": "MP3 audio files, as an array of samples (`.mp3`)" } ] diff --git a/rust/refkv-ir/schema/mp3.json b/rust/refkv-ir/schema/mp3.json new file mode 100644 index 00000000..f25f880b --- /dev/null +++ b/rust/refkv-ir/schema/mp3.json @@ -0,0 +1,66 @@ +{ + "source_model": "mp3", + "version": 1, + "about": "The source model of an MP3 file (spec/virtualize/mp3.md §5.3 ): the elements a parser emits, and the roles the file, each ID3v2 tag, the stream's first frame, a frame of another stream and the audio play, each with the constraints a file must meet to have the MP3 convention's layout (spec/virtualize/mp3.md §17.1 ). The parser checks them in this order; projections read a valid IR and check nothing.", + "limits": { + "chunk_frames": 256, + "reservoir_mpeg1": 511, + "reservoir_mpeg2": 255 + }, + "tables": { + "layers": { + "about": "the layer bits of a frame header: its name", + "1": "III", + "2": "II", + "3": "I" + } + }, + "elements": { + "about": "The IR of an MP3 file. n is a tag's size, L a frame's length, S the samples per channel of a frame (1152 for MPEG-1, 576 for MPEG-2 and 2.5), C the channel count.", + "id3v2/": {"kind": "struct", "about": "the k-th ID3v2 tag at the front of the file, k from 0; its extent the whole tag"}, + "id3v2//header": {"kind": "value", "type": "{id:ascii[3],version:u1[2],flags:u1,size:u1[4]}", "extent": "(at, 10): the tag's header"}, + "id3v2//data": {"kind": "value", "type": "bytes[n]", "extent": "(at + 10, n), when n > 0: the tag's frames and padding, opaque"}, + "id3v2//footer": {"kind": "value", "type": "bytes[10]", "extent": "(at + 10 + n, 10), when the header's footer flag (0x10) is set"}, + "info": {"kind": "value", "type": "bytes[L]", "extent": "the stream's first frame, when it holds a Xing, Info or VBRI tag: not an audio frame"}, + "frames": {"kind": "struct", "about": "the audio frames; its extent from the first frame's start to the last frame's end"}, + "frames/": {"kind": "data", "form": "{geometry: {samples: S, channels: C, data_type: int16}, codec: {name: mp3, version, sample_rate}, recipe: [[src, 0, null]]}", "extent": "(offset, L): audio frame j, j from 0, header included"}, + "id3v1": {"kind": "value", "type": "{tag:ascii[3],title:cstr[30],artist:cstr[30],album:cstr[30],year:ascii[4],comment:cstr[30],genre:u1}", "extent": "(size - 128, 128), when the file ends with TAG there"}, + "ape": {"kind": "value", "type": "bytes[n]", "extent": "the APE tag before the ID3v1 tag (or at the end), footer and header included, opaque"}, + "gaps/": {"kind": "gap", "about": "bytes no frame or tag holds: junk between frames, a truncated last frame, bytes after the last frame"} + }, + "roles": { + "file": { + "given": {"size": "the file's size"}, + "constraints": [ + {"require": "size >= 4", "message": "file too short for an MPEG audio frame header"} + ] + }, + "id3v2": { + "given": {"at": "the tag's offset", "size": "the file's size", "have": "size - at", "total": "10 + the size field + 10 for a footer", "syncsafe": "whether every size byte is below 0x80", "byte": "the first size byte that is not, when one is"}, + "constraints": [ + {"require": "have >= 10", "message": "the ID3v2 tag at {at} has no 10-byte header: the file ends at {size}"}, + {"require": "syncsafe", "message": "the ID3v2 tag at {at} has a size byte that is not syncsafe ({byte})"}, + {"require": "at + total <= size", "message": "the {total}-byte ID3v2 tag at {at} is outside the {size}-byte file"} + ] + }, + "stream": { + "given": {"at": "the first confirmed frame's offset", "version": "its version's name", "layer": "its layer bits (1: Layer III)", "layer_name": "its layer's name"}, + "constraints": [ + {"require": "layer == 1", "message": "{version} Layer {layer_name} (the frame at {at}) is not supported: only Layer III is"} + ] + }, + "frame": { + "given": {"at": "a confirmed frame's offset", "what": "the first parameter it changes: version, layer, sample rate or channel count", "from": "the stream's value", "to": "the frame's", "same": "false"}, + "constraints": [ + {"require": "same", "message": "the frame at {at} changes the {what} from {from} to {to}"} + ] + }, + "audio": { + "given": {"frames": "the audio frames found", "free": "the offset of the first free-format header met, when one was"}, + "constraints": [ + {"require": "frames >= 1 || !present(free)", "message": "free-format bitrate (the frame header at {free}) is not supported"}, + {"require": "frames >= 1", "message": "no MPEG audio frames"} + ] + } + } +} diff --git a/rust/refkv-ir/src/lib.rs b/rust/refkv-ir/src/lib.rs index cdf0f525..b3b400ba 100644 --- a/rust/refkv-ir/src/lib.rs +++ b/rust/refkv-ir/src/lib.rs @@ -20,6 +20,7 @@ pub mod json; pub mod lv; pub mod lzw; pub mod mirror; +pub mod mp3; pub mod mrc; pub mod n5; pub mod nd2; @@ -78,6 +79,7 @@ parser!(czi::Czi); parser!(dicom::Dicom); parser!(nifti::Nifti); parser!(mrc::Mrc); +parser!(mp3::Mp3); parser!(ims::Ims); parser!(safe::Safe); diff --git a/rust/refkv-ir/src/mp3.rs b/rust/refkv-ir/src/mp3.rs new file mode 100644 index 00000000..efd4afbd --- /dev/null +++ b/rust/refkv-ir/src/mp3.rs @@ -0,0 +1,503 @@ +//! A sans-IO parser of MP3 files (MPEG-1, MPEG-2 and MPEG-2.5 Layer III audio) into the IR +//! (spec/virtualize/mp3.md §17 +//! , and the +//! source model of §5.3 ). +//! +//! One round: the whole file (an MP3 has no index; each frame's header gives the next). The +//! schema (`schema/mp3.json`) holds every check, in the profile's order. The IR is the ID3v2 +//! tags at the front (a struct each), the Xing, Info or VBRI frame (one opaque value, when the +//! stream starts with one), the audio frames (a `data` element each, under `frames`), the ID3v1 +//! tag (one record value) and the APE tag (one opaque value) at the end, and gaps for the bytes +//! that are none of these. + +use crate::check; +use crate::expr::V; +use crate::fed::Fed; +use crate::ir::*; +use crate::nd2::Step; +use crate::rules::{self, Rules, Vars, flag, num, text}; +use serde_json::{json, Value as J}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// The versions by their header bits, and the layers by theirs (1 is Layer III). +pub const VERSIONS: [&str; 4] = ["MPEG-2.5", "reserved", "MPEG-2", "MPEG-1"]; +pub const LAYERS: [&str; 4] = ["reserved", "III", "II", "I"]; +/// The sample rates by version then index, and the channel modes by their bits. +const RATES: [[u32; 3]; 4] = [[11025, 12000, 8000], [0, 0, 0], [22050, 24000, 16000], [44100, 48000, 32000]]; +pub const MODES: [&str; 4] = ["stereo", "joint_stereo", "dual_channel", "mono"]; +/// Bitrates in kbps: MPEG-1, then MPEG-2 and 2.5; Layer I, II, III; then the index. +const BITRATES: [[[u32; 15]; 3]; 2] = [ + [ + [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448], + [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384], + [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320], + ], + [ + [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256], + [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160], + [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160], + ], +]; +/// The most bytes of earlier frames a frame's main data may begin in (`main_data_begin`, 9 +/// bits in MPEG-1 and 8 in MPEG-2 and 2.5), by version: the bit reservoir a decoder must hold +/// before a frame to decode it as the stream does (spec/virtualize/mp3.md §17.2 +/// ). +pub const RESERVOIR: [u64; 4] = [255, 0, 255, 511]; + +/// The frames of a chunk (spec/virtualize/mp3.md §4.1 +/// ), but in tests +/// (`set_chunk_frames`). Each run reads it once, into its facts. +pub const DEFAULT_CHUNK_FRAMES: usize = 256; +static CHUNK_FRAMES: AtomicUsize = AtomicUsize::new(DEFAULT_CHUNK_FRAMES); + +/// For tests: the frames of a chunk (None: the default, 256), so that small files have several +/// chunks and lead-ins. +pub fn set_chunk_frames(n: Option) { + CHUNK_FRAMES.store(n.unwrap_or(DEFAULT_CHUNK_FRAMES).max(1), Ordering::Relaxed); +} + +pub fn chunk_frames() -> usize { + CHUNK_FRAMES.load(Ordering::Relaxed) +} + +/// A frame header's fields (spec/virtualize/mp3.md §2 +/// ). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Head { + /// the version bits: 0 MPEG-2.5, 2 MPEG-2, 3 MPEG-1 + pub version: u8, + /// the layer bits: 1 Layer III, 2 Layer II, 3 Layer I + pub layer: u8, + /// whether a 16-bit CRC follows the header + pub crc: bool, + pub bitrate: u8, + pub rate: u8, + pub padding: bool, + /// the channel mode bits: 0 stereo, 1 joint stereo, 2 dual channel, 3 mono + pub mode: u8, +} + +impl Head { + /// The header at the start of `b`, when `b` starts with a **valid** one: the 11-bit sync, a + /// version that is not reserved, a layer that is not reserved, a bitrate index from 1 to 14 + /// and a sample rate index from 0 to 2. + pub fn parse(b: &[u8]) -> Option { + if b.len() < 4 || b[0] != 0xFF || b[1] & 0xE0 != 0xE0 { + return None; + } + let (version, layer) = ((b[1] >> 3) & 3, (b[1] >> 1) & 3); + let (bitrate, rate) = (b[2] >> 4, (b[2] >> 2) & 3); + if version == 1 || layer == 0 || bitrate == 0 || bitrate == 15 || rate == 3 { + return None; + } + Some(Head { version, layer, crc: b[1] & 1 == 0, bitrate, rate, padding: b[2] & 2 != 0, mode: b[3] >> 6 }) + } + + /// Whether `b` starts with a free-format header: valid but for a bitrate index of 0. + pub fn free_format(b: &[u8]) -> bool { + b.len() >= 4 && b[0] == 0xFF && b[1] & 0xE0 == 0xE0 && (b[1] >> 3) & 3 != 1 && (b[1] >> 1) & 3 != 0 + && b[2] >> 4 == 0 && (b[2] >> 2) & 3 != 3 + } + + pub fn rate(&self) -> u32 { + RATES[self.version as usize][self.rate as usize] + } + + pub fn channels(&self) -> u64 { + if self.mode == 3 { 1 } else { 2 } + } + + pub fn kbps(&self) -> u32 { + BITRATES[(self.version != 3) as usize][(3 - self.layer) as usize][self.bitrate as usize] + } + + /// The frame's length in bytes, header included. + pub fn length(&self) -> u64 { + let (br, sr, pad) = (self.kbps() as u64 * 1000, self.rate() as u64, self.padding as u64); + match self.layer { + 3 => (12 * br / sr + pad) * 4, + 2 => 144 * br / sr + pad, + _ if self.version == 3 => 144 * br / sr + pad, + _ => 72 * br / sr + pad, + } + } + + /// The samples per channel a Layer III frame decodes to. + pub fn samples(&self) -> u64 { + if self.version == 3 { 1152 } else { 576 } + } + + /// The side information's length (Layer III). + pub fn side(&self) -> u64 { + match (self.version == 3, self.channels() == 1) { + (true, true) => 17, + (true, false) => 32, + (false, true) => 9, + (false, false) => 17, + } + } + + /// The main data's length: the frame less its header, CRC and side information. + pub fn main(&self) -> u64 { + self.length().saturating_sub(4 + 2 * self.crc as u64 + self.side()) + } + + /// Whether `o` is a frame of the same stream: the same version, layer, sample rate and + /// channel count (the bitrate, padding, CRC and channel mode may differ frame by frame). + pub fn same_stream(&self, o: &Head) -> bool { + self.version == o.version && self.layer == o.layer && self.rate == o.rate && self.channels() == o.channels() + } +} + +/// Whether a file's first bytes are an MP3 file's (spec/virtualize.md §1.2 +/// ): `ID3` at 0, or a +/// frame header of any layer whose sync, version, layer and sample rate index are valid, whatever +/// its bitrate index (the profile then rejects a layer other than III, or a free-format +/// bitrate, by name). +pub fn detect(head: &[u8]) -> bool { + (head.len() >= 3 && &head[..3] == b"ID3") + || (head.len() >= 4 && head[0] == 0xFF && head[1] & 0xE0 == 0xE0 && (head[1] >> 3) & 3 != 1 && (head[1] >> 1) & 3 != 0 + && (head[2] >> 2) & 3 != 3) +} + +fn u32be(b: &[u8], at: usize) -> Option { + b.get(at..at + 4).map(|a| u32::from_be_bytes(a.try_into().unwrap())) +} + +fn u16be(b: &[u8], at: usize) -> Option { + b.get(at..at + 2).map(|a| u16::from_be_bytes(a.try_into().unwrap())) +} + +fn u32le(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) +} + +/// The offset in a frame of its Xing or Info tag, if it holds one: after the header, the CRC +/// and the side information. +fn xing_at(f: &[u8], h: &Head) -> Option { + let at = 4 + 2 * h.crc as usize + h.side() as usize; + matches!(f.get(at..at + 4), Some(b"Xing") | Some(b"Info")).then_some(at) +} + +/// Whether the frame `f` is an info frame: a Xing or Info tag, or a VBRI tag at 36. +fn is_info(f: &[u8], h: &Head) -> bool { + xing_at(f, h).is_some() || f.get(36..40) == Some(b"VBRI") +} + +/// The fields of an info frame (spec/virtualize/mp3.md §5.1 +/// ): the tag, the +/// frame and byte counts and the quality it holds (null when it does not), and the encoder, +/// delay and padding of a LAME tag after a Xing or Info tag. +fn info_json(f: &[u8], h: &Head) -> J { + let (mut frames, mut bytes, mut quality, mut encoder, mut delay, mut padding) = (J::Null, J::Null, J::Null, J::Null, J::Null, J::Null); + let tag = if let Some(at) = xing_at(f, h) { + let flags = u32be(f, at + 4).unwrap_or(0); + let mut pos = at + 8; + let field = |pos: &mut usize| { + let v = u32be(f, *pos).map_or(J::Null, |v| json!(v)); + *pos += 4; + v + }; + if flags & 1 != 0 { + frames = field(&mut pos); + } + if flags & 2 != 0 { + bytes = field(&mut pos); + } + if flags & 4 != 0 { + pos += 100; // the seek table + } + if flags & 8 != 0 { + quality = field(&mut pos); + } + // the LAME tag: a 9-byte encoder version (`LAME3.99r`; `LAME4.0`, a space and a NUL), + // then its fields (the delays at 21) + if let Some(e) = f.get(pos..pos + 9) { + let e = &e[..e.iter().position(|&c| c == 0).unwrap_or(9)]; + if !e.is_empty() && e.iter().all(|&c| (0x20..0x7F).contains(&c)) && e[0] != b' ' { + encoder = json!(String::from_utf8_lossy(e).trim_end().to_string()); + if let Some(d) = f.get(pos + 21..pos + 24) { + delay = json!((d[0] as u32) << 4 | (d[1] as u32) >> 4); + padding = json!((d[1] as u32 & 0xF) << 8 | d[2] as u32); + } + } + } + String::from_utf8_lossy(&f[at..at + 4]).to_string() + } else { + // VBRI: version, delay, quality, bytes, frames + delay = u16be(f, 42).map_or(J::Null, |v| json!(v)); + quality = u16be(f, 44).map_or(J::Null, |v| json!(v)); + bytes = u32be(f, 46).map_or(J::Null, |v| json!(v)); + frames = u32be(f, 50).map_or(J::Null, |v| json!(v)); + "VBRI".to_string() + }; + json!({"tag": tag, "frames": frames, "bytes": bytes, "quality": quality, "encoder": encoder, "delay": delay, "padding": padding}) +} + +/// Which parameter a confirmed frame of another stream changes: the first of the version, the +/// layer, the sample rate and the channel count that differs, with the two values. +fn differs(s: &Head, h: &Head) -> (&'static str, String, String) { + if s.version != h.version { + ("version", VERSIONS[s.version as usize].into(), VERSIONS[h.version as usize].into()) + } else if s.layer != h.layer { + ("layer", LAYERS[s.layer as usize].into(), LAYERS[h.layer as usize].into()) + } else if s.rate != h.rate { + ("sample rate", s.rate().to_string(), h.rate().to_string()) + } else { + ("channel count", s.channels().to_string(), h.channels().to_string()) + } +} + +/// The ID3v1 tag as one record type (conventions §8.6 +/// ). +pub const ID3V1_TYPE: &str = "{tag:ascii[3],title:cstr[30],artist:cstr[30],album:cstr[30],year:ascii[4],comment:cstr[30],genre:u1}"; +/// An ID3v2 tag's 10-byte header as one record type. +pub const ID3V2_HEADER_TYPE: &str = "{id:ascii[3],version:u1[2],flags:u1,size:u1[4]}"; + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Phase { + Start, + Scan, + Done, +} + +pub struct Mp3 { + size: u64, + phase: Phase, + fed: Fed, + ir: Ir, + rules: &'static Rules, + facts: J, +} + +impl Mp3 { + pub fn new(size: u64) -> Self { + Mp3 { size, phase: Phase::Start, fed: Fed::default(), ir: Ir::new(size), rules: rules::mp3(), facts: J::Null } + } + + pub fn feed(&mut self, offset: u64, data: Vec) { + self.fed.feed(offset, data); + } + + fn check(&self, role: &str, mut env: Vars<'static>) -> Res<()> { + self.rules.check(role, &mut env) + } + + pub fn step(&mut self) -> Result { + match self.phase { + Phase::Start => { + self.phase = Phase::Scan; + Ok(Step::Read(vec![(0, self.size)])) + } + Phase::Scan => { + self.scan()?; + self.phase = Phase::Done; + Ok(Step::Done) + } + Phase::Done => Ok(Step::Done), + } + } + + /// The whole file (spec/virtualize/mp3.md §17.1 + /// ): the ID3v2 + /// tags, the trailing tags, then the frames between them, and the elements of each. + fn scan(&mut self) -> Res<()> { + let n = self.size; + let d = self.fed.get(0, n).ok_or("internal: the file was not read")?.to_vec(); + self.fed.clear(); + self.check("file", rules::env([("size", num(n as f64))]))?; + // the ID3v2 tags at the front + let mut p: u64 = 0; + let mut id3v2 = Vec::new(); + while d.len() as u64 >= p + 3 && &d[p as usize..p as usize + 3] == b"ID3" { + let at = p as usize; + let have = n - p; + let (size, footer, bad) = if have >= 10 { + let s = &d[at + 6..at + 10]; + let size = s.iter().fold(0u64, |a, &c| a << 7 | (c & 0x7F) as u64); + (size, d[at + 5] & 0x10 != 0, s.iter().position(|&c| c >= 0x80)) + } else { + (0, false, None) + }; + let total = 10 + size + if footer { 10 } else { 0 }; + self.check("id3v2", rules::env([ + ("at", num(p as f64)), ("size", num(n as f64)), ("have", num(have as f64)), ("total", num(total as f64)), + ("syncsafe", flag(bad.is_none())), ("byte", bad.map_or(V::Absent, |k| num(d[at + 6 + k] as f64))), + ]))?; + let tag = self.ir.struct_(0, "id3v2/", id3v2.len() as u64, 0, Some((p, total)))?; + self.ir.value(tag, "header", NO_INDEX, ID3V2_HEADER_TYPE, 0, (p, 10), Some(&d[at..at + 10]))?; + if size > 0 { + self.ir.value(tag, "data", NO_INDEX, &format!("bytes[{size}]"), 0, (p + 10, size), None)?; + } + if footer { + self.ir.value(tag, "footer", NO_INDEX, "bytes[10]", 0, (p + 10 + size, 10), None)?; + } + id3v2.push(json!({"version": [d[at + 3], d[at + 4]], "flags": d[at + 5], "size": size, "footer": footer})); + p += total; + } + let a = p; + // the tags at the end: an ID3v1 tag, then an APE tag before it + let mut e = n; + let mut id3v1 = None; + if e >= a + 128 && &d[(e - 128) as usize..(e - 125) as usize] == b"TAG" { + e -= 128; + id3v1 = Some(e); + } + let mut ape = None; + if e >= a + 32 && &d[(e - 32) as usize..(e - 24) as usize] == b"APETAGEX" { + let f = (e - 32) as usize; + let (version, size, items, flags) = (u32le(&d, f + 8), u32le(&d, f + 12) as u64, u32le(&d, f + 16), u32le(&d, f + 20)); + let total = size + if flags & 0x8000_0000 != 0 { 32 } else { 0 }; + if size >= 32 && total <= e - a { + e -= total; + ape = Some((e, total, version, items)); + } + } + // the frames: a chain from each confirmed frame's end; a scan for the next after junk + let mut frames: Vec<(u64, Head)> = Vec::new(); + let mut info: Option<(u64, Head)> = None; + let mut stream: Option = None; + let mut free: Option = None; + let mut chained = false; + let mut p = a; + while p + 4 <= e { + let at = p as usize; + match Head::parse(&d[at..at + 4]) { + Some(h) => { + let len = h.length(); + let fits = p + len <= e; + let consistent = stream.is_none_or(|s| h.same_stream(&s)); + let next = || { + let q = (p + len) as usize; + q + 4 <= e as usize && Head::parse(&d[q..q + 4]).is_some_and(|g| g.same_stream(&h)) + }; + let confirmed = fits && ((chained && consistent) || (stream.is_none() && p + len == e) || next()); + if confirmed { + match stream { + None => { + self.check("stream", rules::env([ + ("at", num(p as f64)), ("version", text(VERSIONS[h.version as usize])), + ("layer", num(h.layer as f64)), ("layer_name", text(LAYERS[h.layer as usize])), + ]))?; + stream = Some(h); + } + Some(s) if !consistent => { + let (what, from, to) = differs(&s, &h); + self.check("frame", rules::env([ + ("at", num(p as f64)), ("what", text(what)), ("from", text(&from)), ("to", text(&to)), ("same", flag(false)), + ]))?; + } + _ => {} + } + if frames.is_empty() && info.is_none() && is_info(&d[at..at + len as usize], &h) { + info = Some((p, h)); + } else { + frames.push((p, h)); + } + p += len; + chained = true; + continue; + } + } + None => { + if free.is_none() && Head::free_format(&d[at..at + 4]) { + free = Some(p); + } + } + } + p += 1; + chained = false; + } + self.check("audio", rules::env([("frames", num(frames.len() as f64)), ("free", free.map_or(V::Absent, |o| num(o as f64)))]))?; + let s = stream.ok_or("internal: no stream")?; + // the elements, in the file's order + let info_json = match info { + Some((o, h)) => { + let len = h.length(); + self.ir.value(0, "info", NO_INDEX, &format!("bytes[{len}]"), 0, (o, len), Some(&d[o as usize..(o + len) as usize]))?; + info_json(&d[o as usize..(o + len) as usize], &h) + } + None => J::Null, + }; + let (first_o, last) = (frames[0].0, frames[frames.len() - 1]); + let span = last.0 + last.1.length() - first_o; + let frames_struct = self.ir.struct_(0, "frames", NO_INDEX, 0, Some((first_o, span)))?; + let form = json!({ + "geometry": {"samples": s.samples(), "channels": s.channels(), "data_type": "int16"}, + "codec": {"name": "mp3", "version": VERSIONS[s.version as usize], "sample_rate": s.rate()}, + "recipe": [["src", 0, null]], + }) + .to_string(); + let mut first = 0u32; + let mut main = Vec::with_capacity(frames.len()); + let mut used = 0u64; + let (mut crc_frames, mut kbps) = (0u64, Some(frames[0].1.kbps())); + for (j, (o, h)) in frames.iter().enumerate() { + let i = self.ir.data(frames_struct, "", j as u64, 0, (*o, h.length()), &form)?; + if j == 0 { + first = i; + } + main.push(h.main()); + used += h.length(); + crc_frames += h.crc as u64; + if kbps != Some(h.kbps()) { + kbps = None; + } + } + let id3v1_json = match id3v1 { + Some(o) => { + let i = self.ir.value(0, "id3v1", NO_INDEX, ID3V1_TYPE, 0, (o, 128), Some(&d[o as usize..o as usize + 128]))?; + json!(i) + } + None => J::Null, + }; + let ape_json = match ape { + Some((o, total, version, items)) => { + self.ir.value(0, "ape", NO_INDEX, &format!("bytes[{total}]"), 0, (o, total), None)?; + json!({"version": version, "size": total, "items": items}) + } + None => J::Null, + }; + let stray = e - a - used - info.map_or(0, |(_, h)| h.length()); + self.facts = json!({ + "frames": first, "count": frames.len(), "version": s.version, "layer": 3, "sample_rate": s.rate(), + "channels": s.channels(), "mode": frames[0].1.mode, "spf": s.samples(), "main": main, + "kbps": kbps, "crc_frames": crc_frames, "info": info_json, "id3v2": id3v2, "id3v1": id3v1_json, + "ape": ape_json, "stray": stray, "chunk_frames": chunk_frames(), + }); + Ok(()) + } + + pub fn finish(mut self) -> Result<(Ir, J), String> { + if self.phase != Phase::Done { + return Err("internal: the parse is not done".into()); + } + self.ir.sort_values(); + check::finish(&mut self.ir)?; + Ok((self.ir, self.facts)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A 128 kbps MPEG-1 Layer III header at 44.1 kHz, joint stereo, no CRC, no padding: 417 + /// bytes, 1152 samples, 32 bytes of side information. + #[test] + fn a_header_gives_the_frames_length_and_layout() { + let h = Head::parse(&[0xFF, 0xFB, 0x90, 0x64]).unwrap(); + assert_eq!((h.version, h.layer, h.crc, h.rate(), h.channels(), h.kbps()), (3, 1, false, 44100, 2, 128)); + assert_eq!((h.length(), h.samples(), h.side(), h.main()), (417, 1152, 32, 381)); + // MPEG-2.5 at 8 kHz, 16 kbps, mono, with padding and a CRC: 72 * 16000 / 8000 + 1 = 145 + let h = Head::parse(&[0xFF, 0xE2, 0x2A, 0xC0]).unwrap(); + assert_eq!((h.version, h.crc, h.rate(), h.channels(), h.length(), h.samples(), h.side(), h.main()), (0, true, 8000, 1, 145, 576, 9, 130)); + // a reserved version, a reserved layer, a free-format bitrate, an invalid sample rate + for b in [[0xFF, 0xEB, 0x90, 0x64], [0xFF, 0xF9, 0x90, 0x64], [0xFF, 0xFB, 0x00, 0x64], [0xFF, 0xFB, 0x9C, 0x64]] { + assert!(Head::parse(&b).is_none(), "{b:?}"); + } + assert!(Head::free_format(&[0xFF, 0xFB, 0x00, 0x64])); + assert!(detect(b"ID3\x04") && detect(&[0xFF, 0xFB, 0x90, 0x64]) && detect(&[0xFF, 0xFB, 0x00, 0x64]) && !detect(b"RIFF")); + assert!(!detect(&[0xFF, 0xFB, 0x9C, 0x64]) && !detect(&[0xFF, 0xEB, 0x90, 0x64])); + } +} diff --git a/rust/refkv-ir/src/project/mod.rs b/rust/refkv-ir/src/project/mod.rs index 55b0ae6e..203e5514 100644 --- a/rust/refkv-ir/src/project/mod.rs +++ b/rust/refkv-ir/src/project/mod.rs @@ -16,6 +16,7 @@ mod czi; mod dicom; mod ims; pub mod n5; +mod mp3; mod mrc; mod nd2; mod ndpi; @@ -47,6 +48,7 @@ pub fn project(format: Format, ir: &Ir, facts: &J, url: &str, copied: &Copied) - Format::Dicom => dicom::project(ir, facts, url), Format::Nifti => nifti::project(ir, facts, url, copied), Format::Mrc => mrc::project(ir, facts, url, copied), + Format::Mp3 => mp3::project(ir, facts, url, copied), Format::Ims => ims::project(ir, facts, url), Format::Safe => safe::project(ir, facts, url), Format::Ndpi => ndpi::project(ir, facts, url), diff --git a/rust/refkv-ir/src/project/mp3.rs b/rust/refkv-ir/src/project/mp3.rs new file mode 100644 index 00000000..0c961a9d --- /dev/null +++ b/rust/refkv-ir/src/project/mp3.rs @@ -0,0 +1,158 @@ +//! The MP3 projection (spec/virtualize/mp3.md §4 +//! , and the root's +//! source metadata of §5 +//! ) from a valid +//! IR: one array of samples whose chunks are runs of frames, each referenced with the frames +//! before it that a decoder needs (the lead-in, §17.2 +//! ). + +use super::{arr, json_text, n, Copied}; +use crate::ir::Ir; +use crate::mp3::{MODES, RESERVOIR, VERSIONS}; +use crate::out::{array_json, declare, group_doc, Out, Part}; +use crate::refs::Refs; +use serde_json::{json, Map, Value as J}; + +/// The array's path. +pub const ARRAY: &str = "samples"; + +/// The first frame of the lead-in of the chunk that starts at frame `a` (§17.2 +/// ): none for the +/// first chunk; else the two frames before the chunk, and before them the fewest frames whose +/// main data (`main`, per frame) holds at least `reservoir` bytes, or every frame from the +/// first when they do not. +pub fn lead(a: usize, main: &[u64], reservoir: u64) -> usize { + if a == 0 { + return 0; + } + let mut l = a.saturating_sub(2); + let mut have = 0u64; + while l > 0 && have < reservoir { + l -= 1; + have += main[l]; + } + l +} + +/// The ID3v1 tag's fields as the root's source metadata writes them (§5.1 +/// ). +fn id3v1_json(b: &[u8]) -> J { + let mut m = Map::new(); + m.insert("title".into(), json_text(&b[3..33])); + m.insert("artist".into(), json_text(&b[33..63])); + m.insert("album".into(), json_text(&b[63..93])); + m.insert("year".into(), json_text(&b[93..97])); + m.insert("comment".into(), json_text(&b[97..127])); + // ID3v1.1: a NUL at byte 125 makes byte 126 the track number + m.insert("track".into(), if b[125] == 0 && b[126] != 0 { json!(b[126]) } else { J::Null }); + m.insert("genre".into(), json!(b[127])); + J::Object(m) +} + +pub fn project(ir: &Ir, facts: &J, url: &str, _copied: &Copied) -> Result { + let first = n(&facts["frames"]) as usize; + let count = n(&facts["count"]) as usize; + let (spf, channels, rate) = (n(&facts["spf"]), n(&facts["channels"]), n(&facts["sample_rate"])); + let version = n(&facts["version"]) as usize; + let main: Vec = arr(&facts["main"]).iter().map(n).collect(); + let per_chunk = n(&facts["chunk_frames"]).max(1) as usize; + if count == 0 || main.len() != count { + return Err("internal: the facts do not describe the frames".into()); + } + let samples = count as u64 * spf; + + // the root (§5.1 ) + let mut own = Map::new(); + own.insert("version".into(), json!(VERSIONS[version])); + own.insert("layer".into(), json!(3)); + own.insert("sample_rate".into(), json!(rate)); + own.insert("channels".into(), json!(channels)); + own.insert("channel_mode".into(), json!(MODES[n(&facts["mode"]) as usize])); + own.insert("samples_per_frame".into(), json!(spf)); + own.insert("frames".into(), json!(count)); + own.insert("samples".into(), json!(samples)); + own.insert("duration".into(), json!(samples as f64 / rate as f64)); + let kbps = &facts["kbps"]; + own.insert("bitrate_mode".into(), json!(if kbps.is_null() { "variable" } else { "constant" })); + own.insert("bitrate_kbps".into(), kbps.clone()); + own.insert("crc_frames".into(), facts["crc_frames"].clone()); + if !facts["info"].is_null() { + own.insert("info".into(), facts["info"].clone()); + } + if arr(&facts["id3v2"]).first().is_some() { + own.insert("id3v2".into(), facts["id3v2"].clone()); + } + if let Some(i) = facts["id3v1"].as_u64() { + let b = ir.value_bytes(i as u32).ok_or("internal: the ID3v1 tag is not held")?; + own.insert("id3v1".into(), id3v1_json(b)); + } + if !facts["ape"].is_null() { + own.insert("ape".into(), facts["ape"].clone()); + } + own.insert("stray_bytes".into(), facts["stray"].clone()); + let mut out = Out::new(url); + out.json("zarr.json", &group_doc(declare(Map::new(), "mp3", Some(url), Some(J::Object(own))))); + + // the array (§4.1 ) + let shape = [samples, channels]; + let array = array_json(&shape, "int16", json!([per_chunk as u64 * spf, channels]), json!([{"name": "mp3"}]), &["time", "channel"]); + out.json(&format!("{ARRAY}/zarr.json"), &array); + let form = ir.form_id(first as u32).ok_or("internal: a frame has no form")?; + let chunks = count.div_ceil(per_chunk); + let mut refs = Refs::new(ir, out); + for q in 0..chunks { + let a = q * per_chunk; + let b = (a + per_chunk).min(count); + let keep = ((b - a) as u64 * spf) as u32; + let l = lead(a, &main, RESERVOIR[version]); + // the frames l to b - 1, a range per run of frames that follow each other (the bytes + // between frames, which are no frame, are left out: a decoder resyncs on them) + let mut parts = vec![Part::Lit(keep.to_le_bytes().to_vec())]; + for j in l..b { + let (o, n) = (ir.start[first + j], ir.len[first + j]); + match parts.last_mut() { + Some(Part::Src(po, pn)) if *po + *pn == o => *pn += n, + _ => parts.push(Part::Src(o, n)), + } + } + refs.chunk(format!("{ARRAY}/c/{q}/0"), ir.start[first + a], ir.len[first + a], form, Some(parts), q as u64)?; + } + let mut out = refs.out; + let mut tags = Vec::new(); + if arr(&facts["id3v2"]).first().is_some() { + tags.push("id3v2"); + } + if !facts["id3v1"].is_null() { + tags.push("id3v1"); + } + if !facts["ape"].is_null() { + tags.push("ape"); + } + out.summary = json!({ + "version": VERSIONS[version], "sampleRate": rate, "channels": channels, "frames": count, "samples": samples, + "duration": samples as f64 / rate as f64, "bitrate": if kbps.is_null() { json!("variable") } else { kbps.clone() }, + "chunkFrames": per_chunk, "chunks": chunks, "info": facts["info"]["tag"].clone(), "tags": tags, + "strayBytes": facts["stray"].clone(), + }); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::lead; + + /// Frames of 100 bytes of main data, a reservoir of 511: the first chunk has no lead-in; + /// a chunk at frame 8 takes frames 6 and 7, and before them the 6 frames that hold 600 + /// bytes (frames 0 to 5): all of them; at frame 12, frames 10 and 11 and then frames 4 + /// to 9 (600 bytes), so the lead-in starts at 4; a small reservoir takes one frame. + #[test] + fn the_lead_in_holds_the_reservoir_before_the_two_frames_before_the_chunk() { + let main = vec![100u64; 20]; + assert_eq!(lead(0, &main, 511), 0); + assert_eq!(lead(1, &main, 511), 0); + assert_eq!(lead(8, &main, 511), 0); + assert_eq!(lead(12, &main, 511), 4); + assert_eq!(lead(12, &main, 50), 9); + assert_eq!(lead(12, &main, 0), 10); + } +} diff --git a/rust/refkv-ir/src/rules.rs b/rust/refkv-ir/src/rules.rs index ba93bf84..f40b4651 100644 --- a/rust/refkv-ir/src/rules.rs +++ b/rust/refkv-ir/src/rules.rs @@ -39,6 +39,7 @@ pub static TIFF_SCHEMA: &str = include_str!("../schema/tiff.json"); pub static CZI_SCHEMA: &str = include_str!("../schema/czi.json"); pub static NIFTI_SCHEMA: &str = include_str!("../schema/nifti.json"); pub static MRC_SCHEMA: &str = include_str!("../schema/mrc.json"); +pub static MP3_SCHEMA: &str = include_str!("../schema/mp3.json"); pub static IMS_SCHEMA: &str = include_str!("../schema/ims.json"); pub static SAFE_SCHEMA: &str = include_str!("../schema/safe.json"); pub static NDPI_SCHEMA: &str = include_str!("../schema/ndpi.json"); @@ -91,6 +92,11 @@ pub fn mrc() -> &'static Rules { S.get_or_init(|| Rules::new(MRC_SCHEMA).expect("the MRC schema is valid")) } +pub fn mp3() -> &'static Rules { + static S: OnceLock = OnceLock::new(); + S.get_or_init(|| Rules::new(MP3_SCHEMA).expect("the MP3 schema is valid")) +} + pub fn vsi() -> &'static Rules { static S: OnceLock = OnceLock::new(); S.get_or_init(|| Rules::new(VSI_SCHEMA).expect("the VSI schema is valid")) diff --git a/rust/refkv-ir/src/run.rs b/rust/refkv-ir/src/run.rs index e087c45d..bfb32023 100644 --- a/rust/refkv-ir/src/run.rs +++ b/rust/refkv-ir/src/run.rs @@ -8,6 +8,7 @@ use crate::ims::Ims; use crate::ir::Ir; use crate::nd2::{Nd2, Step}; use crate::plan::{Planner, Request}; +use crate::mp3::Mp3; use crate::mrc::Mrc; use crate::nifti::Nifti; use crate::safe::Safe; @@ -31,6 +32,8 @@ pub enum Format { Ndpi, /// an MRC2014 file (a CCP4 map, an image stack, a volume or a volume stack) Mrc, + /// an MP3 file (MPEG-1, MPEG-2 or MPEG-2.5 Layer III audio), read whole + Mp3, /// An Olympus VSI slide: a `.vsi` file whose tile files lie beside it, virtualized as a /// store run of the `vsi` store format (spec/virtualize/vsi.md §15.2 /// ), never @@ -56,6 +59,7 @@ impl Format { Format::Safe => "safe", Format::Ndpi => "ndpi", Format::Mrc => "mrc", + Format::Mp3 => "mp3", Format::Vsi => "vsi", Format::Qptiff => "qptiff", } @@ -67,9 +71,9 @@ impl Format { matches!(self, Format::Vsi) } - /// The format of a name ("nd2", "tiff", "czi", "dicom", "nifti", "ims", "safe", "ndpi", "mrc", "vsi", "qptiff"). + /// The format of a name ("nd2", "tiff", "czi", "dicom", "nifti", "ims", "safe", "ndpi", "mrc", "vsi", "qptiff", "mp3"). pub fn from_name(s: &str) -> Option { - [Format::Nd2, Format::Tiff, Format::Czi, Format::Dicom, Format::Nifti, Format::Ims, Format::Safe, Format::Ndpi, Format::Mrc, Format::Vsi, Format::Qptiff].into_iter().find(|f| f.name() == s) + [Format::Nd2, Format::Tiff, Format::Czi, Format::Dicom, Format::Nifti, Format::Ims, Format::Safe, Format::Ndpi, Format::Mrc, Format::Vsi, Format::Qptiff, Format::Mp3].into_iter().find(|f| f.name() == s) } /// The format a file's first bytes give, if one of these: a hint, the format of its /// signature (any zip file is taken for a SAFE product's zip form, which then rejects one @@ -106,6 +110,9 @@ impl Format { if crate::mrc::detect(head) { return Some(Format::Mrc); } + if crate::mp3::detect(head) { + return Some(Format::Mp3); + } None } } @@ -119,6 +126,7 @@ pub enum AnyParser { Ims(Ims), Safe(Box), Mrc(Mrc), + Mp3(Mp3), /// a VSI file, which no file run reads (its parser is a store parser) Vsi, } @@ -134,6 +142,7 @@ impl AnyParser { Format::Ims => AnyParser::Ims(Ims::new(size)), Format::Safe => AnyParser::Safe(Box::new(Safe::new(size))), Format::Mrc => AnyParser::Mrc(Mrc::new(size)), + Format::Mp3 => AnyParser::Mp3(Mp3::new(size)), Format::Vsi => AnyParser::Vsi, } } @@ -147,6 +156,7 @@ impl AnyParser { AnyParser::Ims(x) => x.step(), AnyParser::Safe(x) => x.step(), AnyParser::Mrc(x) => x.step(), + AnyParser::Mp3(x) => x.step(), AnyParser::Vsi => Err("a VSI file is virtualized as a store run, with its tile files".into()), } } @@ -160,6 +170,7 @@ impl AnyParser { AnyParser::Ims(x) => x.feed(o, b), AnyParser::Safe(x) => x.feed(o, b), AnyParser::Mrc(x) => x.feed(o, b), + AnyParser::Mp3(x) => x.feed(o, b), AnyParser::Vsi => {} } } @@ -173,6 +184,7 @@ impl AnyParser { AnyParser::Ims(x) => Parser::finish(x), AnyParser::Safe(x) => Parser::finish(*x), AnyParser::Mrc(x) => Parser::finish(x), + AnyParser::Mp3(x) => Parser::finish(x), AnyParser::Vsi => Err("a VSI file is virtualized as a store run, with its tile files".into()), } } @@ -209,8 +221,8 @@ impl Run { copies: Vec::new(), copied: Default::default(), planner: Planner::new(size, remote, concurrency), - // TIFF and CZI find their ranges in whole responses; ND2 and SAFE take exact ranges - whole: !matches!(format, Format::Nd2 | Format::Safe), + // TIFF and CZI find their ranges in whole responses; ND2, SAFE and MP3 take exact ranges + whole: !matches!(format, Format::Nd2 | Format::Safe | Format::Mp3), in_batch: false, done: false, } diff --git a/rust/refkv-ir/tests/parsers.rs b/rust/refkv-ir/tests/parsers.rs index 9970e0ba..265c38e4 100644 --- a/rust/refkv-ir/tests/parsers.rs +++ b/rust/refkv-ir/tests/parsers.rs @@ -9,6 +9,7 @@ use refkv_ir::czi::Czi; use refkv_ir::dicom::Dicom; use refkv_ir::nifti::Nifti; use refkv_ir::ims::Ims; +use refkv_ir::mp3::Mp3; use refkv_ir::mrc::Mrc; use refkv_ir::parse_bytes; use refkv_ir::run::{run_bytes, Format}; @@ -261,6 +262,18 @@ reject!(mrc_reject_volume_stack_mz_zero, Mrc, "mrc/mrc_reject_volume_stack_mz_ze reject!(mrc_reject_truncated, Mrc, "mrc/mrc_reject_truncated.mrc", "the 48-byte data at 1024 is outside the 1071-byte file"); reject!(mrc_reject_data_overflow, Mrc, "mrc/mrc_reject_data_overflow.mrc", "the 4951760157141521099596496896-byte data at 1024 is outside the 1072-byte file"); reject!(mrc_reject_data_outside, Mrc, "mrc/mrc_reject_data_outside.mrc", "the 48-byte data at 1040 is outside the 1087-byte file"); +reject!(mp3_reject_layer2, Mp3, "mp3/mp3_reject_layer2.mp3", "MPEG-2 Layer II (the frame at 0) is not supported: only Layer III is"); +reject!(mp3_reject_layer1, Mp3, "mp3/mp3_reject_layer1.mp3", "MPEG-1 Layer I (the frame at 0) is not supported: only Layer III is"); +reject!(mp3_reject_free_format, Mp3, "mp3/mp3_reject_free_format.mp3", "free-format bitrate (the frame header at 0) is not supported"); +reject!(mp3_reject_sample_rate_change, Mp3, "mp3/mp3_reject_sample_rate_change.mp3", "the frame at "); +reject!(mp3_reject_channels_change, Mp3, "mp3/mp3_reject_channels_change.mp3", "the frame at "); +reject!(mp3_reject_version_change, Mp3, "mp3/mp3_reject_version_change.mp3", "the frame at "); +reject!(mp3_reject_layer_change, Mp3, "mp3/mp3_reject_layer_change.mp3", "the frame at "); +reject!(mp3_reject_no_frames, Mp3, "mp3/mp3_reject_no_frames.mp3", "no MPEG audio frames"); +reject!(mp3_reject_only_id3v1, Mp3, "mp3/mp3_reject_only_id3v1.mp3", "no MPEG audio frames"); +reject!(mp3_reject_id3v2_outside, Mp3, "mp3/mp3_reject_id3v2_outside.mp3", "the 100010-byte ID3v2 tag at 0 is outside the 60-byte file"); +reject!(mp3_reject_id3v2_syncsafe, Mp3, "mp3/mp3_reject_id3v2_syncsafe.mp3", "the ID3v2 tag at 0 has a size byte that is not syncsafe (128)"); +reject!(mp3_reject_short, Mp3, "mp3/mp3_reject_short.mp3", "file too short for an MPEG audio frame header"); reject!(ndpi_edge_reject_focal_planes, Tiff, "ndpi/edge_reject_ndpi_focal_planes.ndpi", "NDPI focal planes (two levels with one magnification) are not supported"); reject!(ndpi_edge_reject_growing, Tiff, "ndpi/edge_reject_ndpi_growing.ndpi", "NDPI levels do not decrease in size"); reject!(ndpi_edge_reject_mcustarts, Tiff, "ndpi/edge_reject_ndpi_mcustarts.ndpi", "McuStarts does not match the strip's intervals"); diff --git a/rust/refkv-ir/wasm/run.mjs b/rust/refkv-ir/wasm/run.mjs index 9fbb2b31..ca9f84aa 100644 --- a/rust/refkv-ir/wasm/run.mjs +++ b/rust/refkv-ir/wasm/run.mjs @@ -24,6 +24,8 @@ function format(fd) { if (s.startsWith("\x89HDF\r\n\x1a\n")) return "ims"; if (s.startsWith("PK\x03\x04")) return "safe"; // a SAFE product's zip form if (String.fromCharCode(...head.subarray(208, 211)) === "MAP") return "mrc"; + if (s.startsWith("ID3") || (head[0] === 0xff && (head[1] & 0xe0) === 0xe0 && (head[1] & 0x18) !== 0x08 && (head[1] & 0x06) !== 0 + && (head[2] & 0x0c) !== 0x0c)) return "mp3"; return "czi"; } diff --git a/spec/TECHNIQUES.md b/spec/TECHNIQUES.md index 4f1fdf04..52a94a55 100644 --- a/spec/TECHNIQUES.md +++ b/spec/TECHNIQUES.md @@ -188,6 +188,7 @@ From [§1](#1-three-kinds-of-information), [§4](#4-numeric-metadata-as-arrays) | DICOM | the dataset without Per-frame Functional Groups and without binary-VR values | Per-frame Functional Groups | `OF`/`OD`/`OL`/`OV` values; optionally derived per-frame position and time arrays | | NIfTI | the header; text extensions (comment, AFNI and CIFTI XML, JSON) as text; a derived orientation (which affine, whether it is applied) | none | none | | MRC | the header; the kind of file, the data type, whether the axis map is applied, the voxel size; the extended header by reference | none | none | +| MP3 | the stream's parameters (version, sample rate, channels, frames, bitrate), the Xing or Info frame's counts and LAME fields, the ID3v1 tag's fields; the ID3v2 and APE tags, and every frame, by reference | none | none | | IMS | none | the whole HDF5 tree, mirrored under `refkv_source/hdf5`: each group's attributes, links and named datatypes on its own group | every dataset but the image's (histograms, thumbnail, `DataSetTimes`, `Scene8` tables), by reference, typed when its elements are numbers; large attributes, copied | | N5 | each node's `attributes.json` without its layout members (`dimensions`, `blockSize`, `dataType`, `compression`, `n5`) | none | none | | Zarr v2, OME-Zarr | each node's `.zattrs` | none | none | diff --git a/spec/conventions.md b/spec/conventions.md index cc3bb288..3fc390ab 100644 --- a/spec/conventions.md +++ b/spec/conventions.md @@ -25,6 +25,7 @@ its JSON Schema `virtualize//schema.json`: | Zeiss CZI | [czi](virtualize/czi.md) | `7a0733c7-d4be-4482-a64f-6904d9354ea5` | | Olympus VSI | [vsi](virtualize/vsi.md) | `c0a1d4e6-5b7f-4f3a-9c2d-8e1f6a7b3c45` | | Akoya QPTIFF | [qptiff](virtualize/qptiff.md) | `329ea136-2f55-4c27-b6f4-8e60ca2109b8` | +| MP3 (MPEG Layer III audio) | [mp3](virtualize/mp3.md) | `b6cb0047-9448-4fdc-b03e-10dd3d6aa829` | A format's convention specifies the hierarchy's whole **Zarr layout**: which @@ -177,7 +178,8 @@ nests its own record. Unless the format's convention says otherwise (the store conventions, N5, Zarr v2 and OME-Zarr, define their arrays themselves; the CZI convention gives clipped levels the zarr-extensions `rectilinear` chunk grid and adds -the codecs `imagecodecs_jpegxr` and `numcodecs.shuffle`), every array's +the codecs `imagecodecs_jpegxr` and `numcodecs.shuffle`; the MP3 convention +defines the codec `mp3` and its own dimension names), every array's `zarr.json` is the JSON object: ```json diff --git a/spec/generate_schemas.py b/spec/generate_schemas.py index 3bccf668..f31b5572 100644 --- a/spec/generate_schemas.py +++ b/spec/generate_schemas.py @@ -563,6 +563,85 @@ ]} VSI_DEFS = {"vsiField": _VSI_FIELD} +SOURCE_METADATA["mp3"] = {"anyOf": [ + { + "type": "object", + "description": "The root's: the stream's parameters, derived from its frames, and the fields of its info frame and tags (spec/virtualize/mp3.md §5.1 )", + "properties": { + "version": {"enum": ["MPEG-1", "MPEG-2", "MPEG-2.5"]}, + "layer": {"const": 3}, + "sample_rate": {"enum": [8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000]}, + "channels": {"enum": [1, 2]}, + "channel_mode": {"enum": ["stereo", "joint_stereo", "dual_channel", "mono"], + "description": "The first audio frame's channel mode"}, + "samples_per_frame": {"enum": [576, 1152]}, + "frames": {"type": "integer", "minimum": 1, "description": "The audio frames (the info frame is not one)"}, + "samples": {"type": "integer", "minimum": 1, "description": "frames * samples_per_frame"}, + "duration": {"type": "number", "exclusiveMinimum": 0, "description": "samples / sample_rate, in seconds"}, + "bitrate_mode": {"enum": ["constant", "variable"]}, + "bitrate_kbps": {"type": ["integer", "null"], "description": "The bitrate of every audio frame, or null when they differ"}, + "crc_frames": {"type": "integer", "minimum": 0, "description": "The audio frames with a CRC"}, + "info": { + "type": "object", + "description": "The info frame's fields: a Xing, Info or VBRI tag, and a LAME tag after a Xing or Info one", + "properties": { + "tag": {"enum": ["Xing", "Info", "VBRI"]}, + "frames": {"type": ["integer", "null"]}, + "bytes": {"type": ["integer", "null"]}, + "quality": {"type": ["integer", "null"]}, + "encoder": {"type": ["string", "null"], "description": "The LAME tag's 9-byte encoder version, when printable"}, + "delay": {"type": ["integer", "null"], "description": "The encoder delay in samples (a LAME tag's, or a VBRI tag's)"}, + "padding": {"type": ["integer", "null"], "description": "The encoder padding in samples (a LAME tag's)"}, + }, + "required": ["tag", "frames", "bytes", "quality", "encoder", "delay", "padding"], + "additionalProperties": False, + }, + "id3v2": { + "type": "array", "minItems": 1, + "description": "The ID3v2 tags at the start, in order: each header's version, flags and size, and whether it has a footer", + "items": { + "type": "object", + "properties": { + "version": {"type": "array", "items": {"type": "integer", "minimum": 0, "maximum": 255}, "minItems": 2, "maxItems": 2}, + "flags": {"type": "integer", "minimum": 0, "maximum": 255}, + "size": {"type": "integer", "minimum": 0}, + "footer": {"type": "boolean"}, + }, + "required": ["version", "flags", "size", "footer"], + "additionalProperties": False, + }, + }, + "id3v1": { + "type": "object", + "description": "The ID3v1 tag's fields: each text up to its first NUL", + "properties": { + "title": {"$ref": "#/$defs/text"}, "artist": {"$ref": "#/$defs/text"}, "album": {"$ref": "#/$defs/text"}, + "year": {"$ref": "#/$defs/text"}, "comment": {"$ref": "#/$defs/text"}, + "track": {"type": ["integer", "null"], "description": "An ID3v1.1 tag's track number"}, + "genre": {"type": "integer", "minimum": 0, "maximum": 255}, + }, + "required": ["title", "artist", "album", "year", "comment", "track", "genre"], + "additionalProperties": False, + }, + "ape": { + "type": "object", + "description": "The APE tag's footer fields, and the tag's whole size", + "properties": {"version": {"type": "integer", "minimum": 0}, "size": {"type": "integer", "minimum": 32}, + "items": {"type": "integer", "minimum": 0}}, + "required": ["version", "size", "items"], + "additionalProperties": False, + }, + "stray_bytes": {"type": "integer", "minimum": 0, + "description": "The bytes between the tags that are in no frame"}, + }, + "required": ["version", "layer", "sample_rate", "channels", "channel_mode", "samples_per_frame", "frames", + "samples", "duration", "bitrate_mode", "bitrate_kbps", "crc_frames", "stray_bytes"], + "additionalProperties": False, + }, + {"$ref": "#/$defs/mirror"}, + {"$ref": "#/$defs/mirrorView"}, +]} + # The dataset layout of an MRC file (spec/virtualize/mrc-dataset.md §4 # ): the # MRC convention's root metadata, member for member, on the group `dataset`. diff --git a/spec/virtualize.md b/spec/virtualize.md index e96e4da4..71da1433 100644 --- a/spec/virtualize.md +++ b/spec/virtualize.md @@ -8,7 +8,9 @@ A **virtualizer** reads the structure of an image file or of a chunked array store (the formats of the table below), and writes a vzip archive ([spec/bindings/vzip.md](bindings/vzip.md)) that presents the pixels as an OME-Zarr dataset, or for a store as a Zarr v3 hierarchy (for an OME-Zarr 0.4 store, -an OME-Zarr 0.5 one; for a Sentinel-2 SAFE product, a GeoZarr one). The +an OME-Zarr 0.5 one; for a Sentinel-2 SAFE product, a GeoZarr one; for an +MP3 file, the one format that is not an image, a plain array of audio +samples). The pixels stay where they are: each Zarr chunk is a reference to byte ranges of the file, or to a whole object of the store. This document specifies, for each supported input format (a **profile**), exactly which archive a @@ -36,6 +38,7 @@ is a document of its own, numbered as a section of this one: | 14 | [MRC](virtualize/mrc.md#part-2-the-profile) | file | MRC2014 files (CCP4 maps, image stacks, volumes and volume stacks) | | 15 | [VSI](virtualize/vsi.md#part-2-the-profile) | file | Olympus cellSens VSI slides: a `.vsi` file and the tile files beside it, probed by key (uncompressed, JPEG and JPEG 2000 tiles) | | 16 | [QPTIFF](virtualize/qptiff.md#part-2-the-profile) | file | Akoya (PerkinElmer) Vectra QPTIFF slides and inForm component data, a TIFF with an XML description per image, in LZW, JPEG or uncompressed tiles or strips | +| 17 | [MP3](virtualize/mp3.md#part-2-the-profile) | file | MPEG-1, MPEG-2 and MPEG-2.5 Layer III audio files (`.mp3`), with their ID3v2, ID3v1 and APE tags; one array of samples, not an image | The table says which profiles read a single file (a **file input**), which a store of many @@ -162,6 +165,7 @@ a test that needs a byte beyond `H` does not match. | bytes 0–15 are `5A 49 53 52 41 57 46 49 4C 45` (`ZISRAWFILE`) and six `00` bytes | CZI profile ([§13](virtualize/czi.md#13-czi-profile)) | | bytes 208–210 are `4D 41 50` (`MAP`) | MRC profile ([§14](virtualize/mrc.md#14-mrc-profile)) | | bytes 0–3 are `49 49 2A 00` and bytes 8–11 are `18 00 49 53` (a cellSens container header) | VSI profile ([§15](virtualize/vsi.md#15-vsi-profile)), tested before the TIFF row | +| bytes 0–2 are `49 44 33` (`ID3`), or bytes 0–3 are an MPEG audio frame header: bits 31–21 all ones, bits 20–19 not `01`, bits 18–17 not `00`, bits 11–10 not `11` ([spec/virtualize/mp3.md §2](virtualize/mp3.md#2-frames); any layer and bitrate index) | MP3 profile ([§17](virtualize/mp3.md#17-mp3-profile)) | | anything else, including the JPEG 2000 signature box `00 00 00 0C 6A 50 20 20 0D 0A 87 0A` that starts legacy ND2 files, NIfTI header-and-image pairs (`ni1`, `ni2`) and pre-2000 MRC files without `MAP` | rejected | A DICOM file is read by the DICOM profile even when its 128-byte preamble @@ -539,7 +543,8 @@ other section: - [§13](virtualize/czi.md#13-czi-profile), the CZI profile: [spec/virtualize/czi.md](virtualize/czi.md#part-2-the-profile); - [§14](virtualize/mrc.md#14-mrc-profile), the MRC profile: [spec/virtualize/mrc.md](virtualize/mrc.md#part-2-the-profile); - [§15](virtualize/vsi.md#15-vsi-profile), the VSI profile: [spec/virtualize/vsi.md](virtualize/vsi.md#part-2-the-profile); -- [§16](virtualize/qptiff.md#16-qptiff-profile), the QPTIFF profile: [spec/virtualize/qptiff.md](virtualize/qptiff.md#part-2-the-profile). +- [§16](virtualize/qptiff.md#16-qptiff-profile), the QPTIFF profile: [spec/virtualize/qptiff.md](virtualize/qptiff.md#part-2-the-profile); +- [§17](virtualize/mp3.md#17-mp3-profile), the MP3 profile: [spec/virtualize/mp3.md](virtualize/mp3.md#part-2-the-profile). ## Appendix A. Conformance diff --git a/spec/virtualize/mp3.md b/spec/virtualize/mp3.md new file mode 100644 index 00000000..4923b49a --- /dev/null +++ b/spec/virtualize/mp3.md @@ -0,0 +1,536 @@ +# MP3 + +The Zarr layout of an MP3 file (MPEG-1, MPEG-2 or MPEG-2.5 Layer III audio, +`.mp3`), and the translation of its stream parameters and tags into JSON. +What all of refkv's conventions share is in +[spec/conventions.md](../conventions.md), cited here as "conventions §n". How +refkv produces this layout as a reference store, and which files it accepts, +is the MP3 profile, [Part 2](#part-2-the-profile) below. + +Convention version: 0 (until release, [conventions §1](../conventions.md#1-conventions)) · UUID: `b6cb0047-9448-4fdc-b03e-10dd3d6aa829` · +Schema: [schema.json](mp3/schema.json) + +Added 2026-10-21, after +[revision 29](../history/virtualize/REVISIONS.md#revision-29-no-recorded-revision-the-profile-registry) +(a profile's changes are recorded here, dated, from then on). + +This document has two parts. [Part 1](#part-1-the-convention) is the MP3 +convention: the Zarr layout, cited as "the convention §n". +[Part 2](#part-2-the-profile) is the MP3 profile: how refkv reads the source, +which inputs it rejects, and how each chunk references the source. The profile +keeps its numbering as a section of [spec/virtualize.md](../virtualize.md), +and is cited as spec/virtualize.md §n. + +MP3 is the one format among refkv's conventions whose source is not an +image: the hierarchy holds the decoded audio as one array of samples, with no +OME-NGFF metadata. The samples stay in the file as MPEG frames; the array's +codec decodes them when a chunk is read. + +# Part 1. The convention + +## 1. Declaration + +The root declares the convention by [conventions §2](../conventions.md#2-attributes), +with `"profile": "mp3"`, `"version": 0` ([conventions §1](../conventions.md#1-conventions)), the file's URL as `source.url`, +and the root's source metadata of [§5.1](#51-the-root) as the member `"mp3"`. Its CMO is: + +```json +{ + "uuid": "b6cb0047-9448-4fdc-b03e-10dd3d6aa829", + "schema_url": "https://raw.githubusercontent.com/d-v-b/refkv/refs/heads/main/spec/virtualize/mp3/schema.json", + "spec_url": "https://github.com/d-v-b/refkv/blob/main/spec/virtualize/mp3.md", + "name": "refkv_virtualized", + "description": "The Zarr layout of a MP3 source virtualized by refkv, and the source's metadata" +} +``` + +The root's attributes are `zarr_conventions` and `refkv_virtualized` and +nothing else: there is no `ome` member, since the hierarchy is not an image. +`refkv_source` declares the convention too, with the IR mirror's description +([§5.2](#52-the-ir-mirror), [conventions §8](../conventions.md#8-the-ir-mirror)), and so does each group of +the mirror's view that has a member; the array and the mirror's arrays have no +attributes. + +## 2. Frames + +An MP3 file is a sequence of **frames**, each a 4-byte **header** followed by +the frame's data, with tags ([§3](#3-tags)) before the first frame and after +the last. The header's bits, most significant first (byte 0 first): + +| bits | field | values | +|---|---|---| +| 31–21 | sync | all ones (`FF Ex` to `FF Fx`) | +| 20–19 | version | `00` MPEG-2.5, `01` reserved, `10` MPEG-2, `11` MPEG-1 | +| 18–17 | layer | `00` reserved, `01` Layer III, `10` Layer II, `11` Layer I | +| 16 | protection | `0`: a 16-bit CRC follows the header; `1`: none | +| 15–12 | bitrate index | 0 free format, 15 invalid; else a table entry | +| 11–10 | sample rate index | 3 invalid; else a table entry | +| 9 | padding | the frame is one byte longer | +| 8 | private | | +| 7–6 | channel mode | `00` stereo, `01` joint stereo, `10` dual channel, `11` mono | +| 5–4 | mode extension | | +| 3 | copyright | | +| 2 | original | | +| 1–0 | emphasis | | + +A header is **valid** when its sync is all ones, its version and layer are +not reserved, its bitrate index is from 1 to 14 and its sample rate index is +from 0 to 2. (A free-format header is one that is valid but for a bitrate +index of 0.) The header gives: + +- the **sample rate**, by version and index: MPEG-1 44100, 48000, 32000; + MPEG-2 22050, 24000, 16000; MPEG-2.5 11025, 12000, 8000; +- the **bitrate** in kbps, by version, layer and index 1 to 14: for Layer + III, MPEG-1 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, + and MPEG-2 and 2.5 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, + 160 (Layer I and II frames, which the profile rejects, have their own + tables: Layer I 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, + 416, 448 for MPEG-1 and 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, + 192, 224, 256 for MPEG-2 and 2.5; Layer II 32, 48, 56, 64, 80, 96, 112, + 128, 160, 192, 224, 256, 320, 384 for MPEG-1, and Layer III's for MPEG-2 + and 2.5); +- the **channel count** `C`: 1 for mono, 2 otherwise; +- the frame's **length** `L` in bytes, header included, with `b` the bitrate + in bits per second, `r` the sample rate and `p` the padding bit, integer + division: Layer III `144 × b / r + p` for MPEG-1 and `72 × b / r + p` for + MPEG-2 and 2.5 (Layer II `144 × b / r + p`; Layer I `(12 × b / r + p) × 4`); +- the **samples per channel** `S` a Layer III frame decodes to: 1152 for + MPEG-1, 576 for MPEG-2 and 2.5. + +A Layer III frame is its header, the 2-byte CRC when the protection bit is +0, the **side information** (MPEG-1: 17 bytes for mono, 32 otherwise; +MPEG-2 and 2.5: 9 for mono, 17 otherwise), and the **main data** that fills +the rest of the frame, `M = L − 4 − 2 × crc − side` bytes. A frame's own +audio may begin before its main data, in the main data of earlier frames (the +**bit reservoir**): the side information's `main_data_begin` says how many +bytes back, at most 511 for MPEG-1 and 255 for MPEG-2 and 2.5. So a frame +cannot be decoded alone; [§4.1](#41-the-array) says what a chunk holds so +that it can be. + +Two frames are of **one stream** when they agree on the version, the layer, +the sample rate and the channel count. The bitrate, the padding, the +protection and the channel mode may differ from frame to frame (a variable +bitrate stream; joint stereo). + +**The info frame.** An encoder may start the stream with a frame that holds +no audio but a tag describing the stream: a **Xing** or **Info** tag (the +ASCII `Xing` or `Info` right after the side information, that is at offset +`4 + 2 × crc + side` of the frame), followed by a 32-bit big-endian flags +word and, for each flag bit set, the frame count (bit 0), the byte count (bit +1), a 100-byte seek table (bit 2) and a quality indicator (bit 3), each +32-bit big-endian; a **LAME tag** may follow: a 9-byte encoder version +(`LAME3.100`, `Lavc61.19`), then its fields, among them at offset 21 from +the version's start the encoder **delay** (12 bits) and **padding** (12 +bits) in samples. A **VBRI** tag is the ASCII `VBRI` at offset 36 of the +frame, followed by a 16-bit version, a 16-bit delay, a 16-bit quality, the +byte count and the frame count (32-bit, all big-endian). The convention does +not count an info frame as audio ([§4.1](#41-the-array)) and records its +fields ([§5.1](#51-the-root)). + +## 3. Tags + +An **ID3v2 tag** at the start of the file: `ID3`, a version (2 bytes: the +major version and the revision), a flags byte, and a 4-byte **syncsafe** +size (each byte below `80`; the size is their 7-bit values, most significant +first), the 10-byte header; then `size` bytes of frames and padding; then, +when bit 4 (`10`) of the flags is set, a 10-byte footer. A file may hold +several, one after the other. The convention does not decode the tag's +frames. + +An **ID3v1 tag** is the last 128 bytes of the file when they start with +`TAG`: the title, artist and album (30 bytes each), the year (4), the +comment (30; in ID3v1.1, when its byte 28 is NUL, byte 29 is the track +number) and the genre (1 byte). + +An **APE tag** ends with a 32-byte footer, right before the ID3v1 tag or at +the end of the file: `APETAGEX`, the version, the size (the items and the +footer, not the header), the item count and the flags (32-bit little-endian +each; bit 31 of the flags: the tag also has a 32-byte header before its +items), then 8 reserved bytes. The tag is `size` bytes, plus 32 when it has +a header, ending at the footer's end; a footer whose tag would start before +the first byte after the ID3v2 tags, or whose size is below 32, is not an +APE tag. The convention does not decode the items. + +## 4. The layout + +The hierarchy is a group at the root with one array at path `samples`: the +root `zarr.json` is `{"zarr_format": 3, "node_type": "group", "attributes": +A}`, where `A` has `zarr_conventions` and `refkv_virtualized` ([§1](#1-declaration)) and +nothing else. + +### 4.1 The array + +The **audio frames** are the stream's frames in file order, numbered from 0, +without the info frame ([§2](#2-frames)); `N` is their count ([spec/virtualize.md §17.1](#171-the-stream) +says which frames those are). Every audio frame has the stream's `S` +and `C`. + +- **Shape:** `[N × S, C]`: the samples per channel, then the channels. +- **Data type:** `int16`. +- **Dimension names:** `["time", "channel"]`. +- **Chunk shape:** `[F × S, C]` with **`F = 256` frames per chunk**: chunk `q` + holds the audio frames `q × F` to `min(N, (q + 1) × F) − 1`, which are + **its frames**; the last chunk may hold fewer than `F`, and is padded + to the chunk shape by the codec. The chunk grid is regular; the chunk + keys are `samples/c//0`. +- **Codecs:** `[{"name": "mp3"}]`, the array-to-bytes codec this convention + defines below. +- **Fill value:** 0. + +The array's `zarr.json` is otherwise [conventions §3](../conventions.md#3-arrays)'s. + +**The `mp3` codec.** The bytes of a chunk are: + +1. a 32-bit little-endian unsigned integer **`keep`**, the samples per + channel of the chunk's frames: `F × S`, or fewer for the last chunk; +2. then MPEG audio frames, one after the other in file order (whatever + the file holds between two frames is left out): the chunk's + **lead-in**, the frames before its first frame that a decoder needs to + have decoded so that it decodes the chunk's frames as a decoder of the + whole stream does ([spec/virtualize.md §17.2](#172-chunks) says which); + then the chunk's frames. + +Decoding a chunk is decoding the frames of 2 in one pass as a Layer III +decoder decodes a stream (the frames of the lead-in supply the bit +reservoir and the overlap state of the filter bank for the chunk's frames; +their own output is discarded), to 16-bit PCM of the stream's `C` channels, +interleaved by sample, and taking the **last `keep` samples** of the output: +they are the chunk's samples, from the chunk's first sample on, followed by +zeros up to the chunk shape. A decoder that cannot decode a frame whose +reservoir it does not hold (the stream's first frames, which have no +lead-in) gives fewer than `keep` samples for the first chunk; they are the +last of the `keep`, preceded by zeros. The channel count of the frames is +the array's; a chunk whose frames differ from it, or whose `keep` exceeds +the chunk shape, is an error. + +### 4.2 The samples + +Sample `i` of channel `c` is the output of a Layer III decoder, as ISO/IEC +11172-3 and 13818-3 define it, decoding the audio frames one after the +other from the first (the frames alone: not the bytes between them), as a +signed 16-bit integer (rounded from the decoder's output), the +`i`th sample it produces for channel `c`; the decoder starts with an empty +bit reservoir and a zero filter-bank state, and its first frame's output is +what it is (the stream's encoder delay and the decoder's own delay, about +529 samples, are in the array; neither is trimmed). The samples of a frame +are its `S` outputs; a frame the decoder cannot decode (its reservoir +reaches before the first audio frame) gives zeros. Two conforming decoders +may differ by a few units in a sample (the standards allow it); the +convention does not say which decoder a reader uses, and +[spec/virtualize.md §1.1](../virtualize.md#11-output-and-equivalence) compares +references, not samples. + +The sample rate is `sample_rate` of [§5.1](#51-the-root); sample `i` is at +`i / sample_rate` seconds from the stream's start. + +## 5. Source metadata + +The root's source metadata `S` ([conventions §2](../conventions.md#2-attributes)) +is an object with the members of [§5.1](#51-the-root), in that order, +translated by [conventions §6](../conventions.md#6-source-metadata-as-json). +Everything the file holds is on `refkv_source`, the file's **IR mirror** +([§5.2](#52-the-ir-mirror)), from which the file is rebuilt byte for byte. + +### 5.1 The root + +- `version`: `"MPEG-1"`, `"MPEG-2"` or `"MPEG-2.5"`, the stream's. +- `layer`: 3. +- `sample_rate`: the stream's, in Hz. +- `channels`: `C`. +- `channel_mode`: the first audio frame's: `"stereo"`, `"joint_stereo"`, + `"dual_channel"` or `"mono"`. +- `samples_per_frame`: `S`. +- `frames`: `N`, the audio frames. +- `samples`: `N × S`. +- `duration`: `N × S / sample_rate`, in seconds, as a number. +- `bitrate_mode`: `"constant"` when every audio frame has the same bitrate + index, else `"variable"`. +- `bitrate_kbps`: that bitrate in kbps, or `null` when variable. +- `crc_frames`: how many audio frames have the protection bit 0 (a CRC). +- `info`: when the stream starts with an info frame ([§2](#2-frames)), an + object `{"tag": T, "frames": n, "bytes": n, "quality": n, "encoder": e, + "delay": n, "padding": n}`: `T` is `"Xing"`, `"Info"` or `"VBRI"`; the + counts and the quality are those the tag holds, `null` for each it does + not (a Xing or Info flag bit clear; a value past the frame's end); + `encoder` is the LAME tag's 9-byte version as text: its bytes up to the + first NUL, when there is at least one, they are printable ASCII (`20` to + `7E`) and the first is not a space, trailing spaces removed (`LAME3.99r`, + `LAME4.0`, `Lavc63.1.`), else `null`; `delay` and `padding` are its + encoder delay and padding, `null` without an encoder or when the frame + ends before them; for a VBRI tag, `delay` is the tag's and `encoder` and + `padding` are `null`. Absent when there is no info frame. +- `id3v2`: an array with one object per ID3v2 tag at the start, in order, + `{"version": [major, revision], "flags": f, "size": n, "footer": b}` + (the header's bytes 3 and 4, its flags byte, its size field, and whether + it has a footer). Absent when there is none. +- `id3v1`: when the file ends with an ID3v1 tag, `{"title", "artist", + "album", "year", "comment"}`, each field's bytes up to its first NUL as a + text value, `"track"`, the track number of an ID3v1.1 tag (byte 28 of the + comment NUL and byte 29 not) or `null`, and `"genre"`, the genre byte. + Absent otherwise. +- `ape`: when the file has an APE tag, `{"version": v, "size": n, "items": + n}`: the footer's version and item count, and the tag's whole size + (header included). Absent otherwise. +- `stray_bytes`: the bytes between the ID3v2 tags and the trailing tags + that are in no frame (junk between frames, a truncated last frame, + bytes after the last frame). + +`version` to `crc_frames` are derived from the frames, so that a reader +need not read a header; the tags' fields are there so that a reader finds +the stream's description and the file's labels without decoding the tags. +The ID3v2 and APE tags' items are in the mirror, not decoded. + +### 5.2 The IR mirror + +`refkv_source` is the file's **IR mirror** +([conventions §8](../conventions.md#8-the-ir-mirror)): its table, from which the +file is rebuilt byte for byte, and its view, `refkv_source/tree`. The IR's +elements (the MP3 **source model**) are below; nothing is left out: the +tags, the info frame, every audio frame, and every byte between or after +them. + +### 5.3 Elements + +Paths are relative to the IR's root, whose path is `""` +([conventions §8.1](../conventions.md#81-elements)). Types are those of +[conventions §8.6](../conventions.md#86-types); `n` is a tag's size, `L` a +frame's length. + +| path | kind | type | extent: what | +|---|---|---|---| +| `""` | struct | | the root: the whole file, `(0, size)` | +| `id3v2/` | struct | | the `k`-th ID3v2 tag at the start, `k` from 0: the whole tag | +| `id3v2//header` | value | `{id:ascii[3],version:u1[2],flags:u1,size:u1[4]}` | its 10-byte header | +| `id3v2//data` | value | `bytes[n]` | its `n` bytes of frames and padding, when `n > 0`, opaque | +| `id3v2//footer` | value | `bytes[10]` | its footer, when it has one | +| `info` | value | `bytes[L]` | the info frame ([§2](#2-frames)), when the stream starts with one | +| `frames` | struct | | the audio frames: from the first's start to the last's end | +| `frames/` | data | | audio frame `j`, `j` from 0, `(offset, L)`, header included; its form's geometry is `{"samples": S, "channels": C, "data_type": "int16"}`, its codec `{"name": "mp3", "version": V, "sample_rate": r}`, and its recipe `[["src", 0, null]]` | +| `id3v1` | value | `{tag:ascii[3],title:cstr[30],artist:cstr[30],album:cstr[30],year:ascii[4],comment:cstr[30],genre:u1}` | the ID3v1 tag, `(size − 128, 128)`, when there is one | +| `ape` | value | `bytes[n]` | the APE tag, header and footer included, when there is one, opaque | +| `gaps/` | gap | | bytes no other element claims: junk between frames, a truncated last frame, bytes after the last frame | + +The view ([conventions §8.7](../conventions.md#87-the-view)) shows each ID3v2 +header and the ID3v1 tag; the tags' bodies, the info frame and the APE tag, +opaque bytes, are not shown. + +### 5.4 Equivalence + +The elements are determined by the file, and the mirror by its elements +([conventions §8.8](../conventions.md#88-canonical-form): one order, one folding, one encoding): hierarchies of one +file are compared entry for entry, `refkv_source` included +([spec/virtualize.md §1.1](../virtualize.md#11-output-and-equivalence)). + +## 6. Example + +A mono MPEG-2 stream at 16 kHz and 32 kbps of 22 frames and no tags, at +`https://example.org/clip.mp3` (the fixture +`fixtures/mp3/mp3_mpeg2_mono_16k.mp3`, 3168 bytes of frames of 144 bytes), +has this root `zarr.json`: + +```json +{ + "zarr_format": 3, + "node_type": "group", + "attributes": { + "zarr_conventions": [{ + "uuid": "b6cb0047-9448-4fdc-b03e-10dd3d6aa829", + "schema_url": "https://raw.githubusercontent.com/d-v-b/refkv/refs/heads/main/spec/virtualize/mp3/schema.json", + "spec_url": "https://github.com/d-v-b/refkv/blob/main/spec/virtualize/mp3.md", + "name": "refkv_virtualized", + "description": "The Zarr layout of a MP3 source virtualized by refkv, and the source's metadata" + }], + "refkv_virtualized": { + "profile": "mp3", + "version": 0, + "source": {"url": "https://example.org/clip.mp3"}, + "mp3": { + "version": "MPEG-2", + "layer": 3, + "sample_rate": 16000, + "channels": 1, + "channel_mode": "mono", + "samples_per_frame": 576, + "frames": 22, + "samples": 12672, + "duration": 0.792, + "bitrate_mode": "constant", + "bitrate_kbps": 32, + "crc_frames": 0, + "stray_bytes": 0 + } + } + } +} +``` + +Its array `samples/zarr.json` has the shape `[12672, 1]`, the chunk shape +`[147456, 1]` (256 frames of 576 samples), the data type `int16`, the codecs +`[{"name": "mp3"}]` and the dimension names `["time", "channel"]`. The one +chunk, `samples/c/0/0`, is the literal `80 31 00 00` (`keep = 12672`) then +the range `(0, 3168)` of source 0: every frame, no lead-in. Read with 4 +frames a chunk instead (as the tests do), chunk 3 holds frames 12 to 15, +`keep = 2304`, and its range is `(1152, 1152)`: it starts at frame 8, since +the lead-in is frames 10 and 11, the two before the chunk, and before them +frames 8 and 9, whose main data (131 bytes each: `144 − 4 − 9`) holds 262 +bytes, at least the 255-byte reservoir of MPEG-2. + +## 7. What the layout does not express + +This section is informative. + +- **Gapless playback.** The encoder delay and padding of a LAME tag say + which samples at the start and the end are the encoder's, not the + recording's; players trim them. The array holds every decoded sample and + records the two counts in `info`, so a reader that wants the recording + alone drops `delay + 529` samples at the start (529 being a decoder's own + delay) and `padding − 529` at the end, as players do. +- **The tags' items.** An ID3v2 tag's frames (title, artist, cover art, + lyrics) and an APE tag's items are kept whole in the mirror and not + decoded; `id3v1` is, as it is fixed-width. +- **Per-frame values.** The bitrate, padding and channel mode of each + frame, and the seek table of a Xing tag, are in the frames and the info + frame in the mirror, not in the root. +- **The decoder.** Which decoder, and whether it uses the gapless + information or the seek table, is the reader's choice; the convention + says what the samples are, to the accuracy the standards allow. +- **A broken reservoir.** A frame whose bit reservoir reaches into frames + the stream does not hold (two streams cut and joined, a frame lost) + decodes as the reader's decoder has it: some decoders skip it, which + shifts the samples of its chunk by a frame; others give noise or + silence for it. Only the stream's first frames, which have no earlier + frames, are such frames in a well-formed file. + +# Part 2. The profile + +The MP3 profile of [spec/virtualize.md](../virtualize.md), numbered as its +[§17](#17-mp3-profile). [§1](../virtualize.md#1-introduction) and +[§2](../virtualize.md#2-the-zarr-layout) are in spec/virtualize.md and apply +here. + +The output has the convention's layout for the input +([spec/virtualize.md §2](../virtualize.md#2-the-zarr-layout)); this profile says +which inputs are accepted and how each chunk references the file. "The +convention §n" below is a section of Part 1. + +## 17. MP3 profile + +### 17.1 The stream + +The input is an MP3 file, which [§1.2](../virtualize.md#12-input) chose by +`ID3` at offset 0, or a frame header at offset 0 whose sync, version, layer +and sample rate index are valid, of any layer and bitrate index +([the convention §2](#2-frames)). The whole file is read: an MP3 file has +no index, and each frame's header gives the next. + +**Checks.** The input is rejected unless all of these hold, checked in this +order: + +- **Size:** the file is at least 4 bytes. +- **ID3v2 tags:** for each tag at the start (`ID3` at offset 0, and again + at the end of each tag): the file has its 10-byte header; its four size + bytes are each below `80`; and the tag, footer included, ends within the + file. +- **The stream:** the file has a first confirmed frame (below), and it is + Layer III. A Layer I or Layer II first frame rejects by name + (`MPEG-2 Layer II (the frame at 0) is not supported: only Layer III is`). +- **One stream:** no confirmed frame changes the version, the layer, the + sample rate or the channel count of the stream (the first confirmed + frame's): `the frame at 2016 changes the sample rate from 16000 to 22050`. +- **Audio:** at least one audio frame. Without one, the input is rejected + as `free-format bitrate (the frame header at ) is not supported` + when the scan met a free-format header (the first one), else as `no MPEG + audio frames`. + +**Tags.** The ID3v2 tags are those at the start, one after the other, each +as [the convention §3](#3-tags) measures it; `A` is the offset after the last +(0 without one). The ID3v1 tag is the last 128 bytes when they start with +`TAG` and lie at or after `A`; `E` is its offset, else the file's size. An +APE footer ending at `E`, whose tag ([the convention §3](#3-tags)) is at +least 32 bytes and starts at or after `A`, makes the tag end at `E` and +moves `E` to the tag's start. The frames are scanned in `[A, E)`. + +**Frames.** The scan keeps a position `p`, from `A`, and the **stream** +(the first confirmed frame's version, layer, sample rate and channel +count; none at first). At `p`, when `p + 4 ≤ E` and the header at `p` is +valid ([the convention §2](#2-frames)), with its frame length `L`, the +frame **fits** when `p + L ≤ E`; it is **consistent** when there is no +stream yet or it is of the stream; the **next header** is valid and of the +same stream as this header when `p + L + 4 ≤ E` and the 4 bytes at `p + L` +are a valid header that agrees with this one on the version, layer, sample +rate and channel count. The frame is **confirmed** when it fits and one of +these holds: + +- it is **chained** (the previous confirmed frame ended at `p`) and + consistent; +- there is no stream yet and `p + L = E` (a file of one frame); +- the next header is valid and of the same stream as this header. + +A confirmed frame that is not consistent rejects the input (the one-stream +check); the first confirmed frame sets the stream (the Layer III check). A +confirmed frame is a frame: `p` moves to `p + L`, and the next frame is +chained. Otherwise, or when the header at `p` is not valid, `p` moves to +`p + 1` and the next frame is not chained. The scan ends when `p + 4 > E`. +A valid header that is not confirmed, a frame that does not fit (the last +frame, truncated), and every byte between confirmed frames are not frames. + +**The info frame.** The first confirmed frame is the **info frame** when it +holds a Xing, Info or VBRI tag ([the convention §2](#2-frames)); it is not +an audio frame. Every other confirmed frame is an audio frame, numbered +from 0 in order. + +So the scan accepts a frame reached by the chain without looking ahead, +as decoders do; a frame found after junk (or the first) needs the next +frame to agree with it, so that a sync in junk is not a frame; a parameter +change is only taken for one when two frames of the new parameters follow +each other; and trailing junk, a truncated last frame and a tag the +convention does not know are bytes between or after frames, kept as gaps +([the convention §5.3](#53-elements)). + +### 17.2 Chunks + +The array has `ceil(N / F)` chunks, `F = 256`, chunk `q` holding the audio +frames `a = q × F` to `b − 1`, `b = min(N, a + F)` +([the convention §4.1](#41-the-array)). Every chunk is present. Chunk `q`, at +`samples/c//0`, is the reference of two ranges: + +1. the literal range of 4 bytes `keep = (b − a) × S` as a 32-bit + little-endian unsigned integer; +2. the audio frames `l` to `b − 1` as ranges of source 0, in order, one + range per run of frames that follow each other in the file: a frame that + starts where the frame before it ends extends that frame's range, and + any other frame starts a new one (so the bytes between frames, which are + no frame, are in no range). `l` is the first frame of the **lead-in**: + `l = a` for the first chunk (`a = 0`); otherwise `l` is the greatest + index at most `a − 2` such that the main data of frames `l` to `a − 3` + ([the convention §2](#2-frames), `M` of each) sums to at least `R`, + where `R = 511` for MPEG-1 and `255` for MPEG-2 and 2.5, or `l = 0` when + no index does. + +So the lead-in is the two frames before the chunk, and before them the +fewest frames whose main data holds the largest bit reservoir the version +allows: a decoder that decodes the lead-in holds the reservoir the two +frames need, the two frames give it the filter bank's overlap and the +synthesis state the chunk's first frame needs (the frame before it in +MPEG-1, where a frame has two granules; the two frames before it in MPEG-2 +and 2.5, where a frame has one), and the chunk's frames then decode as +they do in the stream. Decoding the lead-in costs a few frames' work per +chunk and no extra reads. Leaving the bytes between frames out keeps a +decoder from resyncing in the middle of a chunk: a decoder that looks for a +run of frames to lock onto would drop the frames before junk, and one that +resets its state on junk would decode the frame after it from nothing. + +The ranges lie within the file (frames are found within `[A, E)`). The +output has no data sources: its source table is source 0 alone. +`refkv_source` is the IR mirror ([the convention §5.2](#52-the-ir-mirror)). The archive +has no other entries than the root's and the array's `zarr.json`, the +array's chunks, and the mirror's. + +### 17.3 Summary + +This section is informative. Both implementations print a one-line JSON +summary: `version`, `sampleRate`, `channels`, `frames` (the audio frames), +`samples`, `duration` (seconds), `bitrate` (kbps, or `"variable"`), +`chunkFrames` (`F`), `chunks`, `info` (the info frame's tag, or `null`), +`tags` (those of `"id3v2"`, `"id3v1"` and `"ape"` the file has, in that +order) and `strayBytes`. diff --git a/spec/virtualize/mp3/schema.json b/spec/virtualize/mp3/schema.json new file mode 100644 index 00000000..c7e5b48f --- /dev/null +++ b/spec/virtualize/mp3/schema.json @@ -0,0 +1,616 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/d-v-b/refkv/refs/heads/main/spec/virtualize/mp3/schema.json", + "title": "refkv MP3 convention, version 0", + "description": "The zarr.json of a node that declares the refkv MP3 convention (https://github.com/d-v-b/refkv/blob/main/spec/virtualize/mp3.md)", + "type": "object", + "properties": { + "zarr_format": { + "type": "integer", + "const": 3 + }, + "node_type": { + "type": "string", + "enum": [ + "group", + "array" + ] + }, + "attributes": { + "type": "object", + "properties": { + "ome": { + "type": "object", + "description": "OME-NGFF 0.5 metadata" + }, + "zarr_conventions": { + "type": "array", + "contains": { + "$ref": "#/$defs/conventionMetadata" + }, + "minContains": 1, + "maxContains": 1 + }, + "refkv_virtualized": { + "oneOf": [ + { + "$ref": "#/$defs/rootProperty" + }, + { + "$ref": "#/$defs/nodeProperty" + } + ] + } + }, + "required": [ + "zarr_conventions", + "refkv_virtualized" + ], + "additionalProperties": false + } + }, + "required": [ + "zarr_format", + "node_type", + "attributes" + ], + "$defs": { + "conventionMetadata": { + "type": "object", + "description": "The MP3 convention's metadata object in zarr_conventions", + "properties": { + "uuid": { + "type": "string", + "const": "b6cb0047-9448-4fdc-b03e-10dd3d6aa829" + }, + "schema_url": { + "type": "string", + "const": "https://raw.githubusercontent.com/d-v-b/refkv/refs/heads/main/spec/virtualize/mp3/schema.json" + }, + "spec_url": { + "type": "string", + "const": "https://github.com/d-v-b/refkv/blob/main/spec/virtualize/mp3.md" + }, + "name": { + "type": "string", + "const": "refkv_virtualized" + }, + "description": { + "type": "string", + "const": "The Zarr layout of a MP3 source virtualized by refkv, and the source's metadata" + } + }, + "required": [ + "uuid" + ], + "additionalProperties": false + }, + "rootProperty": { + "type": "object", + "description": "The root's property: the profile and version that produced the hierarchy, its source, and the root's source metadata", + "properties": { + "profile": { + "type": "string", + "const": "mp3" + }, + "version": { + "type": "integer", + "const": 0 + }, + "source": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL of the source file" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "layouts": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "string", + "pattern": "^[^/]+(/[^/]+)*$" + }, + "description": "The other layouts of the source in this hierarchy, by name, each the path of its node below the root (spec/conventions.md §9 )" + }, + "mp3": { + "$ref": "#/$defs/sourceMetadata" + } + }, + "required": [ + "profile", + "version", + "source" + ], + "additionalProperties": false + }, + "sourceMetadata": { + "anyOf": [ + { + "type": "object", + "description": "The root's: the stream's parameters, derived from its frames, and the fields of its info frame and tags (spec/virtualize/mp3.md §5.1 )", + "properties": { + "version": { + "enum": [ + "MPEG-1", + "MPEG-2", + "MPEG-2.5" + ] + }, + "layer": { + "const": 3 + }, + "sample_rate": { + "enum": [ + 8000, + 11025, + 12000, + 16000, + 22050, + 24000, + 32000, + 44100, + 48000 + ] + }, + "channels": { + "enum": [ + 1, + 2 + ] + }, + "channel_mode": { + "enum": [ + "stereo", + "joint_stereo", + "dual_channel", + "mono" + ], + "description": "The first audio frame's channel mode" + }, + "samples_per_frame": { + "enum": [ + 576, + 1152 + ] + }, + "frames": { + "type": "integer", + "minimum": 1, + "description": "The audio frames (the info frame is not one)" + }, + "samples": { + "type": "integer", + "minimum": 1, + "description": "frames * samples_per_frame" + }, + "duration": { + "type": "number", + "exclusiveMinimum": 0, + "description": "samples / sample_rate, in seconds" + }, + "bitrate_mode": { + "enum": [ + "constant", + "variable" + ] + }, + "bitrate_kbps": { + "type": [ + "integer", + "null" + ], + "description": "The bitrate of every audio frame, or null when they differ" + }, + "crc_frames": { + "type": "integer", + "minimum": 0, + "description": "The audio frames with a CRC" + }, + "info": { + "type": "object", + "description": "The info frame's fields: a Xing, Info or VBRI tag, and a LAME tag after a Xing or Info one", + "properties": { + "tag": { + "enum": [ + "Xing", + "Info", + "VBRI" + ] + }, + "frames": { + "type": [ + "integer", + "null" + ] + }, + "bytes": { + "type": [ + "integer", + "null" + ] + }, + "quality": { + "type": [ + "integer", + "null" + ] + }, + "encoder": { + "type": [ + "string", + "null" + ], + "description": "The LAME tag's 9-byte encoder version, when printable" + }, + "delay": { + "type": [ + "integer", + "null" + ], + "description": "The encoder delay in samples (a LAME tag's, or a VBRI tag's)" + }, + "padding": { + "type": [ + "integer", + "null" + ], + "description": "The encoder padding in samples (a LAME tag's)" + } + }, + "required": [ + "tag", + "frames", + "bytes", + "quality", + "encoder", + "delay", + "padding" + ], + "additionalProperties": false + }, + "id3v2": { + "type": "array", + "minItems": 1, + "description": "The ID3v2 tags at the start, in order: each header's version, flags and size, and whether it has a footer", + "items": { + "type": "object", + "properties": { + "version": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 2, + "maxItems": 2 + }, + "flags": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "footer": { + "type": "boolean" + } + }, + "required": [ + "version", + "flags", + "size", + "footer" + ], + "additionalProperties": false + } + }, + "id3v1": { + "type": "object", + "description": "The ID3v1 tag's fields: each text up to its first NUL", + "properties": { + "title": { + "$ref": "#/$defs/text" + }, + "artist": { + "$ref": "#/$defs/text" + }, + "album": { + "$ref": "#/$defs/text" + }, + "year": { + "$ref": "#/$defs/text" + }, + "comment": { + "$ref": "#/$defs/text" + }, + "track": { + "type": [ + "integer", + "null" + ], + "description": "An ID3v1.1 tag's track number" + }, + "genre": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + }, + "required": [ + "title", + "artist", + "album", + "year", + "comment", + "track", + "genre" + ], + "additionalProperties": false + }, + "ape": { + "type": "object", + "description": "The APE tag's footer fields, and the tag's whole size", + "properties": { + "version": { + "type": "integer", + "minimum": 0 + }, + "size": { + "type": "integer", + "minimum": 32 + }, + "items": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "version", + "size", + "items" + ], + "additionalProperties": false + }, + "stray_bytes": { + "type": "integer", + "minimum": 0, + "description": "The bytes between the tags that are in no frame" + } + }, + "required": [ + "version", + "layer", + "sample_rate", + "channels", + "channel_mode", + "samples_per_frame", + "frames", + "samples", + "duration", + "bitrate_mode", + "bitrate_kbps", + "crc_frames", + "stray_bytes" + ], + "additionalProperties": false + }, + { + "$ref": "#/$defs/mirror" + }, + { + "$ref": "#/$defs/mirrorView" + } + ] + }, + "headerValue": { + "description": "A translated header value (spec/conventions.md §6 )", + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/text" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + } + ] + }, + "text": { + "description": "A text value: a string (UTF-8), or its ISO 8859-1 reading when it is not UTF-8", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "latin1": { + "type": "string" + } + }, + "required": [ + "latin1" + ], + "additionalProperties": false + } + ] + }, + "mirror": { + "type": "object", + "description": "refkv_source's: the IR mirror's description (spec/conventions.md §8.2 )", + "properties": { + "ir": { + "type": "object", + "properties": { + "version": { + "const": 2 + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "The source's size in bytes (a store IR's: its objects' sizes, summed)" + }, + "objects": { + "type": "integer", + "minimum": 0, + "description": "A store IR's objects (spec/conventions.md §8.9 ); a file IR has none" + }, + "elements": { + "type": "integer", + "minimum": 1, + "description": "The elements, column runs expanded" + }, + "rows": { + "type": "integer", + "minimum": 1, + "description": "The rows the table stores" + }, + "names": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "const": "" + } + ], + "description": "The interned names, sorted; the first, \"\", is the root's (conventions §8.1 )" + }, + "types": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "const": "" + } + ], + "description": "The interned types (§8.6 )" + }, + "forms": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The interned forms, each a JSON text (§8.5 )" + } + }, + "required": [ + "version", + "size", + "elements", + "rows", + "names", + "types", + "forms" + ], + "additionalProperties": false + } + }, + "required": [ + "ir" + ], + "additionalProperties": false + }, + "mirrorView": { + "type": "object", + "description": "A view group's (refkv_source/tree/...): a struct's view document (spec/conventions.md §8.7 )", + "properties": { + "$vz": { + "const": "derived", + "description": "A derived element's document" + }, + "$form": { + "type": [ + "string", + "null" + ], + "description": "A derived element's form (§8.5 )" + }, + "$partial": { + "const": true, + "description": "The document stops short of the struct's children" + } + }, + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/viewValue" + } + }, + "viewValue": { + "description": "A child's view: a JSON value, a nested document, or a $vz form", + "anyOf": [ + { + "type": [ + "string", + "number", + "boolean", + "null", + "array" + ] + }, + { + "type": "object", + "properties": { + "$vz": { + "enum": [ + "alias", + "group", + "element", + "derived", + "int", + "float", + "bytes", + "utf16" + ] + } + }, + "required": [ + "$vz" + ] + }, + { + "type": "object" + } + ] + }, + "nodeProperty": { + "type": "object", + "description": "Any other node's property: its source metadata only", + "properties": { + "mp3": { + "$ref": "#/$defs/sourceMetadata" + } + }, + "required": [ + "mp3" + ], + "additionalProperties": false + } + } +} diff --git a/spec/virtualize/profiles.json b/spec/virtualize/profiles.json index 18ec3e38..31935747 100644 --- a/spec/virtualize/profiles.json +++ b/spec/virtualize/profiles.json @@ -215,5 +215,20 @@ "source": "Akoya QPTIFF", "inputs": "Akoya (PerkinElmer) Vectra QPTIFF slides and inForm component data, a TIFF with an XML description per image, in LZW, JPEG or uncompressed tiles or strips", "for": "Akoya (PerkinElmer) Vectra multiplex scans and inForm component data" + }, + { + "name": "mp3", + "section": 17, + "title": "MP3", + "uuid": "b6cb0047-9448-4fdc-b03e-10dd3d6aa829", + "version": 0, + "kind": "file", + "suffixes": [ + ".mp3" + ], + "reference": null, + "source": "MP3 (MPEG Layer III audio)", + "inputs": "MPEG-1, MPEG-2 and MPEG-2.5 Layer III audio files (`.mp3`), with their ID3v2, ID3v1 and APE tags; one array of samples, not an image", + "for": "MP3 audio files, as an array of samples (`.mp3`)" } ] diff --git a/uv.lock b/uv.lock index ac9a4440..2536e1e5 100644 --- a/uv.lock +++ b/uv.lock @@ -230,6 +230,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "charset-normalizer" version = "3.5.2" @@ -1266,6 +1351,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, ] +[[package]] +name = "miniaudio" +version = "1.71" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/d5/e5439dc08561f73656bfeb3340fc64ab63163e101426593d8fb9a025ff1e/miniaudio-1.71.tar.gz", hash = "sha256:ff51e2887bb673e2e757752b586b3dc924d59aa5fbcae9bbc45f4a111bd3262b", size = 1116480, upload-time = "2026-04-29T21:20:38.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/d3/71124f5abbcdcae62e040f58d3dca3bd3d90fd01faa7bb276b112387b47a/miniaudio-1.71-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:62db602651bc20a2698f36a0d356d7217ed6f4f917550c7ffb3705c8e8be90cf", size = 377186, upload-time = "2026-04-29T21:20:16.713Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ac/30a324f758bed1b193e017ec25183cfb10a79e549656331f5d068a2d343a/miniaudio-1.71-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fc1a4f084cc1b4b25c567d22f54d1e46bfa505c17ed777c8b198e5c53d0f785", size = 351485, upload-time = "2026-04-29T21:20:17.761Z" }, + { url = "https://files.pythonhosted.org/packages/fa/62/ae884a9d3b2ebec9c2ed1db857593e74bff45b70c4ab17fccc11db31cbeb/miniaudio-1.71-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19be6f0a1e601c2237433e579734cfaf6469191b224c20c9e5f73c32ef9ee2b9", size = 643556, upload-time = "2026-04-29T21:20:18.87Z" }, + { url = "https://files.pythonhosted.org/packages/a5/39/84fc665e2ea8f9f1301b6370226e3a24f08d5ad5d97143b69b4ae7ac260a/miniaudio-1.71-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e6287f15caa808a88aad0700a182bec1ff6d98769717425adf9ebf41259d1936", size = 645176, upload-time = "2026-04-29T21:20:20.194Z" }, + { url = "https://files.pythonhosted.org/packages/81/b8/37d9f67d4511da29bdb82b6c73a3ef6f4ebf2fbd30f9524f1e4a84d6f033/miniaudio-1.71-cp312-cp312-win32.whl", hash = "sha256:ab100e5240b104b5326e4ec1be07b6ae461f7d3d4d7a694857fd2f0493d210f9", size = 235095, upload-time = "2026-04-29T21:20:21.653Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/c1a19e6800e725b6e2b4576407620a798d69e3528ebdea9aea84d69d7088/miniaudio-1.71-cp312-cp312-win_amd64.whl", hash = "sha256:f4a44b70b66628b0c307e40ae0ae857695978cae18462179b806d8edc807d416", size = 274252, upload-time = "2026-04-29T21:20:22.824Z" }, + { url = "https://files.pythonhosted.org/packages/3a/85/44545f767ec21142ffed5f9108406d11dc8a19aafed9bd57621a0892bb60/miniaudio-1.71-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:61b86f26d653040db32d9d15b05446321dd10e45beba25b44f841e26935213d5", size = 377184, upload-time = "2026-04-29T21:20:24.109Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d1/071a560000c8ce903dc919968ecce40fbe7a73213ac399051b887184f8a3/miniaudio-1.71-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d9dc15eff711bcfc62a9d05e0c78e4bc34821a455595e049629f2fea7491a523", size = 351488, upload-time = "2026-04-29T21:20:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/46/24/5873a569451cae5686fb656ebd78ffe0b5eebe48ca21ef61e227d237d20a/miniaudio-1.71-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12bc33e7e61072b4b541c14e10ef76119d5643e6bbb98e2dec0c0738889438fb", size = 643552, upload-time = "2026-04-29T21:20:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/90/9b/25785525e6b5ff9afd7f4c4279215dc09c3317ea4d837275b1ae17912b36/miniaudio-1.71-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70fa2ea5353e6919aca59b8c5768144af009d18c3bca251749d66fb497424563", size = 645171, upload-time = "2026-04-29T21:20:27.494Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/cbfd55fdc40256231f7b0c861e2bf79cc289bfbbe5e15869317944e6d673/miniaudio-1.71-cp313-cp313-win32.whl", hash = "sha256:1bf93aeede652926f27f430f0fd69ef0cf8a949c07b537d6a2f295602c747037", size = 235088, upload-time = "2026-04-29T21:20:29.031Z" }, + { url = "https://files.pythonhosted.org/packages/8d/8d/d5059c04b247b1079c0e48914a9ec20352910a3b9373060fb258dfd194ab/miniaudio-1.71-cp313-cp313-win_amd64.whl", hash = "sha256:4c849ccb1349f7b3553a77a66fe7e972315185f5c4c44a0bbda7ebcdd224db37", size = 274247, upload-time = "2026-04-29T21:20:29.96Z" }, + { url = "https://files.pythonhosted.org/packages/16/e7/b3e0df641d2d5283446d7960fc407195ba722b9a0789bb0a1429bf9ee855/miniaudio-1.71-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3ef441d139264f8a5dcb9aa6fcd0b1e1e69f58715baae416ff33f045ffba6ad5", size = 378418, upload-time = "2026-04-29T21:20:31.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/ea/f5940232d0c83777562e802f376a841046d78e94753450fb9a6685a44190/miniaudio-1.71-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:84139a10ef172acd762ccf120142877b037a1aaf71def99d2c75f66329f89d8b", size = 351473, upload-time = "2026-04-29T21:20:32.464Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a6/6b5ae21b74fe70da935de389e01b3cce86c790ca4083f6a63c3ee922673e/miniaudio-1.71-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a28ff4ad23e55bbde8808ce525d3bb7d249d7612f77646b30e06fc6b7a778ac", size = 643683, upload-time = "2026-04-29T21:20:33.598Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/ef851e2e1d9dfde2b97cc053f0d79c6612088b27044698ed5d8c687f05de/miniaudio-1.71-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:33986d5d725ebcbc253551e7358689bc81b19b6950b33cec8e8c1142ca4fc0a9", size = 645203, upload-time = "2026-04-29T21:20:34.713Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4a/0da61fea8b8469d51b77d43846572ef9255d54c9b6b65a552446bbd55f90/miniaudio-1.71-cp314-cp314-win32.whl", hash = "sha256:3bbeb1e068fe42475e017e8150e9e345182b583d0dd4d9e77ffa20c39935d9ec", size = 241126, upload-time = "2026-04-29T21:20:35.975Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d0/ad7bfa63e1baacd2d4803ee04862bb06fcfbbb34a340bf2bf3979e0068dc/miniaudio-1.71-cp314-cp314-win_amd64.whl", hash = "sha256:154b085dd914a0e79e3d93160e1a07aacb27d66c65f9ef6a0d87c1a194f32c04", size = 281740, upload-time = "2026-04-29T21:20:37.07Z" }, +] + [[package]] name = "mrcfile" version = "1.5.4" @@ -2123,6 +2237,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" }, ] +[[package]] +name = "pycparser" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -2512,6 +2635,10 @@ dependencies = [ ] [package.optional-dependencies] +audio = [ + { name = "miniaudio" }, + { name = "zarr" }, +] images = [ { name = "imagecodecs" }, { name = "zarr" }, @@ -2532,14 +2659,16 @@ zarr = [ requires-dist = [ { name = "google-crc32c", specifier = ">=1.9.0" }, { name = "imagecodecs", marker = "extra == 'images'", specifier = ">=2026.8.16" }, + { name = "miniaudio", marker = "extra == 'audio'", specifier = ">=1.59" }, { name = "numpy", specifier = ">=2.5.3" }, { name = "obstore", marker = "extra == 'obstore'", specifier = ">=0.11.1" }, + { name = "refkv", extras = ["zarr"], marker = "extra == 'audio'", editable = "python" }, { name = "refkv", extras = ["zarr"], marker = "extra == 'images'", editable = "python" }, { name = "refkv", extras = ["zarr", "obstore"], marker = "extra == 'virtualizarr'", editable = "python" }, { name = "virtualizarr", marker = "extra == 'virtualizarr'", specifier = ">=2.7.3" }, { name = "zarr", marker = "extra == 'zarr'", specifier = ">=3.4.0" }, ] -provides-extras = ["images", "obstore", "virtualizarr", "zarr"] +provides-extras = ["audio", "images", "obstore", "virtualizarr", "zarr"] [[package]] name = "refkv-workspace" @@ -2572,7 +2701,7 @@ dev = [ { name = "pyproj" }, { name = "pytest" }, { name = "rasterio" }, - { name = "refkv", extra = ["images", "obstore", "virtualizarr", "zarr"] }, + { name = "refkv", extra = ["audio", "images", "obstore", "virtualizarr", "zarr"] }, { name = "tifffile" }, { name = "xarray" }, { name = "zarr-n5" }, @@ -2605,7 +2734,7 @@ dev = [ { name = "pyproj", specifier = ">=3.8.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "rasterio", specifier = ">=1.5.2" }, - { name = "refkv", extras = ["zarr", "images", "virtualizarr", "obstore"], editable = "python" }, + { name = "refkv", extras = ["zarr", "images", "audio", "virtualizarr", "obstore"], editable = "python" }, { name = "tifffile", specifier = ">=2026.9.20" }, { name = "xarray", specifier = ">=2026.9.0" }, { name = "zarr-n5", specifier = ">=0.3.1" },