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" },