Skip to main content

oxideav_basic/
wav.rs

1//! Minimal pure-Rust RIFF/WAVE container.
2//!
3//! Supports reading and writing linear PCM streams via the `pcm_*` codecs
4//! (`pcm_u8`, `pcm_s16le`, `pcm_s24le`, `pcm_s32le`, `pcm_f32le`,
5//! `pcm_f64le`) and dispatches `WAVE_FORMAT_ALAW (0x0006)` /
6//! `WAVE_FORMAT_MULAW (0x0007)` streams to the external `pcm_alaw` /
7//! `pcm_mulaw` codecs (provided by `oxideav-g711`, applied at decode
8//! time by the host runtime — this crate's WAV layer only handles
9//! framing).
10//!
11//! ## WAVEFORMATEXTENSIBLE (`wFormatTag = 0xFFFE`)
12//!
13//! Per `docs/container/riff/waveformatextensible/README.md` the demuxer
14//! parses the 22-byte extension and surfaces:
15//!
16//! - `wValidBitsPerSample` — actual bit precision (may differ from the
17//!   `wBitsPerSample` container size for 24-in-32-bit PCM).
18//! - `dwChannelMask` — `SPEAKER_*` bitmap describing the channel
19//!   ordering of the interleaved PCM byte stream.
20//! - `SubFormat` GUID — the codec identifier when the legacy
21//!   `wFormatTag` is the EXTENSIBLE escape hatch.
22//!
23//! Well-known `KSDATAFORMAT_SUBTYPE_*` GUIDs (PCM, IEEE_FLOAT, ALAW,
24//! MULAW) are mapped to the same codec ids the legacy
25//! `WAVEFORMATEX` path would have produced. Unknown GUIDs synthesise a
26//! `wav:guid_<canonical-text>` codec id so downstream
27//! `CodecRegistry::make_decoder` lookups fail cleanly naming the
28//! actual GUID.
29//!
30//! The extension fields are also exposed verbatim through
31//! `Demuxer::metadata` under the keys
32//! `wav:fmt.valid_bits_per_sample` / `wav:fmt.channel_mask` /
33//! `wav:fmt.channel_layout` / `wav:fmt.subformat` (matching the
34//! round-75 `oxideav-avi` shape, but single-stream so no per-stream
35//! index). `wav:fmt.channel_layout` is the `dwChannelMask` bitmap
36//! decoded into a `+`-separated list of `SPEAKER_*` positions per
37//! `docs/container/riff/waveformatextensible/ms-waveformatextensible.html`.
38
39use oxideav_core::{
40    CodecId, CodecParameters, CodecResolver, Error, MediaType, Packet, Result, SampleFormat,
41    StreamInfo, TimeBase,
42};
43use oxideav_core::{ContainerRegistry, Demuxer, Muxer, ReadSeek, WriteSeek};
44use std::io::{Read, Seek, SeekFrom, Write};
45
46pub fn register(reg: &mut ContainerRegistry) {
47    reg.register_demuxer("wav", open_demuxer);
48    reg.register_muxer("wav", open_muxer);
49    reg.register_extension("wav", "wav");
50    reg.register_extension("wave", "wav");
51    reg.register_probe("wav", probe);
52}
53
54/// `RIFF....WAVE` (standard), `RF64....WAVE` (EBU Tech 3306 §3
55/// 64-bit-extended) or `BW64....WAVE` (ITU-R BS.2088 ADM-carrying
56/// 64-bit-extended) — all unambiguous when present.
57fn probe(p: &oxideav_core::ProbeData) -> u8 {
58    if p.buf.len() < 12 {
59        return 0;
60    }
61    let magic = &p.buf[0..4];
62    let is_known_form = magic == b"RIFF" || magic == b"RF64" || magic == b"BW64";
63    if is_known_form && &p.buf[8..12] == b"WAVE" {
64        100
65    } else {
66        0
67    }
68}
69
70/// The 32-bit sentinel value placed in the legacy `RIFF`/`data`/`fact`
71/// size fields of an RF64 or BW64 file to signal "don't use this 32-bit
72/// value, look up the real 64-bit value in `ds64`" (EBU Tech 3306 §3
73/// and Annex A.2 `RF64Chunk` / `DataSize64Chunk` comments).
74const SIZE64_SENTINEL: u32 = 0xFFFF_FFFF;
75
76/// `ds64` chunk decoded body. Populated only when the top-level magic
77/// is `RF64` or `BW64`; for plain `RIFF`/WAVE the file uses 32-bit
78/// sizes throughout and `Ds64` is unused.
79///
80/// Layout per EBU Tech 3306 v1 Annex A.2 `DataSize64Chunk`:
81///
82/// ```text
83/// ds64
84///   riffSize:    u64   // replaces the 32-bit RIFF size when sentinel
85///   dataSize:    u64   // replaces the 32-bit `data` chunk size
86///   sampleCount: u64   // replaces the 32-bit `fact` sample count
87///   tableLength: u32   // number of `ChunkSize64` entries that follow
88///   table:       [ChunkSize64; tableLength]
89/// ```
90///
91/// Each `ChunkSize64` is `{ chunkId: [u8; 4], chunkSize: u64 }` and
92/// gives a 64-bit size override for one non-`data` chunk-ID present
93/// elsewhere in the file (the spec calls out that an LEVL chunk over
94/// 4 GiB is the typical realistic case at ~512 GiB of audio payload).
95#[derive(Default)]
96struct Ds64 {
97    /// 64-bit promotion of the top-level form-magic size field.
98    /// Surfaced through metadata for downstream introspection; the
99    /// demuxer doesn't use it directly because chunk-walking is
100    /// driven by the per-chunk `data` size and individual chunk
101    /// sizes (with sentinel resolution against `table`).
102    #[allow(dead_code)]
103    riff_size: u64,
104    data_size: u64,
105    sample_count: u64,
106    table: Vec<([u8; 4], u64)>,
107}
108
109/// Parse a `ds64` chunk body. Surfaces every decoded field through
110/// `metadata` for round-tripping. The `data` and `fact` sentinel
111/// promotion is applied by the caller (which already holds the
112/// 32-bit-field values from the regular chunk walk).
113///
114/// Per EBU Tech 3306 v1 Annex A.2: the fixed 28-byte prefix is
115/// mandatory; the `table` array may be empty. Bodies shorter than 28
116/// bytes are rejected as malformed; bodies longer than `28 + 12 *
117/// tableLength` retain the trailing region for forward compatibility
118/// (the body_len key surfaces it).
119fn parse_ds64_chunk(buf: &[u8], out: &mut Vec<(String, String)>) -> Result<Ds64> {
120    if buf.len() < 28 {
121        return Err(Error::invalid("RF64 ds64 chunk shorter than 28 bytes"));
122    }
123    let riff_size = u64::from_le_bytes([
124        buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7],
125    ]);
126    let data_size = u64::from_le_bytes([
127        buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15],
128    ]);
129    let sample_count = u64::from_le_bytes([
130        buf[16], buf[17], buf[18], buf[19], buf[20], buf[21], buf[22], buf[23],
131    ]);
132    let table_len = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]) as usize;
133
134    out.push(("wav:rf64.riff_size".to_string(), riff_size.to_string()));
135    out.push(("wav:rf64.data_size".to_string(), data_size.to_string()));
136    out.push((
137        "wav:rf64.sample_count".to_string(),
138        sample_count.to_string(),
139    ));
140    out.push(("wav:rf64.table.count".to_string(), table_len.to_string()));
141
142    const REC_LEN: usize = 12;
143    let mut table = Vec::with_capacity(table_len);
144    let table_bytes_available = buf.len().saturating_sub(28);
145    let table_recs_available = table_bytes_available / REC_LEN;
146    // Defensive vs. writers that lie about the count: only consume as
147    // many records as the body actually carries.
148    let n = table_len.min(table_recs_available);
149    for i in 0..n {
150        let off = 28 + i * REC_LEN;
151        let id: [u8; 4] = [buf[off], buf[off + 1], buf[off + 2], buf[off + 3]];
152        let size = u64::from_le_bytes([
153            buf[off + 4],
154            buf[off + 5],
155            buf[off + 6],
156            buf[off + 7],
157            buf[off + 8],
158            buf[off + 9],
159            buf[off + 10],
160            buf[off + 11],
161        ]);
162        let id_str = if id.iter().all(|&b| (0x20..=0x7E).contains(&b)) {
163            String::from_utf8_lossy(&id).to_string()
164        } else {
165            format!("0x{:02X}{:02X}{:02X}{:02X}", id[0], id[1], id[2], id[3])
166        };
167        out.push((format!("wav:rf64.table.{i}.id"), id_str.clone()));
168        out.push((format!("wav:rf64.table.{i}.size"), size.to_string()));
169        table.push((id, size));
170    }
171    out.push(("wav:rf64.body_len".to_string(), buf.len().to_string()));
172    Ok(Ds64 {
173        riff_size,
174        data_size,
175        sample_count,
176        table,
177    })
178}
179
180/// Resolve a chunk's real size: when the 32-bit on-wire size is the
181/// `0xFFFFFFFF` sentinel, look up the 64-bit override in the `ds64`
182/// table (matching FOURCC). For chunks that aren't in the table the
183/// sentinel cannot be resolved — returns `None` so the caller can
184/// reject the file as malformed rather than silently skipping forward
185/// by a billion bytes.
186fn resolve_chunk_size(id: &[u8; 4], on_wire: u32, ds64: Option<&Ds64>) -> Option<u64> {
187    if on_wire != SIZE64_SENTINEL {
188        return Some(on_wire as u64);
189    }
190    let ds64 = ds64?;
191    // `data` size override lives in the dedicated `dataSize` field
192    // rather than the table.
193    if id == b"data" {
194        return Some(ds64.data_size);
195    }
196    ds64.table
197        .iter()
198        .find(|(tid, _)| tid == id)
199        .map(|(_, sz)| *sz)
200}
201
202// On-the-wire `wFormatTag` constants from RFC 2361 / `mmreg.h`. Public so
203// muxer callers can build `WAVE_FORMAT_EXTENSIBLE` streams against the
204// same dispatch table the demuxer uses.
205/// `WAVE_FORMAT_PCM` — integer linear PCM (`mmreg.h`).
206pub const WAVE_FORMAT_PCM: u16 = 0x0001;
207/// `WAVE_FORMAT_IEEE_FLOAT` — 32-bit / 64-bit IEEE 754 float PCM.
208pub const WAVE_FORMAT_IEEE_FLOAT: u16 = 0x0003;
209/// `WAVE_FORMAT_ALAW` — ITU-T G.711 A-law (RFC 2361 A.7).
210pub const WAVE_FORMAT_ALAW: u16 = 0x0006;
211/// `WAVE_FORMAT_MULAW` — ITU-T G.711 μ-law (RFC 2361 A.8).
212pub const WAVE_FORMAT_MULAW: u16 = 0x0007;
213/// `WAVE_FORMAT_EXTENSIBLE` — escape hatch with 22-byte extension
214/// carrying `wValidBitsPerSample` / `dwChannelMask` / `SubFormat` GUID
215/// (per docs/container/riff/waveformatextensible/README.md).
216pub const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
217
218// Internal aliases kept for readability of the local match arms below.
219const FMT_PCM: u16 = WAVE_FORMAT_PCM;
220const FMT_IEEE_FLOAT: u16 = WAVE_FORMAT_IEEE_FLOAT;
221const FMT_ALAW: u16 = WAVE_FORMAT_ALAW;
222const FMT_MULAW: u16 = WAVE_FORMAT_MULAW;
223const FMT_EXTENSIBLE: u16 = WAVE_FORMAT_EXTENSIBLE;
224
225// `KSDATAFORMAT_SUBTYPE_*` GUIDs (`KSMedia.h`). All follow the same
226// `<tag>-0000-0010-8000-00AA00389B71` "DataFormat" base where the
227// leading 16-bit `<tag>` is the legacy `wFormatTag` — per
228// docs/container/riff/waveformatextensible/README.md.
229const GUID_PCM: [u8; 16] = [
230    0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
231];
232const GUID_IEEE_FLOAT: [u8; 16] = [
233    0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
234];
235const GUID_ALAW: [u8; 16] = [
236    0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
237];
238const GUID_MULAW: [u8; 16] = [
239    0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
240];
241
242/// The 14 trailing bytes of `KSDATAFORMAT_SUBTYPE_WAVEFORMATEX`
243/// (`{00000000-0000-0010-8000-00aa00389b71}`) — i.e. the GUID with its
244/// leading 16-bit `Data1` field (the embedded `wFormatTag`) removed.
245///
246/// Per
247/// `docs/container/riff/waveformatextensible/ms-converting-format-tags-and-subformat-guids.md`,
248/// the `KSMedia.h` macro `DEFINE_WAVEFORMATEX_GUID(x)` constructs a
249/// SubFormat GUID as `(USHORT)(x), 0x0000, 0x0010, 0x80, 0x00, 0x00,
250/// 0xaa, 0x00, 0x38, 0x9b, 0x71` — the legacy `wFormatTag` `x` occupies
251/// the low 16 bits of `Data1`, the high 16 bits of `Data1` are zero, and
252/// the remaining twelve bytes are the fixed "WAVEFORMATEX" base. The
253/// companion `IS_VALID_WAVEFORMATEX_GUID(Guid)` macro tests a candidate
254/// GUID by comparing every byte *after* the leading `USHORT` (i.e. bytes
255/// `[2..16]`) against this base; `EXTRACT_WAVEFORMATEX_ID(Guid)` then
256/// reads the legacy tag from `(USHORT)(Guid->Data1)` (bytes `[0..2]`,
257/// little-endian).
258const GUID_WAVEFORMATEX_TAIL: [u8; 14] = [
259    0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
260];
261
262/// If `g` is a SubFormat GUID produced by the `KSMedia.h`
263/// `DEFINE_WAVEFORMATEX_GUID(x)` template (a "WAVEFORMATEX GUID"),
264/// return the embedded legacy `wFormatTag` `x`; otherwise `None`.
265///
266/// Implements `IS_VALID_WAVEFORMATEX_GUID` + `EXTRACT_WAVEFORMATEX_ID`
267/// from
268/// `docs/container/riff/waveformatextensible/ms-converting-format-tags-and-subformat-guids.md`:
269/// the validity test compares the fourteen bytes after the leading
270/// `Data1` low half — bytes `[2..16]` — against the fixed
271/// `KSDATAFORMAT_SUBTYPE_WAVEFORMATEX` tail (which includes the zero high
272/// half of `Data1` at byte `[2..4]`), and the tag is the little-endian
273/// `u16` at bytes `[0..2]`.
274fn waveformatex_tag(g: &[u8; 16]) -> Option<u16> {
275    if g[2..16] == GUID_WAVEFORMATEX_TAIL {
276        Some(u16::from_le_bytes([g[0], g[1]]))
277    } else {
278        None
279    }
280}
281
282/// Format the 16-byte SubFormat GUID for diagnostic strings as
283/// `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` (canonical text representation
284/// used by `mmreg.h` GUID definitions). The first three groups are
285/// little-endian on the wire, the trailing two groups are big-endian.
286fn fmt_guid(g: &[u8; 16]) -> String {
287    format!(
288        "{:08X}-{:04X}-{:04X}-{:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}",
289        u32::from_le_bytes([g[0], g[1], g[2], g[3]]),
290        u16::from_le_bytes([g[4], g[5]]),
291        u16::from_le_bytes([g[6], g[7]]),
292        g[8],
293        g[9],
294        g[10],
295        g[11],
296        g[12],
297        g[13],
298        g[14],
299        g[15],
300    )
301}
302
303/// `WAVEFORMATEXTENSIBLE.dwChannelMask` `SPEAKER_*` flag bits, ordered
304/// least-significant-bit first. Per
305/// `docs/container/riff/waveformatextensible/ms-waveformatextensible.html`
306/// §"dwChannelMask": the LSB is the front-left speaker, the next bit the
307/// front-right speaker, and so on through bit 17 (`SPEAKER_TOP_BACK_RIGHT`,
308/// `0x20000`). The interleaved PCM samples appear in this same
309/// least-significant-bit-up order.
310const SPEAKER_FLAGS: [(u32, &str); 18] = [
311    (0x1, "FRONT_LEFT"),
312    (0x2, "FRONT_RIGHT"),
313    (0x4, "FRONT_CENTER"),
314    (0x8, "LOW_FREQUENCY"),
315    (0x10, "BACK_LEFT"),
316    (0x20, "BACK_RIGHT"),
317    (0x40, "FRONT_LEFT_OF_CENTER"),
318    (0x80, "FRONT_RIGHT_OF_CENTER"),
319    (0x100, "BACK_CENTER"),
320    (0x200, "SIDE_LEFT"),
321    (0x400, "SIDE_RIGHT"),
322    (0x800, "TOP_CENTER"),
323    (0x1000, "TOP_FRONT_LEFT"),
324    (0x2000, "TOP_FRONT_CENTER"),
325    (0x4000, "TOP_FRONT_RIGHT"),
326    (0x8000, "TOP_BACK_LEFT"),
327    (0x10000, "TOP_BACK_CENTER"),
328    (0x20000, "TOP_BACK_RIGHT"),
329];
330
331/// Decode a `WAVEFORMATEXTENSIBLE.dwChannelMask` bitmap into a
332/// human-readable, `+`-separated list of `SPEAKER_*` positions in the
333/// canonical least-significant-bit-first order
334/// (`FRONT_LEFT+FRONT_RIGHT+...`).
335///
336/// Returns `None` when `mask == 0` (no assigned speaker positions —
337/// "use direct-out / discrete channels"). Any bits set above the highest
338/// defined flag (`0x20000`) that aren't recognised are reported as
339/// `UNKNOWN(0x...)` so the round-trip information isn't silently dropped.
340///
341/// Per
342/// `docs/container/riff/waveformatextensible/ms-waveformatextensible.html`,
343/// the number of set bits should equal `WAVEFORMATEX.nChannels`; this
344/// function only decodes the mask and does not enforce that invariant.
345fn channel_mask_layout(mask: u32) -> Option<String> {
346    if mask == 0 {
347        return None;
348    }
349    let mut parts: Vec<String> = Vec::new();
350    let mut known: u32 = 0;
351    for (bit, name) in SPEAKER_FLAGS {
352        if mask & bit != 0 {
353            parts.push(name.to_string());
354            known |= bit;
355        }
356    }
357    let unknown = mask & !known;
358    if unknown != 0 {
359        parts.push(format!("UNKNOWN(0x{unknown:X})"));
360    }
361    Some(parts.join("+"))
362}
363
364// --- Demuxer ---------------------------------------------------------------
365
366fn open_demuxer(input: Box<dyn ReadSeek>, _codecs: &dyn CodecResolver) -> Result<Box<dyn Demuxer>> {
367    Ok(Box::new(open_wav_demuxer(input)?))
368}
369
370/// Open a WAV/RF64/BW64 demuxer returning the concrete [`WavDemuxer`]
371/// so the typed accessor surface ([`WavDemuxer::format_tag`],
372/// [`WavDemuxer::channel_mask`], [`WavDemuxer::acid`], …) is reachable
373/// without downcasting. The registry path wraps this in a
374/// `Box<dyn Demuxer>`.
375pub fn open_wav_demuxer(mut input: Box<dyn ReadSeek>) -> Result<WavDemuxer> {
376    let mut hdr = [0u8; 12];
377    input.read_exact(&mut hdr)?;
378    let magic: [u8; 4] = [hdr[0], hdr[1], hdr[2], hdr[3]];
379    let is_rf64 = &magic == b"RF64";
380    let is_bw64 = &magic == b"BW64";
381    let is_riff = &magic == b"RIFF";
382    if !(is_riff || is_rf64 || is_bw64) || &hdr[8..12] != b"WAVE" {
383        return Err(Error::invalid("not a RIFF/RF64/BW64 WAVE file"));
384    }
385
386    // Walk chunks until we hit "data"; parse "fmt ", "ds64" and "LIST"
387    // along the way.
388    let mut fmt: Option<WaveFmt> = None;
389    let mut metadata: Vec<(String, String)> = Vec::new();
390    // `fact` chunk's `dwFileSize` (per-channel sample count) when present.
391    // Required for non-PCM / `wavl LIST` waveform data per
392    // `docs/container/riff/metadata/microsoft-riffmci.pdf` §3 "FACT
393    // Chunk" — the only honest sample count when `block_align *
394    // total_samples != data_size`.
395    let mut fact_sample_count: Option<u64> = None;
396    // Typed Acidizer view, populated when an `acid` chunk parses.
397    let mut acid: Option<AcidChunk> = None;
398    // RF64/BW64 ds64 (EBU Tech 3306 §3 / Annex A.2): mandatory first
399    // chunk after the form header when the magic is RF64 or BW64;
400    // otherwise must be absent.
401    let mut ds64: Option<Ds64> = None;
402    if is_rf64 || is_bw64 {
403        // Surface the form-magic up front so a downstream tool can
404        // distinguish the three identical-otherwise top-level shapes
405        // without re-reading the input.
406        let form = if is_rf64 { "RF64" } else { "BW64" };
407        metadata.push(("wav:rf64.magic".to_string(), form.to_string()));
408        // The legacy 32-bit "RIFF size" field at offset 4..8 of the
409        // form header must be the sentinel for a well-formed RF64 /
410        // BW64 file (EBU Tech 3306 §3 last paragraph + Annex A.2
411        // RF64Chunk comment). We surface the on-wire value so a
412        // non-compliant writer is observable but don't reject.
413        let on_wire_riff = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]);
414        if on_wire_riff != SIZE64_SENTINEL {
415            metadata.push(("wav:rf64.riff_size32".to_string(), on_wire_riff.to_string()));
416        }
417    }
418    let mut data_offset: Option<u64> = None;
419    let mut data_size: Option<u64> = None;
420    loop {
421        let mut chdr = [0u8; 8];
422        // A `wavl`-form file has no top-level `data` chunk to break on, so
423        // the scan walks every remaining chunk and terminates at a clean
424        // end-of-stream. A short/absent header at EOF after a `wavl` LIST
425        // already anchored the cursor is the normal termination; only an
426        // EOF before any waveform is anchored is malformed (caught below).
427        match input.read_exact(&mut chdr) {
428            Ok(()) => {}
429            Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
430            Err(e) => return Err(e.into()),
431        }
432        let id_arr: [u8; 4] = [chdr[0], chdr[1], chdr[2], chdr[3]];
433        let id = &chdr[0..4];
434        let on_wire_size = u32::from_le_bytes([chdr[4], chdr[5], chdr[6], chdr[7]]);
435        // ds64-aware size resolution: only `data` and the table-listed
436        // chunk-IDs can carry the 32-bit sentinel; the standalone
437        // chunks (`fmt `, `bext`, etc.) are bounded by RIFF's 32-bit
438        // arithmetic and never hit the sentinel.
439        let size = match resolve_chunk_size(&id_arr, on_wire_size, ds64.as_ref()) {
440            Some(s) => s,
441            None => {
442                return Err(Error::invalid(format!(
443                    "RF64 chunk {:?} carries 0xFFFFFFFF sentinel but no ds64 override",
444                    String::from_utf8_lossy(id)
445                )));
446            }
447        };
448        match id {
449            b"ds64" => {
450                // RF64/BW64: mandatory, must precede `fmt `/`data`. For
451                // a plain RIFF file the chunk must not appear; if it
452                // does we surface the keys but otherwise treat it as
453                // an unknown ahead-of-fmt extension and keep going.
454                let mut buf = vec![0u8; size as usize];
455                input.read_exact(&mut buf)?;
456                let parsed = parse_ds64_chunk(&buf, &mut metadata)?;
457                ds64 = Some(parsed);
458                if size % 2 == 1 {
459                    input.seek(SeekFrom::Current(1))?;
460                }
461            }
462            b"fmt " => {
463                let mut buf = vec![0u8; size as usize];
464                input.read_exact(&mut buf)?;
465                fmt = Some(parse_fmt(&buf)?);
466                if size % 2 == 1 {
467                    input.seek(SeekFrom::Current(1))?;
468                }
469            }
470            b"fact" => {
471                let mut buf = vec![0u8; size as usize];
472                input.read_exact(&mut buf)?;
473                // RF64/BW64: when the 32-bit `dwFileSize` carries the
474                // sentinel, the authoritative 64-bit sample count
475                // lives in `ds64.sampleCount` (EBU Tech 3306 §3 last
476                // bullet of the three-mandatory-fields list). The
477                // standard `parse_fact_chunk` only returns the 32-bit
478                // legacy field; we promote here when needed.
479                let legacy = parse_fact_chunk(&buf, &mut metadata);
480                fact_sample_count = match legacy {
481                    Some(v) if v == SIZE64_SENTINEL => ds64.as_ref().map(|d| d.sample_count),
482                    Some(v) => Some(v as u64),
483                    None => None,
484                };
485                if size % 2 == 1 {
486                    input.seek(SeekFrom::Current(1))?;
487                }
488            }
489            b"LIST" => {
490                // The LIST chunk body opens with a 4-byte list type. The
491                // `wavl` (wave-list) type per Microsoft RIFF MCI §3
492                // "Storage of WAVE Data" is the segmented waveform
493                // container —
494                // `LIST('wavl' { <data-ck> | <silence-ck> }... )` —
495                // alternating `data` payloads with `slnt` silence
496                // counts. We need byte offsets for the embedded `data`
497                // sub-chunks, so capture the LIST body's absolute start
498                // and resolve the first `data` segment as the decode
499                // anchor; `INFO` / `adtl` LISTs are pure metadata and
500                // stay on the buffered path.
501                let list_start = input.stream_position()?;
502                let mut buf = vec![0u8; size as usize];
503                input.read_exact(&mut buf)?;
504                if buf.len() >= 4 && &buf[0..4] == b"wavl" {
505                    if let Some((off, sz)) = parse_wavl_list(&buf, list_start, &mut metadata) {
506                        // The §3 grammar lets `<wave-data>` be a `wavl`
507                        // LIST instead of a top-level `data` chunk. Anchor
508                        // the decode cursor at the first `data` segment so
509                        // the leading audio is readable; later segments
510                        // (and embedded silence) are surfaced as
511                        // `wav:wavl.*` metadata for downstream walking.
512                        if data_offset.is_none() {
513                            data_offset = Some(off);
514                            data_size = Some(sz);
515                        }
516                    }
517                } else {
518                    parse_list_chunk(&buf, &mut metadata);
519                }
520                if size % 2 == 1 {
521                    input.seek(SeekFrom::Current(1))?;
522                }
523            }
524            b"bext" => {
525                let mut buf = vec![0u8; size as usize];
526                input.read_exact(&mut buf)?;
527                parse_bext_chunk(&buf, &mut metadata);
528                if size % 2 == 1 {
529                    input.seek(SeekFrom::Current(1))?;
530                }
531            }
532            b"cue " => {
533                let mut buf = vec![0u8; size as usize];
534                input.read_exact(&mut buf)?;
535                parse_cue_chunk(&buf, &mut metadata);
536                if size % 2 == 1 {
537                    input.seek(SeekFrom::Current(1))?;
538                }
539            }
540            b"plst" => {
541                let mut buf = vec![0u8; size as usize];
542                input.read_exact(&mut buf)?;
543                parse_plst_chunk(&buf, &mut metadata);
544                if size % 2 == 1 {
545                    input.seek(SeekFrom::Current(1))?;
546                }
547            }
548            b"smpl" => {
549                let mut buf = vec![0u8; size as usize];
550                input.read_exact(&mut buf)?;
551                parse_smpl_chunk(&buf, &mut metadata);
552                if size % 2 == 1 {
553                    input.seek(SeekFrom::Current(1))?;
554                }
555            }
556            b"inst" => {
557                let mut buf = vec![0u8; size as usize];
558                input.read_exact(&mut buf)?;
559                parse_inst_chunk(&buf, &mut metadata);
560                if size % 2 == 1 {
561                    input.seek(SeekFrom::Current(1))?;
562                }
563            }
564            b"acid" => {
565                let mut buf = vec![0u8; size as usize];
566                input.read_exact(&mut buf)?;
567                acid = parse_acid_chunk(&buf, &mut metadata);
568                if size % 2 == 1 {
569                    input.seek(SeekFrom::Current(1))?;
570                }
571            }
572            b"iXML" => {
573                let mut buf = vec![0u8; size as usize];
574                input.read_exact(&mut buf)?;
575                parse_ixml_chunk(&buf, &mut metadata);
576                if size % 2 == 1 {
577                    input.seek(SeekFrom::Current(1))?;
578                }
579            }
580            b"axml" => {
581                let mut buf = vec![0u8; size as usize];
582                input.read_exact(&mut buf)?;
583                parse_axml_chunk(&buf, &mut metadata);
584                if size % 2 == 1 {
585                    input.seek(SeekFrom::Current(1))?;
586                }
587            }
588            b"_PMX" => {
589                let mut buf = vec![0u8; size as usize];
590                input.read_exact(&mut buf)?;
591                parse_pmx_chunk(&buf, &mut metadata);
592                if size % 2 == 1 {
593                    input.seek(SeekFrom::Current(1))?;
594                }
595            }
596            b"CSET" => {
597                let mut buf = vec![0u8; size as usize];
598                input.read_exact(&mut buf)?;
599                parse_cset_chunk(&buf, &mut metadata);
600                if size % 2 == 1 {
601                    input.seek(SeekFrom::Current(1))?;
602                }
603            }
604            b"JUNK" => {
605                // Microsoft RIFF MCI §2 "JUNK (Filler) Chunk": padding,
606                // filler or outdated information; the body contains
607                // random data and carries no relevant payload. We skip
608                // the body but surface accounting metadata so a
609                // downstream tool can observe how much filler the
610                // producer reserved (e.g. for in-place editing) and
611                // how many JUNK chunks appeared. Multiple JUNK chunks
612                // are allowed; the count/total-bytes accumulate.
613                input.seek(SeekFrom::Current(size as i64))?;
614                surface_junk_metadata(&mut metadata, size);
615                if size % 2 == 1 {
616                    input.seek(SeekFrom::Current(1))?;
617                }
618            }
619            b"slnt" => {
620                // Microsoft RIFF MCI §3 "Wave Data": the `slnt`
621                // (silence) chunk `slnt( <dwSamples:DWORD> )` carries a
622                // single DWORD count of silent samples rather than a
623                // stretch of zeroed sample data. It normally appears
624                // inside a `wavl` LIST alternating with `data` chunks,
625                // but the spec allows it as a sibling of `data` at the
626                // top level too. We surface its sample count without
627                // synthesising real silence into the decoded stream.
628                let mut buf = vec![0u8; size as usize];
629                input.read_exact(&mut buf)?;
630                surface_slnt_metadata(&mut metadata, &buf);
631                if size % 2 == 1 {
632                    input.seek(SeekFrom::Current(1))?;
633                }
634            }
635            b"data" => {
636                data_offset = Some(input.stream_position()?);
637                data_size = Some(size);
638                break;
639            }
640            _ => {
641                let pad = size + (size % 2);
642                input.seek(SeekFrom::Current(pad as i64))?;
643            }
644        }
645    }
646    let fmt = fmt.ok_or_else(|| Error::invalid("WAV missing fmt chunk"))?;
647    // Either a top-level `data` chunk (the common case) or the first
648    // `data` sub-chunk of a `wavl` LIST must have anchored the cursor.
649    let data_offset =
650        data_offset.ok_or_else(|| Error::invalid("WAV missing data / wavl waveform"))?;
651    let data_size = data_size.unwrap_or(0);
652
653    let codec_id = resolve_codec(&fmt)?;
654    // Sample format hint for the decoded shape (NOT the on-wire layout
655    // — A-law/μ-law expand to S16 once decoded, mirroring the
656    // round-75 `oxideav-avi` audio_codec_sample_format helper). For
657    // synthesised `wav:guid_<...>` ids the decoded shape is unknown
658    // so we leave sample_format as None — callers can still pull
659    // channels / sample_rate / channel_mask / SubFormat to surface a
660    // useful diagnostic without us pretending to know the codec.
661    let sample_fmt = decoded_sample_format(&codec_id);
662
663    let time_base = TimeBase::new(1, fmt.sample_rate as i64);
664    let block_align = fmt.block_align.max(1) as u64;
665    let block_samples = data_size / block_align;
666    // Prefer the `fact` chunk's per-channel sample count when present —
667    // for compressed WAV streams (and for `wavl LIST` containers in
668    // general) the `data_size / block_align` heuristic is meaningless
669    // because one byte of payload no longer maps to one sample. For PCM
670    // the two should agree; when they don't we surface
671    // `wav:fact.mismatch` so a downstream tool can flag the file rather
672    // than silently trusting one number over the other.
673    let total_samples = if let Some(fc) = fact_sample_count {
674        if fc != block_samples {
675            metadata.push((
676                "wav:fact.mismatch".to_string(),
677                format!("block_samples={block_samples} fact_samples={fc}"),
678            ));
679        }
680        fc
681    } else {
682        block_samples
683    };
684    let duration_micros: i64 = if fmt.sample_rate > 0 {
685        (total_samples as i128 * 1_000_000 / fmt.sample_rate as i128) as i64
686    } else {
687        0
688    };
689
690    let mut params = CodecParameters::audio(codec_id);
691    params.tag = Some(oxideav_core::CodecTag::wave_format(fmt.format_tag));
692    params.channels = Some(fmt.channels);
693    params.sample_rate = Some(fmt.sample_rate);
694    params.sample_format = sample_fmt;
695    // bit_rate uses the on-wire bytes_per_second (== block_align *
696    // sample_rate) — for A-law/μ-law that's 8 * channels * rate, NOT
697    // the post-decode S16 rate.
698    params.bit_rate = Some(8 * block_align * (fmt.sample_rate as u64));
699
700    // Round-77 metadata: surface WAVEFORMATEXTENSIBLE side-info under
701    // the same key shape `oxideav-avi` uses (without the per-stream
702    // index — WAV is single-stream by construction). Only emitted when
703    // the on-wire wFormatTag is EXTENSIBLE and the extension parsed.
704    if fmt.format_tag == FMT_EXTENSIBLE {
705        if let Some(valid) = fmt.valid_bits_per_sample {
706            metadata.push((
707                "wav:fmt.valid_bits_per_sample".to_string(),
708                valid.to_string(),
709            ));
710        }
711        if let Some(mask) = fmt.channel_mask {
712            metadata.push(("wav:fmt.channel_mask".to_string(), format!("0x{mask:08X}")));
713            if let Some(layout) = channel_mask_layout(mask) {
714                metadata.push(("wav:fmt.channel_layout".to_string(), layout));
715            }
716        }
717        if let Some(sub) = &fmt.subformat {
718            metadata.push(("wav:fmt.subformat".to_string(), fmt_guid(sub)));
719            // When the GUID follows the KSMedia.h DEFINE_WAVEFORMATEX_GUID
720            // template, surface the embedded legacy wFormatTag it is
721            // equivalent to (per
722            // docs/.../ms-converting-format-tags-and-subformat-guids.md).
723            // Lets a downstream tool see that e.g. an EXTENSIBLE file with
724            // subformat {00000055-...} is MP3-tagged without re-deriving
725            // the mapping.
726            if let Some(tag) = waveformatex_tag(sub) {
727                metadata.push(("wav:fmt.subformat_tag".to_string(), format!("0x{tag:04X}")));
728            }
729        }
730    }
731
732    let stream = StreamInfo {
733        index: 0,
734        time_base,
735        duration: Some(total_samples as i64),
736        start_time: Some(0),
737        params,
738    };
739
740    Ok(WavDemuxer {
741        input,
742        streams: vec![stream],
743        data_offset,
744        data_end: data_offset + data_size,
745        cursor: data_offset,
746        block_align,
747        chunk_frames: 1024,
748        samples_emitted: 0,
749        metadata,
750        duration_micros,
751        format_tag: fmt.format_tag,
752        valid_bits_per_sample: fmt.valid_bits_per_sample,
753        channel_mask: fmt.channel_mask,
754        subformat: fmt.subformat,
755        acid,
756    })
757}
758
759/// Parse a RIFF LIST chunk body. Dispatches by the 4-byte list type:
760/// `INFO` maps `IART`/`INAM`/... sub-chunks to standard key names
761/// (`artist`, `title`, ...); `adtl` (Associated Data List, per
762/// `docs/container/riff/metadata/microsoft-riffmci.pdf` §3 "Associated
763/// Data Chunk") maps `labl`/`note`/`ltxt`/`file` sub-chunks to
764/// `wav:adtl.<id>.<dwName>` keys carrying the cue-point text /
765/// embedded-file accounting.
766fn parse_list_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
767    if buf.len() < 4 {
768        return;
769    }
770    match &buf[0..4] {
771        b"INFO" => parse_info_list(&buf[4..], out),
772        b"adtl" => parse_adtl_list(&buf[4..], out),
773        _ => {}
774    }
775}
776
777/// Parse a `LIST('wavl' ...)` wave-list body per Microsoft RIFF MCI §3
778/// "Storage of WAVE Data":
779///
780/// > `<wave-data>` ➝ `{ <data-ck> | <data-list> }`
781/// > `<wave-list>` ➝ `LIST( 'wavl' { <data-ck> | <silence-ck> }... )`
782/// > `<silence-ck>` ➝ `slnt( <dwSamples:DWORD> )`
783///
784/// The `wavl` form interleaves runs of real PCM (`data` sub-chunks) with
785/// `slnt` silence-count markers, letting a writer encode long silent
786/// stretches sparsely instead of storing zeroed samples. The §3 note is
787/// explicit that `slnt` is a *count of silent samples*, not a baseline
788/// fill, so we do not synthesise samples — silence is surfaced through
789/// the same `wav:slnt.*` accounting used for top-level `slnt` chunks.
790///
791/// `buf` is the LIST body (starting at the `wavl` list type); `list_start`
792/// is the absolute stream offset of that first body byte, so the returned
793/// `(offset, size)` of the first `data` sub-chunk is an absolute file
794/// offset the demuxer can seek to. Returns `None` when the LIST holds no
795/// `data` segment (a silence-only `wavl`, which carries no decodable
796/// audio but is still fully surfaced as metadata).
797///
798/// Surfaced keys:
799/// * `wav:wavl.segment_count` — total `data` + `slnt` sub-chunks walked.
800/// * `wav:wavl.data_count` — number of `data` segments.
801/// * `wav:wavl.data_bytes` — cumulative payload bytes across `data`
802///   segments (excludes the 8-byte sub-chunk headers and word-align
803///   padding).
804/// * `wav:wavl.<n>.kind` / `.length` — per-segment type (`data`/`slnt`)
805///   and on-wire body length, indexed zero-based by encounter order.
806/// * embedded `slnt` segments additionally feed `wav:slnt.*` so the
807///   silent-sample totals match a top-level-`slnt` file.
808fn parse_wavl_list(
809    buf: &[u8],
810    list_start: u64,
811    out: &mut Vec<(String, String)>,
812) -> Option<(u64, u64)> {
813    // Skip the 4-byte 'wavl' list type.
814    let mut i = 4usize;
815    let mut first_data: Option<(u64, u64)> = None;
816    let mut segment_count: u64 = 0;
817    let mut data_count: u64 = 0;
818    let mut data_bytes: u64 = 0;
819    while i + 8 <= buf.len() {
820        let id: [u8; 4] = [buf[i], buf[i + 1], buf[i + 2], buf[i + 3]];
821        let size = u32::from_le_bytes([buf[i + 4], buf[i + 5], buf[i + 6], buf[i + 7]]) as usize;
822        let body = i + 8;
823        if body + size > buf.len() {
824            break;
825        }
826        let idx = segment_count;
827        match &id {
828            b"data" => {
829                out.push((format!("wav:wavl.{idx}.kind"), "data".to_string()));
830                out.push((format!("wav:wavl.{idx}.length"), size.to_string()));
831                data_count += 1;
832                data_bytes = data_bytes.saturating_add(size as u64);
833                if first_data.is_none() {
834                    // Absolute offset = LIST body start + sub-chunk body
835                    // offset within the body.
836                    first_data = Some((list_start + body as u64, size as u64));
837                }
838            }
839            b"slnt" => {
840                out.push((format!("wav:wavl.{idx}.kind"), "slnt".to_string()));
841                out.push((format!("wav:wavl.{idx}.length"), size.to_string()));
842                surface_slnt_metadata(out, &buf[body..body + size]);
843            }
844            // The §3 grammar admits only `data` and `slnt` inside `wavl`;
845            // anything else is an unknown forward extension — record its
846            // presence so the file stays observable, but don't anchor on
847            // it.
848            _ => {
849                out.push((
850                    format!("wav:wavl.{idx}.kind"),
851                    String::from_utf8_lossy(&id).trim().to_string(),
852                ));
853                out.push((format!("wav:wavl.{idx}.length"), size.to_string()));
854            }
855        }
856        segment_count += 1;
857        // RIFF word-alignment: sub-chunks are padded to an even length.
858        i = body + size + (size & 1);
859    }
860    out.push((
861        "wav:wavl.segment_count".to_string(),
862        segment_count.to_string(),
863    ));
864    out.push(("wav:wavl.data_count".to_string(), data_count.to_string()));
865    out.push(("wav:wavl.data_bytes".to_string(), data_bytes.to_string()));
866    first_data
867}
868
869fn parse_info_list(buf: &[u8], out: &mut Vec<(String, String)>) {
870    let mut i = 0usize;
871    while i + 8 <= buf.len() {
872        let id: [u8; 4] = [buf[i], buf[i + 1], buf[i + 2], buf[i + 3]];
873        let size = u32::from_le_bytes([buf[i + 4], buf[i + 5], buf[i + 6], buf[i + 7]]) as usize;
874        i += 8;
875        if i + size > buf.len() {
876            break;
877        }
878        let raw = &buf[i..i + size];
879        let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
880        let value = String::from_utf8_lossy(&raw[..end]).trim().to_string();
881        let key = info_id_to_key(&id);
882        if !value.is_empty() {
883            if let Some(k) = key {
884                out.push((k.to_string(), value));
885            }
886        }
887        i += size;
888        if size % 2 == 1 {
889            i += 1;
890        }
891    }
892}
893
894/// Parse a `LIST adtl` (Associated Data List) body and emit
895/// `wav:adtl.<sub-id>.<dwName>` keys carrying the text payload of each
896/// sub-chunk. Per `docs/container/riff/metadata/microsoft-riffmci.pdf`
897/// §3 "Associated Data Chunk":
898///
899/// * `labl(<dwName:DWORD> <data:ZSTR>)` — title text for cue `dwName`.
900/// * `note(<dwName:DWORD> <data:ZSTR>)` — comment text for cue `dwName`.
901/// * `ltxt(<dwName> <dwSampleLength> <dwPurpose> <wCountry> <wLanguage>
902///   <wDialect> <wCodePage> <data:BYTE>...)` — text covering a
903///   `dwSampleLength`-sample segment starting at cue `dwName`. The
904///   parser surfaces the segment length under `.ltxt.<dwName>.length`,
905///   the FOURCC purpose under `.ltxt.<dwName>.purpose`, the text
906///   payload (trimmed at the first NUL) under `.ltxt.<dwName>.text`,
907///   and the four locale WORDs under `.ltxt.<dwName>.country` /
908///   `.language` / `.dialect` / `.code_page` (raw decimals, always
909///   emitted). Per §3 "Text with Data Length Information" the country
910///   and `(language, dialect)` codes come from the same Chapter-2
911///   tables the `CSET` chunk uses, so the parser resolves them through
912///   the shared table lookups into `.ltxt.<dwName>.country_name` /
913///   `.language_name` (emitted only when the code is in the spec's
914///   enumerated set).
915/// * `file(<dwName:DWORD> <dwMedType:DWORD> <fileData:BYTE>...)` —
916///   embedded media file for cue `dwName`. Per §3 "Embedded File
917///   Information" `dwMedType` identifies the file type carried in
918///   `fileData` ("If the fileData section contains a RIFF form, the
919///   dwMedType field is the same as the RIFF form type"; zero is
920///   explicitly allowed). The parser surfaces the type under
921///   `.file.<dwName>.med_type` (FOURCC text when printable, `0` for
922///   the spec-allowed zero value, hex otherwise) and the embedded
923///   payload length under `.file.<dwName>.body_len`. The `fileData`
924///   bytes themselves are not surfaced through the string-typed
925///   metadata API — `body_len` keeps the payload observable without
926///   pretending the parser can interpret the inner format.
927///
928/// Sub-chunks shorter than the minimum required header are skipped.
929fn parse_adtl_list(buf: &[u8], out: &mut Vec<(String, String)>) {
930    let mut i = 0usize;
931    while i + 8 <= buf.len() {
932        let id: [u8; 4] = [buf[i], buf[i + 1], buf[i + 2], buf[i + 3]];
933        let size = u32::from_le_bytes([buf[i + 4], buf[i + 5], buf[i + 6], buf[i + 7]]) as usize;
934        i += 8;
935        if i + size > buf.len() {
936            break;
937        }
938        let body = &buf[i..i + size];
939        match &id {
940            b"labl" | b"note" if body.len() >= 4 => {
941                let dw_name = u32::from_le_bytes([body[0], body[1], body[2], body[3]]);
942                let raw = &body[4..];
943                let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
944                let text = String::from_utf8_lossy(&raw[..end]).trim().to_string();
945                if !text.is_empty() {
946                    let sub = if &id == b"labl" { "labl" } else { "note" };
947                    out.push((format!("wav:adtl.{sub}.{dw_name}"), text));
948                }
949            }
950            // 4 dwName + 4 dwSampleLength + 4 dwPurpose + 2 wCountry
951            // + 2 wLanguage + 2 wDialect + 2 wCodePage = 20 bytes
952            // fixed header.
953            b"ltxt" if body.len() >= 20 => {
954                let dw_name = u32::from_le_bytes([body[0], body[1], body[2], body[3]]);
955                let dw_length = u32::from_le_bytes([body[4], body[5], body[6], body[7]]);
956                let purpose = &body[8..12];
957                out.push((
958                    format!("wav:adtl.ltxt.{dw_name}.length"),
959                    dw_length.to_string(),
960                ));
961                // Render purpose as a FOURCC when printable, hex otherwise.
962                let purpose_str = if purpose.iter().all(|&b| (0x20..=0x7E).contains(&b)) {
963                    String::from_utf8_lossy(purpose).to_string()
964                } else {
965                    format!(
966                        "0x{:02X}{:02X}{:02X}{:02X}",
967                        purpose[0], purpose[1], purpose[2], purpose[3]
968                    )
969                };
970                out.push((format!("wav:adtl.ltxt.{dw_name}.purpose"), purpose_str));
971                // §3 "Text with Data Length Information": wCountry,
972                // (wLanguage, wDialect) and wCodePage qualify the text
973                // payload, drawing on the same Chapter-2 country /
974                // language-and-dialect tables that the CSET chunk uses.
975                // Raw decimals are always emitted (zero = "use the
976                // default" per the CSET zero-value semantics); the
977                // human-readable names only when the table resolves.
978                let country = u16::from_le_bytes([body[12], body[13]]);
979                let language = u16::from_le_bytes([body[14], body[15]]);
980                let dialect = u16::from_le_bytes([body[16], body[17]]);
981                let code_page = u16::from_le_bytes([body[18], body[19]]);
982                out.push((
983                    format!("wav:adtl.ltxt.{dw_name}.country"),
984                    country.to_string(),
985                ));
986                if let Some(name) = cset_country_name(country) {
987                    out.push((
988                        format!("wav:adtl.ltxt.{dw_name}.country_name"),
989                        name.to_string(),
990                    ));
991                }
992                out.push((
993                    format!("wav:adtl.ltxt.{dw_name}.language"),
994                    language.to_string(),
995                ));
996                out.push((
997                    format!("wav:adtl.ltxt.{dw_name}.dialect"),
998                    dialect.to_string(),
999                ));
1000                if let Some(name) = cset_language_name(language, dialect) {
1001                    out.push((
1002                        format!("wav:adtl.ltxt.{dw_name}.language_name"),
1003                        name.to_string(),
1004                    ));
1005                }
1006                out.push((
1007                    format!("wav:adtl.ltxt.{dw_name}.code_page"),
1008                    code_page.to_string(),
1009                ));
1010                let raw = &body[20..];
1011                // The text payload may or may not be NUL-terminated
1012                // per the spec ("<data:BYTE>..."); trim at the first
1013                // NUL if present and strip surrounding whitespace.
1014                let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
1015                let text = String::from_utf8_lossy(&raw[..end]).trim().to_string();
1016                if !text.is_empty() {
1017                    out.push((format!("wav:adtl.ltxt.{dw_name}.text"), text));
1018                }
1019            }
1020            // §3 "Embedded File Information": dwName + dwMedType fixed
1021            // header, then the embedded file bytes. The fileData payload
1022            // is not surfaced through the string-typed metadata API;
1023            // `med_type` + `body_len` keep it observable.
1024            b"file" if body.len() >= 8 => {
1025                let dw_name = u32::from_le_bytes([body[0], body[1], body[2], body[3]]);
1026                let med_type = &body[4..8];
1027                // "This field can contain a zero value" — render the
1028                // spec-allowed zero as plain `0`, a printable FOURCC as
1029                // text, anything else as hex.
1030                let med_type_str = if med_type == [0, 0, 0, 0] {
1031                    "0".to_string()
1032                } else if med_type.iter().all(|&b| (0x20..=0x7E).contains(&b)) {
1033                    String::from_utf8_lossy(med_type).to_string()
1034                } else {
1035                    format!(
1036                        "0x{:02X}{:02X}{:02X}{:02X}",
1037                        med_type[0], med_type[1], med_type[2], med_type[3]
1038                    )
1039                };
1040                out.push((format!("wav:adtl.file.{dw_name}.med_type"), med_type_str));
1041                out.push((
1042                    format!("wav:adtl.file.{dw_name}.body_len"),
1043                    (body.len() - 8).to_string(),
1044                ));
1045            }
1046            // Truncated `labl`/`note`/`ltxt`/`file` (under the
1047            // fixed-header minimum) are skipped as opaque.
1048            _ => {}
1049        }
1050        i += size;
1051        if size % 2 == 1 {
1052            i += 1;
1053        }
1054    }
1055}
1056
1057/// Parse a `cue ` chunk body and emit `wav:cue.count` + per-point
1058/// `wav:cue.<dwName>.position` / `.fcc_chunk` / `.chunk_start` /
1059/// `.block_start` / `.sample_offset` keys. Layout per
1060/// `docs/container/riff/metadata/microsoft-riffmci.pdf` §3 "Cue-Points
1061/// Chunk":
1062///
1063/// ```text
1064/// <cue-ck> -> cue( <dwCuePoints:DWORD> <cue-point>... )
1065/// <cue-point> -> struct {
1066///     DWORD dwName;        // unique id, referenced by plst/adtl
1067///     DWORD dwPosition;    // sample position in the play order
1068///     FOURCC fccChunk;     // 'data' or 'slnt' (for wavl LIST forms)
1069///     DWORD dwChunkStart;  // byte offset of fccChunk within wavl LIST
1070///     DWORD dwBlockStart;  // byte offset of enclosing block
1071///     DWORD dwSampleOffset;// sample offset within block
1072/// }
1073/// ```
1074///
1075/// A truncated chunk (count > body) is treated as opaque and skipped.
1076/// Each cue-point record is 24 bytes; the function consumes as many
1077/// records as the body actually carries even if `dwCuePoints` claims
1078/// more (defensive vs. writers that lie about the count).
1079fn parse_cue_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
1080    if buf.len() < 4 {
1081        return;
1082    }
1083    let count_claimed = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
1084    let body = &buf[4..];
1085    const REC_LEN: usize = 24;
1086    let count_actual = (body.len() / REC_LEN) as u32;
1087    let count = count_claimed.min(count_actual);
1088    out.push(("wav:cue.count".to_string(), count.to_string()));
1089    for i in 0..count as usize {
1090        let off = i * REC_LEN;
1091        let dw_name = u32::from_le_bytes([body[off], body[off + 1], body[off + 2], body[off + 3]]);
1092        let dw_position =
1093            u32::from_le_bytes([body[off + 4], body[off + 5], body[off + 6], body[off + 7]]);
1094        let fcc_chunk = &body[off + 8..off + 12];
1095        let dw_chunk_start = u32::from_le_bytes([
1096            body[off + 12],
1097            body[off + 13],
1098            body[off + 14],
1099            body[off + 15],
1100        ]);
1101        let dw_block_start = u32::from_le_bytes([
1102            body[off + 16],
1103            body[off + 17],
1104            body[off + 18],
1105            body[off + 19],
1106        ]);
1107        let dw_sample_offset = u32::from_le_bytes([
1108            body[off + 20],
1109            body[off + 21],
1110            body[off + 22],
1111            body[off + 23],
1112        ]);
1113        let fcc_str = if fcc_chunk.iter().all(|&b| (0x20..=0x7E).contains(&b)) {
1114            String::from_utf8_lossy(fcc_chunk).to_string()
1115        } else {
1116            format!(
1117                "0x{:02X}{:02X}{:02X}{:02X}",
1118                fcc_chunk[0], fcc_chunk[1], fcc_chunk[2], fcc_chunk[3]
1119            )
1120        };
1121        out.push((
1122            format!("wav:cue.{dw_name}.position"),
1123            dw_position.to_string(),
1124        ));
1125        out.push((format!("wav:cue.{dw_name}.fcc_chunk"), fcc_str));
1126        out.push((
1127            format!("wav:cue.{dw_name}.chunk_start"),
1128            dw_chunk_start.to_string(),
1129        ));
1130        out.push((
1131            format!("wav:cue.{dw_name}.block_start"),
1132            dw_block_start.to_string(),
1133        ));
1134        out.push((
1135            format!("wav:cue.{dw_name}.sample_offset"),
1136            dw_sample_offset.to_string(),
1137        ));
1138    }
1139}
1140
1141/// Parse a `plst` (Playlist) chunk body and emit `wav:plst.count` plus
1142/// per-segment `wav:plst.<n>.cue_id` / `.length` / `.loops` keys. Layout
1143/// per `docs/container/riff/metadata/microsoft-riffmci.pdf` §3 "Playlist
1144/// Chunk":
1145///
1146/// ```text
1147/// <plst-ck> -> plst( <dwSegments:DWORD> <play-segment>... )
1148/// <play-segment> -> struct {
1149///     DWORD dwName;    // cue-point id (must match a <cue-ck> entry)
1150///     DWORD dwLength;  // section length in samples
1151///     DWORD dwLoops;   // play count
1152/// }
1153/// ```
1154///
1155/// The segment index `<n>` is the zero-based position in the playlist,
1156/// NOT `dwName` — unlike `cue ` / `smpl`-loops, multiple playlist
1157/// entries can reference the same cue point (a cue replayed twice =
1158/// two segments with identical `dwName`), so keying on the cue id
1159/// would collide. A `dwSegments` count exceeding what the body
1160/// actually carries is clamped to the records that fit (defensive
1161/// against writers that lie about the count); a body shorter than the
1162/// 4-byte segment-count header is treated as opaque and skipped.
1163fn parse_plst_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
1164    if buf.len() < 4 {
1165        return;
1166    }
1167    let count_claimed = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
1168    let body = &buf[4..];
1169    const REC_LEN: usize = 12;
1170    let count_actual = (body.len() / REC_LEN) as u32;
1171    let count = count_claimed.min(count_actual);
1172    out.push(("wav:plst.count".to_string(), count.to_string()));
1173    for i in 0..count as usize {
1174        let off = i * REC_LEN;
1175        let dw_name = u32::from_le_bytes([body[off], body[off + 1], body[off + 2], body[off + 3]]);
1176        let dw_length =
1177            u32::from_le_bytes([body[off + 4], body[off + 5], body[off + 6], body[off + 7]]);
1178        let dw_loops =
1179            u32::from_le_bytes([body[off + 8], body[off + 9], body[off + 10], body[off + 11]]);
1180        out.push((format!("wav:plst.{i}.cue_id"), dw_name.to_string()));
1181        out.push((format!("wav:plst.{i}.length"), dw_length.to_string()));
1182        out.push((format!("wav:plst.{i}.loops"), dw_loops.to_string()));
1183    }
1184}
1185
1186/// Parse a `smpl` (Sampler) chunk body and emit `wav:smpl.*` metadata
1187/// keys. Layout per the RIFF MCI / `mmreg.h`-era Sampler structure as
1188/// catalogued in
1189/// `docs/container/riff/metadata/exiftool-riff-tags.html` § "RIFF
1190/// Sampler Tags" and summarised in
1191/// `docs/container/riff/metadata/README.md` § "Sampler / Instrument
1192/// chunks":
1193///
1194/// ```text
1195/// <smpl-ck> -> smpl( <fixed:36 bytes> <loops:N × 24 bytes> <sampler-data> )
1196/// <fixed> -> struct {
1197///     DWORD dwManufacturer;
1198///     DWORD dwProduct;
1199///     DWORD dwSamplePeriod;       // nanoseconds per sample
1200///     DWORD dwMIDIUnityNote;      // 0..=127
1201///     DWORD dwMIDIPitchFraction;  // fractional offset, /0xFFFFFFFF
1202///     DWORD dwSMPTEFormat;        // 0/24/25/29/30 fps
1203///     DWORD dwSMPTEOffset;        // packed HH MM SS FF (MSB → LSB)
1204///     DWORD cSampleLoops;
1205///     DWORD cbSamplerData;        // bytes of trailing sampler-specific data
1206/// }
1207/// <loop> -> struct {
1208///     DWORD dwCuePointID;
1209///     DWORD dwType;               // 0=forward, 1=ping-pong, 2=reverse
1210///     DWORD dwStart;              // start sample offset
1211///     DWORD dwEnd;                // end sample offset
1212///     DWORD dwFraction;           // /0xFFFFFFFF fractional sample
1213///     DWORD dwPlayCount;          // 0 == infinite
1214/// }
1215/// ```
1216///
1217/// A `cSampleLoops` count exceeding what the chunk body actually
1218/// carries is clamped to the records that fit (defensive against
1219/// writers that lie about the count); a body shorter than the 36-byte
1220/// fixed header is treated as opaque and skipped.
1221fn parse_smpl_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
1222    const FIXED_LEN: usize = 36;
1223    const LOOP_LEN: usize = 24;
1224    if buf.len() < FIXED_LEN {
1225        return;
1226    }
1227    let r = |off: usize| -> u32 {
1228        u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
1229    };
1230    let manufacturer = r(0);
1231    let product = r(4);
1232    let sample_period = r(8);
1233    let midi_unity_note = r(12);
1234    let midi_pitch_fraction = r(16);
1235    let smpte_format = r(20);
1236    let smpte_offset = r(24);
1237    let num_loops_claimed = r(28);
1238    let sampler_data_len = r(32);
1239
1240    out.push((
1241        "wav:smpl.manufacturer".to_string(),
1242        manufacturer.to_string(),
1243    ));
1244    out.push(("wav:smpl.product".to_string(), product.to_string()));
1245    out.push((
1246        "wav:smpl.sample_period".to_string(),
1247        sample_period.to_string(),
1248    ));
1249    out.push((
1250        "wav:smpl.midi_unity_note".to_string(),
1251        midi_unity_note.to_string(),
1252    ));
1253    out.push((
1254        "wav:smpl.midi_pitch_fraction".to_string(),
1255        midi_pitch_fraction.to_string(),
1256    ));
1257    out.push((
1258        "wav:smpl.smpte_format".to_string(),
1259        smpte_format.to_string(),
1260    ));
1261    // SMPTE offset packs HH MM SS FF in the high-to-low bytes of the
1262    // DWORD. Render the canonical HH:MM:SS:FF form alongside the raw
1263    // value for callers that prefer the on-wire integer.
1264    let smpte_hh = (smpte_offset >> 24) & 0xFF;
1265    let smpte_mm = (smpte_offset >> 16) & 0xFF;
1266    let smpte_ss = (smpte_offset >> 8) & 0xFF;
1267    let smpte_ff = smpte_offset & 0xFF;
1268    out.push((
1269        "wav:smpl.smpte_offset".to_string(),
1270        format!("{smpte_hh:02}:{smpte_mm:02}:{smpte_ss:02}:{smpte_ff:02}"),
1271    ));
1272    out.push((
1273        "wav:smpl.sampler_data_len".to_string(),
1274        sampler_data_len.to_string(),
1275    ));
1276
1277    let body = &buf[FIXED_LEN..];
1278    let num_loops_fits = (body.len() / LOOP_LEN) as u32;
1279    let num_loops = num_loops_claimed.min(num_loops_fits);
1280    out.push((
1281        "wav:smpl.num_sample_loops".to_string(),
1282        num_loops.to_string(),
1283    ));
1284    for i in 0..num_loops as usize {
1285        let off = i * LOOP_LEN;
1286        let loop_field = |word: usize| -> u32 {
1287            let p = off + word * 4;
1288            u32::from_le_bytes([body[p], body[p + 1], body[p + 2], body[p + 3]])
1289        };
1290        let cue_point_id = loop_field(0);
1291        let loop_type = loop_field(1);
1292        let start = loop_field(2);
1293        let end = loop_field(3);
1294        let fraction = loop_field(4);
1295        let play_count = loop_field(5);
1296        out.push((
1297            format!("wav:smpl.loop.{i}.cue_point_id"),
1298            cue_point_id.to_string(),
1299        ));
1300        out.push((format!("wav:smpl.loop.{i}.type"), loop_type.to_string()));
1301        out.push((format!("wav:smpl.loop.{i}.start"), start.to_string()));
1302        out.push((format!("wav:smpl.loop.{i}.end"), end.to_string()));
1303        out.push((format!("wav:smpl.loop.{i}.fraction"), fraction.to_string()));
1304        out.push((
1305            format!("wav:smpl.loop.{i}.play_count"),
1306            play_count.to_string(),
1307        ));
1308    }
1309}
1310
1311/// Parse an `inst` (Instrument) chunk body and emit `wav:inst.*`
1312/// metadata keys. Layout per
1313/// `docs/container/riff/metadata/exiftool-riff-tags.html` § "RIFF
1314/// Instrument Tags":
1315///
1316/// ```text
1317/// <inst-ck> -> inst( <UnshiftedNote:i8> <FineTune:i8> <Gain:i8>
1318///                    <LowNote:u8> <HighNote:u8>
1319///                    <LowVelocity:u8> <HighVelocity:u8> )
1320/// ```
1321///
1322/// `UnshiftedNote`, `LowNote`, `HighNote` are MIDI note numbers
1323/// (0..=127); `FineTune` is cents and `Gain` is dB, both signed
1324/// 8-bit. Velocity range is 1..=127 (unsigned).
1325///
1326/// A body shorter than the 7-byte fixed struct is treated as opaque
1327/// and skipped.
1328fn parse_inst_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
1329    const FIXED_LEN: usize = 7;
1330    if buf.len() < FIXED_LEN {
1331        return;
1332    }
1333    let unshifted_note = buf[0];
1334    let fine_tune = buf[1] as i8;
1335    let gain = buf[2] as i8;
1336    let low_note = buf[3];
1337    let high_note = buf[4];
1338    let low_velocity = buf[5];
1339    let high_velocity = buf[6];
1340    out.push((
1341        "wav:inst.unshifted_note".to_string(),
1342        unshifted_note.to_string(),
1343    ));
1344    out.push(("wav:inst.fine_tune".to_string(), fine_tune.to_string()));
1345    out.push(("wav:inst.gain".to_string(), gain.to_string()));
1346    out.push(("wav:inst.low_note".to_string(), low_note.to_string()));
1347    out.push(("wav:inst.high_note".to_string(), high_note.to_string()));
1348    out.push((
1349        "wav:inst.low_velocity".to_string(),
1350        low_velocity.to_string(),
1351    ));
1352    out.push((
1353        "wav:inst.high_velocity".to_string(),
1354        high_velocity.to_string(),
1355    ));
1356}
1357
1358/// `AcidChunk::flags` bit 0 — the clip is a one-shot (played once,
1359/// not tempo-stretched as a loop).
1360pub const ACID_FLAG_ONE_SHOT: u32 = 1 << 0;
1361/// `AcidChunk::flags` bit 1 — `root_note` carries a meaningful value.
1362pub const ACID_FLAG_ROOT_NOTE_SET: u32 = 1 << 1;
1363/// `AcidChunk::flags` bit 2 — time-stretch enabled.
1364pub const ACID_FLAG_STRETCH: u32 = 1 << 2;
1365/// `AcidChunk::flags` bit 3 — disk-based (streamed) rather than
1366/// RAM-resident.
1367pub const ACID_FLAG_DISK_BASED: u32 = 1 << 3;
1368/// `AcidChunk::flags` bit 4 — high-octave root-note interpretation.
1369pub const ACID_FLAG_HIGH_OCTAVE: u32 = 1 << 4;
1370
1371/// Typed view of the Acidizer `acid` chunk body (loop/tempo metadata
1372/// written by loop-authoring tools). Field offsets and flag-bit
1373/// semantics per
1374/// `docs/container/riff/metadata/exiftool-riff-tags.html` § "RIFF
1375/// Acidizer Tags" (byte-indexed table: flags at 0, root note at 4,
1376/// beats at 12, meter at 16, tempo at 20):
1377///
1378/// ```text
1379/// <acid-ck> -> acid( <Flags:u32> <RootNote:u16> <Reserved:[u8;6]>
1380///                    <Beats:u32> <Meter:u32> <Tempo:f32> )   // 24 bytes
1381/// ```
1382///
1383/// All integer fields are little-endian. The staged reference
1384/// enumerates the field *offsets*; the widths follow from the offset
1385/// deltas (flags 0..4, beats 12..16, meter 16..20, tempo 20..24).
1386/// Within the 8-byte span between the root-note offset (4) and the
1387/// beats offset (12) only the leading 16 bits are enumerated (root
1388/// note, value range 48..=71 per the table) — the remaining 6 bytes
1389/// are not described, so they are carried verbatim in [`Self::reserved`]
1390/// and round-trip losslessly. `tempo` is the 32-bit field at offset
1391/// 20, interpreted as an IEEE-754 little-endian beats-per-minute
1392/// value (the only 32-bit reading under which musically plausible
1393/// tempos are representable with fractional precision).
1394#[derive(Clone, Copy, Debug, PartialEq)]
1395pub struct AcidChunk {
1396    /// Bit-field at offset 0 — see the `ACID_FLAG_*` constants.
1397    pub flags: u32,
1398    /// Root note at offset 4. 48 = C up to 71 = High B per the staged
1399    /// table; meaningful only when [`Self::root_note_set`] is true.
1400    pub root_note: u16,
1401    /// Bytes 6..12 — not enumerated by the staged reference; preserved
1402    /// verbatim so a read→write pass is byte-lossless.
1403    pub reserved: [u8; 6],
1404    /// Number of beats in the clip (offset 12).
1405    pub num_beats: u32,
1406    /// Meter field at offset 16 (single 32-bit field in the staged
1407    /// table; carried raw).
1408    pub meter: u32,
1409    /// Tempo in beats per minute (offset 20).
1410    pub tempo: f32,
1411}
1412
1413impl AcidChunk {
1414    /// Fixed body length of the `acid` chunk in bytes.
1415    pub const BODY_LEN: usize = 24;
1416
1417    /// Bit 0 — one-shot clip.
1418    pub fn one_shot(&self) -> bool {
1419        self.flags & ACID_FLAG_ONE_SHOT != 0
1420    }
1421
1422    /// Bit 1 — `root_note` carries a meaningful value.
1423    pub fn root_note_set(&self) -> bool {
1424        self.flags & ACID_FLAG_ROOT_NOTE_SET != 0
1425    }
1426
1427    /// Bit 2 — time-stretch enabled.
1428    pub fn stretch(&self) -> bool {
1429        self.flags & ACID_FLAG_STRETCH != 0
1430    }
1431
1432    /// Bit 3 — disk-based (streamed).
1433    pub fn disk_based(&self) -> bool {
1434        self.flags & ACID_FLAG_DISK_BASED != 0
1435    }
1436
1437    /// Bit 4 — high-octave root-note interpretation.
1438    pub fn high_octave(&self) -> bool {
1439        self.flags & ACID_FLAG_HIGH_OCTAVE != 0
1440    }
1441
1442    /// Note name for [`Self::root_note`] per the staged value table
1443    /// (48 = C … 59 = B, 60 = High C … 71 = High B). `None` outside
1444    /// the enumerated 48..=71 range.
1445    pub fn root_note_name(&self) -> Option<&'static str> {
1446        const NAMES: [&str; 24] = [
1447            "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", "High C", "High C#",
1448            "High D", "High D#", "High E", "High F", "High F#", "High G", "High G#", "High A",
1449            "High A#", "High B",
1450        ];
1451        NAMES.get(self.root_note.wrapping_sub(48) as usize).copied()
1452    }
1453
1454    /// Decode an `acid` chunk body. Returns `None` when the body is
1455    /// shorter than the 24-byte fixed struct (treated as opaque, same
1456    /// policy as the other fixed-layout metadata chunks). Trailing
1457    /// bytes past offset 24 are tolerated and ignored.
1458    pub fn parse(buf: &[u8]) -> Option<AcidChunk> {
1459        if buf.len() < Self::BODY_LEN {
1460            return None;
1461        }
1462        let r32 =
1463            |o: usize| -> u32 { u32::from_le_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]) };
1464        let mut reserved = [0u8; 6];
1465        reserved.copy_from_slice(&buf[6..12]);
1466        Some(AcidChunk {
1467            flags: r32(0),
1468            root_note: u16::from_le_bytes([buf[4], buf[5]]),
1469            reserved,
1470            num_beats: r32(12),
1471            meter: r32(16),
1472            tempo: f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]),
1473        })
1474    }
1475
1476    /// Serialize the 24-byte `acid` chunk body (little-endian, layout
1477    /// per the struct-level documentation).
1478    pub fn to_bytes(&self) -> [u8; 24] {
1479        let mut out = [0u8; 24];
1480        out[0..4].copy_from_slice(&self.flags.to_le_bytes());
1481        out[4..6].copy_from_slice(&self.root_note.to_le_bytes());
1482        out[6..12].copy_from_slice(&self.reserved);
1483        out[12..16].copy_from_slice(&self.num_beats.to_le_bytes());
1484        out[16..20].copy_from_slice(&self.meter.to_le_bytes());
1485        out[20..24].copy_from_slice(&self.tempo.to_le_bytes());
1486        out
1487    }
1488}
1489
1490/// Parse an `acid` chunk body, surface `wav:acid.*` metadata keys and
1491/// return the typed view for the demuxer's [`WavDemuxer::acid`]
1492/// accessor. Keys:
1493///
1494/// - `wav:acid.flags` — bit-field as `0xXXXXXXXX`.
1495/// - `wav:acid.one_shot` / `.root_note_set` / `.stretch` /
1496///   `.disk_based` / `.high_octave` — each documented flag bit as
1497///   `0` / `1`.
1498/// - `wav:acid.root_note` — raw value, plus `wav:acid.root_note_name`
1499///   when the value falls in the enumerated 48..=71 table.
1500/// - `wav:acid.num_beats`, `wav:acid.meter`, `wav:acid.tempo`.
1501/// - `wav:acid.reserved` — bytes 6..12 as hex, only when nonzero.
1502/// - `wav:acid.body_len` — only when the body exceeds the 24-byte
1503///   fixed struct (extension bytes riding along).
1504fn parse_acid_chunk(buf: &[u8], out: &mut Vec<(String, String)>) -> Option<AcidChunk> {
1505    let acid = AcidChunk::parse(buf)?;
1506    out.push((
1507        "wav:acid.flags".to_string(),
1508        format!("0x{:08X}", acid.flags),
1509    ));
1510    out.push((
1511        "wav:acid.one_shot".to_string(),
1512        (acid.one_shot() as u8).to_string(),
1513    ));
1514    out.push((
1515        "wav:acid.root_note_set".to_string(),
1516        (acid.root_note_set() as u8).to_string(),
1517    ));
1518    out.push((
1519        "wav:acid.stretch".to_string(),
1520        (acid.stretch() as u8).to_string(),
1521    ));
1522    out.push((
1523        "wav:acid.disk_based".to_string(),
1524        (acid.disk_based() as u8).to_string(),
1525    ));
1526    out.push((
1527        "wav:acid.high_octave".to_string(),
1528        (acid.high_octave() as u8).to_string(),
1529    ));
1530    out.push(("wav:acid.root_note".to_string(), acid.root_note.to_string()));
1531    if let Some(name) = acid.root_note_name() {
1532        out.push(("wav:acid.root_note_name".to_string(), name.to_string()));
1533    }
1534    out.push(("wav:acid.num_beats".to_string(), acid.num_beats.to_string()));
1535    out.push(("wav:acid.meter".to_string(), acid.meter.to_string()));
1536    out.push(("wav:acid.tempo".to_string(), acid.tempo.to_string()));
1537    if acid.reserved.iter().any(|&b| b != 0) {
1538        let hex: String = acid.reserved.iter().map(|b| format!("{b:02X}")).collect();
1539        out.push(("wav:acid.reserved".to_string(), hex));
1540    }
1541    if buf.len() > AcidChunk::BODY_LEN {
1542        out.push(("wav:acid.body_len".to_string(), buf.len().to_string()));
1543    }
1544    Some(acid)
1545}
1546
1547/// Parse a `fact` chunk body per
1548/// `docs/container/riff/metadata/microsoft-riffmci.pdf` §3 "FACT Chunk":
1549///
1550/// ```text
1551/// <fact-ck> -> fact( <dwFileSize:DWORD> )   // Number of samples per channel
1552/// ```
1553///
1554/// The 1991 RIFF MCI spec defines exactly one field — `dwFileSize`,
1555/// the count of *samples per channel* (the spec text uses "sample"
1556/// in the per-channel sense; the field name predates the
1557/// per-channel/per-frame disambiguation). The spec explicitly
1558/// reserves the trailing bytes for future extension fields: any
1559/// bytes past offset 4 must be tolerated, with the chunk size
1560/// telling applications which fields are present.
1561///
1562/// The `fact` chunk is required for compressed WAV streams and for
1563/// any file using the `wavl LIST` waveform container; for plain
1564/// PCM `data` chunks it is optional. The parser surfaces:
1565///
1566/// - `wav:fact.sample_count` — `dwFileSize` as a decimal string.
1567/// - `wav:fact.body_len` — total chunk-body length, present when the
1568///   body exceeds the 4-byte fixed field so downstream tools can see
1569///   that future-extension bytes are riding along (the bytes themselves
1570///   are opaque to this parser by spec).
1571///
1572/// Returns the `dwFileSize` value so the demuxer can use it as the
1573/// authoritative `total_samples` for the stream's duration. A body
1574/// shorter than 4 bytes is treated as opaque-and-skipped (returns
1575/// `None`, no metadata key emitted).
1576fn parse_fact_chunk(buf: &[u8], out: &mut Vec<(String, String)>) -> Option<u32> {
1577    if buf.len() < 4 {
1578        return None;
1579    }
1580    let sample_count = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
1581    out.push((
1582        "wav:fact.sample_count".to_string(),
1583        sample_count.to_string(),
1584    ));
1585    if buf.len() > 4 {
1586        out.push(("wav:fact.body_len".to_string(), buf.len().to_string()));
1587    }
1588    Some(sample_count)
1589}
1590
1591/// Parse an `iXML` chunk body and surface its payload through the
1592/// metadata table.
1593///
1594/// The `iXML` chunk carries a UTF-8 XML document (third-party
1595/// production-recorder metadata block — `IXML_VERSION`, `PROJECT`,
1596/// `SCENE`, `TAKE`, `TAPE`, `NOTE`, `UBITS`, `FILE_UID`, a `BWF`
1597/// sub-group mirroring the `bext` fields and a `TRACK_LIST` of
1598/// per-track name / function / channel-index mappings). The schema
1599/// catalog is documented in
1600/// `docs/container/riff/metadata/exiftool-riff-tags.html` § `iXML`
1601/// and discussed in
1602/// `docs/container/riff/metadata/README.md` § "iXML".
1603///
1604/// The chunk body is surfaced verbatim under `wav:ixml` (trimmed at
1605/// the first NUL and stripped of surrounding whitespace so a writer
1606/// that pads the body to a fixed length with NULs does not surface
1607/// spurious trailing bytes). The raw chunk-body length is always
1608/// surfaced under `wav:ixml.body_len` whenever the chunk is present
1609/// — even when the text payload itself is empty — so downstream
1610/// tooling can distinguish "no `iXML` chunk" from "an `iXML` chunk
1611/// whose body is entirely NULs / whitespace".
1612///
1613/// An empty chunk body (zero bytes between header and pad) is treated
1614/// as opaque: `wav:ixml.body_len = 0` is emitted but no `wav:ixml`
1615/// text key is added.
1616fn parse_ixml_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
1617    out.push(("wav:ixml.body_len".to_string(), buf.len().to_string()));
1618    let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
1619    let text = String::from_utf8_lossy(&buf[..end]).trim().to_string();
1620    if !text.is_empty() {
1621        out.push(("wav:ixml".to_string(), text));
1622    }
1623}
1624
1625/// Parse an `<axml>` chunk body and surface its XML payload through
1626/// the metadata table.
1627///
1628/// The `<axml>` chunk carries an XML document compliant with XML 1.0
1629/// (or later), per
1630/// `docs/container/riff/metadata/ebu-tech3285s5-ADM.pdf` §3 "AXML
1631/// chunk definition":
1632///
1633/// > The <axml> chunk consists of a header followed by data compliant
1634/// > with the XML format. The overall length of the chunk is not fixed.
1635/// >
1636/// > typedef struct axml {
1637/// >     CHAR    ckID[4];     // {'a','x','m','l'}
1638/// >     DWORD   ckSize;      // size of chunk
1639/// >     CHAR    xmlData[];   // text data in XML
1640/// > } axml_chunk;
1641///
1642/// The §3 "Terminology" paragraph also notes the `<axml>` chunk may
1643/// occur in any order relative to other BWF chunks within the same
1644/// file — the demuxer's chunk-walk already tolerates any inter-chunk
1645/// ordering between `fmt ` and `data`.
1646///
1647/// Typical payloads are EBUCore (`<ebuCoreMain>`) wrappers around an
1648/// `<audioFormatExtended>` ADM document or an ISRC identifier
1649/// declaration (§4.1 + §4.2 examples). This parser does not interpret
1650/// the XML schema — it surfaces the textual payload verbatim so a
1651/// downstream tool (or a higher-level ADM-aware crate) can apply the
1652/// schema-specific decoding without re-walking the RIFF tree.
1653///
1654/// Surface shape mirrors `parse_ixml_chunk` (the sibling third-party
1655/// XML metadata block):
1656///
1657/// * `wav:axml.body_len` — raw on-wire chunk-body length. Always
1658///   emitted when the chunk is present (even for empty / NUL-only /
1659///   whitespace-only bodies) so downstream tooling can distinguish
1660///   "no `<axml>` chunk" from "an `<axml>` chunk reserved for later
1661///   population". Excludes the 8-byte chunk header and the implicit
1662///   RIFF §2 word-align pad byte.
1663/// * `wav:axml` — the UTF-8 XML text payload. Trimmed at the first
1664///   NUL byte (writers commonly NUL-pad to reserve room for in-place
1665///   editing of a larger ADM document) and stripped of surrounding
1666///   whitespace. Omitted entirely when the pre-NUL, trimmed text is
1667///   empty — the §3 note "if the receiving device cannot interpret
1668///   the content of the <axml> chunk in accordance with the
1669///   specification stated in the XML, the entire chunk shall be
1670///   ignored" applies to the *schema* level, not the byte level, so a
1671///   present-but-empty body still surfaces its `body_len` so the
1672///   placeholder is observable.
1673fn parse_axml_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
1674    out.push(("wav:axml.body_len".to_string(), buf.len().to_string()));
1675    let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
1676    let text = String::from_utf8_lossy(&buf[..end]).trim().to_string();
1677    if !text.is_empty() {
1678        out.push(("wav:axml".to_string(), text));
1679    }
1680}
1681
1682/// Parse a `_PMX` (Adobe XMP packet) chunk body and surface its UTF-8
1683/// XMP packet through the metadata table.
1684///
1685/// The `_PMX` FOURCC is the WAV/AVI carrier for an XMP serialised
1686/// packet, catalogued under
1687/// `docs/container/riff/metadata/exiftool-riff-tags.html` § "RIFF Main
1688/// tags" (entry `'_PMX'`, family `XMP`, scope note "AVI and WAV
1689/// files"). The FOURCC is little-endian "XMP_" reversed — the same
1690/// convention RIFF uses for chunks whose payload originates in a
1691/// little-endian DWORD-aligned authoring tool. The payload itself is
1692/// the XMP packet text exactly as it would appear in an XMP sidecar
1693/// (`x:xmpmeta` wrapped in `<?xpacket begin=...?>` /
1694/// `<?xpacket end=...?>` processing instructions).
1695///
1696/// Surface shape mirrors `parse_ixml_chunk` and `parse_axml_chunk`
1697/// (the two existing third-party XML metadata blocks) for orthogonality
1698/// at the consumer surface:
1699///
1700/// * `wav:xmp.body_len` — raw on-wire chunk-body length. Always
1701///   emitted when the `_PMX` chunk is present (even for empty /
1702///   NUL-only / whitespace-only bodies) so downstream tooling can
1703///   distinguish "no `_PMX` chunk" from "an `_PMX` chunk reserved for
1704///   later population by an XMP-aware writer". Excludes the 8-byte
1705///   chunk header and the implicit RIFF §2 word-align pad byte.
1706/// * `wav:xmp` — the UTF-8 XMP packet text. Trimmed at the first NUL
1707///   byte (writers commonly NUL-pad to a fixed length so an XMP-aware
1708///   editor can rewrite the packet in place without re-walking the
1709///   chunk graph) and stripped of surrounding whitespace. Omitted
1710///   entirely when the pre-NUL, trimmed text is empty — the
1711///   placeholder body length still surfaces so the reservation is
1712///   observable.
1713///
1714/// This parser deliberately does not interpret the XMP schema (RDF,
1715/// namespace prefixes, xpacket processing instructions). A higher
1716/// layer (or a dedicated XMP crate) can apply the schema-specific
1717/// decoding without re-walking the RIFF tree. Keeps the wav module's
1718/// surface schema-agnostic, matching the existing `_PMX`-adjacent
1719/// `iXML` and `<axml>` parsers.
1720fn parse_pmx_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
1721    out.push(("wav:xmp.body_len".to_string(), buf.len().to_string()));
1722    let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
1723    let text = String::from_utf8_lossy(&buf[..end]).trim().to_string();
1724    if !text.is_empty() {
1725        out.push(("wav:xmp".to_string(), text));
1726    }
1727}
1728
1729/// Surface metadata for a `JUNK` (Filler) chunk per
1730/// `docs/container/riff/metadata/microsoft-riffmci.pdf` §2 "JUNK
1731/// (Filler) Chunk":
1732///
1733/// > A JUNK chunk represents padding, filler or outdated information.
1734/// > It contains no relevant data; it is a space filler of arbitrary
1735/// > size.
1736///
1737/// We deliberately do not surface the chunk body (it's defined as
1738/// random/outdated data with no semantic content). What we do surface
1739/// is *accounting*: how many `JUNK` chunks the file contains and the
1740/// cumulative number of payload bytes they reserve. This lets a
1741/// downstream tool answer "is this writer leaving room for in-place
1742/// edits, and how much?" without us pretending the bytes carry meaning.
1743///
1744/// Keys:
1745///
1746/// * `wav:junk.count` — number of `JUNK` chunks seen so far. Each
1747///   call increments the counter so a multi-`JUNK` file (common when
1748///   a writer reserves separate slots ahead of `LIST INFO` and ahead
1749///   of `data`) is fully observable.
1750/// * `wav:junk.total_bytes` — cumulative payload size across all
1751///   `JUNK` chunks (the spec's "arbitrary size" filler region; does
1752///   not include the 8-byte chunk header or the trailing word-align
1753///   pad byte).
1754/// * `wav:junk.<n>.body_len` — per-chunk payload size, indexed
1755///   zero-based by encounter order. Allows a downstream tool to
1756///   distinguish "one big filler" from "many small fillers" without
1757///   re-walking the file.
1758///
1759/// Empty (`size = 0`) `JUNK` chunks are tolerated and still bump the
1760/// counter; their `body_len` surfaces as `0`. The spec calls the
1761/// filler "of arbitrary size" so a zero-length body is in-range.
1762fn surface_junk_metadata(out: &mut Vec<(String, String)>, size: u64) {
1763    // Count existing entries to derive the next zero-based index and
1764    // the running total. We linear-scan the metadata vector because
1765    // it's already keyed by string and we want a single source of
1766    // truth (no parallel counter to forget to update). Metadata
1767    // vectors stay small (low hundreds of entries) so this is O(n)
1768    // per JUNK chunk over a tiny n.
1769    let mut count: u64 = 0;
1770    let mut total: u64 = 0;
1771    for (k, v) in out.iter() {
1772        if k == "wav:junk.count" {
1773            count = v.parse().unwrap_or(count);
1774        } else if k == "wav:junk.total_bytes" {
1775            total = v.parse().unwrap_or(total);
1776        }
1777    }
1778    let idx = count;
1779    count = count.saturating_add(1);
1780    total = total.saturating_add(size);
1781    // Per-chunk entry.
1782    out.push((format!("wav:junk.{idx}.body_len"), size.to_string()));
1783    // Update the rolling aggregates. We push fresh entries rather than
1784    // mutate in place so the vector stays append-only (matching how
1785    // every other chunk parser in this module emits).
1786    out.retain(|(k, _)| k != "wav:junk.count" && k != "wav:junk.total_bytes");
1787    out.push(("wav:junk.count".to_string(), count.to_string()));
1788    out.push(("wav:junk.total_bytes".to_string(), total.to_string()));
1789}
1790
1791/// Surface metadata for a `slnt` (silence) chunk per
1792/// `docs/container/riff/metadata/microsoft-riffmci.pdf` §3 "Wave Data":
1793///
1794/// > `<silence-ck>` ➝ `slnt( <dwSamples:DWORD> )` — Count of silent
1795/// > samples.
1796///
1797/// The §3 note clarifies that "the `slnt` chunk represents silence, not
1798/// necessarily a repeated zero volume or baseline sample" — i.e. the
1799/// chunk records a *count of silent samples* rather than carrying any
1800/// PCM payload. A `slnt` chunk most commonly appears inside a `wavl`
1801/// LIST alternating with `data` chunks (a sparse-silence encoding), but
1802/// the §3 grammar `<wave-data> ➝ { <data-ck> | <data-list> }` also lets
1803/// the demuxer encounter a top-level `slnt` sibling of `data`; we
1804/// account for every occurrence either way.
1805///
1806/// We deliberately do not synthesise real zero/baseline samples into
1807/// the decoded stream (that is a host-runtime playback decision, and
1808/// the §3 note is explicit that the "right" fill value is
1809/// context-dependent — the last-played sample, not necessarily zero).
1810/// Instead we surface accounting so a downstream tool can observe how
1811/// many silent samples the producer encoded sparsely without re-walking
1812/// the file:
1813///
1814/// * `wav:slnt.count` — number of `slnt` chunks seen so far. Each call
1815///   increments the counter so a multi-`slnt` file (the normal `wavl`
1816///   case) is fully observable.
1817/// * `wav:slnt.total_samples` — cumulative silent-sample count across
1818///   all `slnt` chunks (the sum of every `dwSamples` field).
1819/// * `wav:slnt.<n>.samples` — per-chunk `dwSamples` value, indexed
1820///   zero-based by encounter order.
1821///
1822/// A body shorter than the 4-byte `dwSamples` field is treated as
1823/// opaque: the chunk is still counted (so the reservation is
1824/// observable) but contributes `0` to the running sample total and its
1825/// per-chunk `samples` key is omitted, mirroring how the other
1826/// fixed-struct parsers treat an under-length body.
1827fn surface_slnt_metadata(out: &mut Vec<(String, String)>, buf: &[u8]) {
1828    let mut count: u64 = 0;
1829    let mut total: u64 = 0;
1830    for (k, v) in out.iter() {
1831        if k == "wav:slnt.count" {
1832            count = v.parse().unwrap_or(count);
1833        } else if k == "wav:slnt.total_samples" {
1834            total = v.parse().unwrap_or(total);
1835        }
1836    }
1837    let idx = count;
1838    count = count.saturating_add(1);
1839    if buf.len() >= 4 {
1840        let samples = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
1841        total = total.saturating_add(samples as u64);
1842        out.push((format!("wav:slnt.{idx}.samples"), samples.to_string()));
1843    }
1844    out.retain(|(k, _)| k != "wav:slnt.count" && k != "wav:slnt.total_samples");
1845    out.push(("wav:slnt.count".to_string(), count.to_string()));
1846    out.push(("wav:slnt.total_samples".to_string(), total.to_string()));
1847}
1848
1849/// Map a Microsoft RIFF MCI §3 "Country Codes" three-digit code to its
1850/// human-readable name. Returns `None` for unknown codes; the caller
1851/// surfaces the numeric value regardless so a future code-page addition
1852/// is still visible.
1853fn cset_country_name(code: u16) -> Option<&'static str> {
1854    // Verbatim from `docs/container/riff/metadata/microsoft-riffmci.pdf`
1855    // §3 "Country Codes" (the table immediately following the CSET
1856    // definition). Codes are three-digit telephony region codes.
1857    match code {
1858        0 => Some("None"),
1859        1 => Some("USA"),
1860        2 => Some("Canada"),
1861        3 => Some("Latin America"),
1862        30 => Some("Greece"),
1863        31 => Some("Netherlands"),
1864        32 => Some("Belgium"),
1865        33 => Some("France"),
1866        34 => Some("Spain"),
1867        39 => Some("Italy"),
1868        41 => Some("Switzerland"),
1869        43 => Some("Austria"),
1870        44 => Some("United Kingdom"),
1871        45 => Some("Denmark"),
1872        46 => Some("Sweden"),
1873        47 => Some("Norway"),
1874        49 => Some("West Germany"),
1875        52 => Some("Mexico"),
1876        55 => Some("Brazil"),
1877        61 => Some("Australia"),
1878        64 => Some("New Zealand"),
1879        81 => Some("Japan"),
1880        82 => Some("Korea"),
1881        86 => Some("People's Republic of China"),
1882        88 => Some("Taiwan"),
1883        90 => Some("Turkey"),
1884        351 => Some("Portugal"),
1885        352 => Some("Luxembourg"),
1886        354 => Some("Iceland"),
1887        358 => Some("Finland"),
1888        _ => None,
1889    }
1890}
1891
1892/// Map a Microsoft RIFF MCI §3 (`wLanguage`, `wDialect`) pair to its
1893/// human-readable name. Returns `None` for any unknown pair; the caller
1894/// surfaces the raw numeric values regardless so dialects added by
1895/// vendor extensions are still observable.
1896fn cset_language_name(language: u16, dialect: u16) -> Option<&'static str> {
1897    // Verbatim from `docs/container/riff/metadata/microsoft-riffmci.pdf`
1898    // §3 "Language and Dialect Codes". The dialect column disambiguates
1899    // regional variants (e.g. UK vs US English, Belgian vs Canadian
1900    // French, Latin vs Cyrillic Serbo-Croatian). A `(language, 0)` pair
1901    // is "ignore dialect": resolved as the first listed dialect when
1902    // present, otherwise `None`.
1903    match (language, dialect) {
1904        (0, _) => Some("None"),
1905        (1, 1) => Some("Arabic"),
1906        (2, 1) => Some("Bulgarian"),
1907        (3, 1) => Some("Catalan"),
1908        (4, 1) => Some("Traditional Chinese"),
1909        (4, 2) => Some("Simplified Chinese"),
1910        (5, 1) => Some("Czech"),
1911        (6, 1) => Some("Danish"),
1912        (7, 1) => Some("German"),
1913        (7, 2) => Some("Swiss German"),
1914        (8, 1) => Some("Greek"),
1915        (9, 1) => Some("US English"),
1916        (9, 2) => Some("UK English"),
1917        (10, 1) => Some("Spanish"),
1918        (10, 2) => Some("Spanish Mexican"),
1919        (11, 1) => Some("Finnish"),
1920        (12, 1) => Some("French"),
1921        (12, 2) => Some("Belgian French"),
1922        (12, 3) => Some("Canadian French"),
1923        (12, 4) => Some("Swiss French"),
1924        (13, 1) => Some("Hebrew"),
1925        (14, 1) => Some("Hungarian"),
1926        (15, 1) => Some("Icelandic"),
1927        (16, 1) => Some("Italian"),
1928        (16, 2) => Some("Swiss Italian"),
1929        (17, 1) => Some("Japanese"),
1930        (18, 1) => Some("Korean"),
1931        (19, 1) => Some("Dutch"),
1932        (19, 2) => Some("Belgian Dutch"),
1933        (20, 1) => Some("Norwegian - Bokmal"),
1934        (20, 2) => Some("Norwegian - Nynorsk"),
1935        (21, 1) => Some("Polish"),
1936        (22, 1) => Some("Brazilian Portuguese"),
1937        (22, 2) => Some("Portuguese"),
1938        (23, 1) => Some("Rhaeto-Romanic"),
1939        (24, 1) => Some("Romanian"),
1940        (25, 1) => Some("Russian"),
1941        (26, 1) => Some("Serbo-Croatian (Latin)"),
1942        (26, 2) => Some("Serbo-Croatian (Cyrillic)"),
1943        (27, 1) => Some("Slovak"),
1944        (28, 1) => Some("Albanian"),
1945        (29, 1) => Some("Swedish"),
1946        (30, 1) => Some("Thai"),
1947        (31, 1) => Some("Turkish"),
1948        (32, 1) => Some("Urdu"),
1949        (33, 1) => Some("Bahasa"),
1950        _ => None,
1951    }
1952}
1953
1954/// Parse a `CSET` (Character Set) chunk body per
1955/// `docs/container/riff/metadata/microsoft-riffmci.pdf` §3
1956/// "CSET (Character Set) Chunk":
1957///
1958/// ```text
1959/// <CSET-chunk> -> CSET( <wCodePage:WORD>
1960///                       <wCountryCode:WORD>
1961///                       <wLanguageCode:WORD>
1962///                       <wDialect:WORD> )
1963/// ```
1964///
1965/// All four fields are 16-bit little-endian; the chunk body is therefore
1966/// exactly 8 bytes in the canonical form. The CSET chunk declares the
1967/// code page, country, language and dialect that file elements (notably
1968/// the `LIST INFO` ZSTR sub-chunks) are interpreted under. Per the spec,
1969/// each field's zero value is "ignore" / "use the default":
1970///
1971/// * `wCodePage = 0` → ISO 8859/1 (Latin-1), identical to code page 1004
1972///   without hex columns 0/1/8/9.
1973/// * `wCountryCode = 0` → USA (country code 001).
1974/// * `wLanguageCode = 0` / `wDialect = 0` → US English (language 9,
1975///   dialect 1).
1976///
1977/// The parser surfaces:
1978///
1979/// * `wav:cset.code_page` — raw `wCodePage` decimal value (0 = ISO
1980///   8859/1; any non-zero value is the 16-bit Windows / OS-2 code-page
1981///   number, e.g. 1252 for Western European, 932 for Shift-JIS, 65001
1982///   for UTF-8).
1983/// * `wav:cset.country` — raw `wCountryCode` decimal value.
1984/// * `wav:cset.country_name` — human-readable name from the §3
1985///   "Country Codes" table (only when the code is in the spec's
1986///   enumerated set).
1987/// * `wav:cset.language` — raw `wLanguageCode` decimal value.
1988/// * `wav:cset.dialect` — raw `wDialect` decimal value.
1989/// * `wav:cset.language_name` — human-readable name from the §3
1990///   "Language and Dialect Codes" table (only when the pair is in the
1991///   spec's enumerated set).
1992/// * `wav:cset.body_len` — total chunk-body length, always emitted when
1993///   the chunk is present so a writer that grew the chunk for forward
1994///   compatibility is still observable.
1995///
1996/// Bodies shorter than the 8-byte canonical struct are treated as
1997/// opaque: only `wav:cset.body_len` is emitted. Bodies longer than 8
1998/// bytes have the trailing region tolerated (forward compatibility); the
1999/// `body_len` key lets downstream tooling notice the extra payload.
2000fn parse_cset_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
2001    out.push(("wav:cset.body_len".to_string(), buf.len().to_string()));
2002    if buf.len() < 8 {
2003        return;
2004    }
2005    let code_page = u16::from_le_bytes([buf[0], buf[1]]);
2006    let country = u16::from_le_bytes([buf[2], buf[3]]);
2007    let language = u16::from_le_bytes([buf[4], buf[5]]);
2008    let dialect = u16::from_le_bytes([buf[6], buf[7]]);
2009    out.push(("wav:cset.code_page".to_string(), code_page.to_string()));
2010    out.push(("wav:cset.country".to_string(), country.to_string()));
2011    if let Some(name) = cset_country_name(country) {
2012        out.push(("wav:cset.country_name".to_string(), name.to_string()));
2013    }
2014    out.push(("wav:cset.language".to_string(), language.to_string()));
2015    out.push(("wav:cset.dialect".to_string(), dialect.to_string()));
2016    if let Some(name) = cset_language_name(language, dialect) {
2017        out.push(("wav:cset.language_name".to_string(), name.to_string()));
2018    }
2019}
2020
2021/// Map a `LIST INFO` sub-chunk FOURCC to its conventional metadata-key
2022/// name, covering the full Microsoft RIFF MCI §3 "INFO List Chunk"
2023/// baseline (per `docs/container/riff/metadata/microsoft-riffmci.pdf`
2024/// pp. 2-14 .. 2-16). The 1991 baseline registers 23 sub-IDs; this
2025/// function returns the key under which each is surfaced to the
2026/// caller through `Demuxer::metadata`.
2027///
2028/// The mapping keeps the prior short-form names for the four widely-
2029/// quoted "audio tag" sub-IDs (`INAM` → `title`, `IART` → `artist`,
2030/// `IPRD` → `album`, `ICMT` → `comment`, `ICRD` → `date`, `IGNR` →
2031/// `genre`, `ICOP` → `copyright`, `IENG` → `engineer`, `ITCH` →
2032/// `technician`, `ISFT` → `encoder`, `ISBJ` → `subject`). `IPRD`'s
2033/// "album" alias is conventional rather than spec-literal — the §3
2034/// description says "Product. Specifies the name of the title the
2035/// file was originally intended for"; the tagging community has
2036/// long re-used the field to carry album names (matching how
2037/// ExifTool surfaces it).
2038///
2039/// The remaining baseline sub-IDs surface under spec-derived
2040/// snake_case names that come straight from the §3 entries:
2041///
2042/// * `IARL` Archival Location → `archival_location`.
2043/// * `ICMS` Commissioned → `commissioned`.
2044/// * `ICRP` Cropped → `cropped`.
2045/// * `IDIM` Dimensions → `dimensions`.
2046/// * `IDPI` Dots Per Inch → `dpi`.
2047/// * `IKEY` Keywords → `keywords`.
2048/// * `ILGT` Lightness → `lightness`.
2049/// * `IMED` Medium → `medium`.
2050/// * `IPLT` Palette Setting → `palette_setting`.
2051/// * `ISHP` Sharpness → `sharpness`.
2052/// * `ISRC` Source → `source`.
2053/// * `ISRF` Source Form → `source_form`.
2054///
2055/// `ITRK` (Track Number) is not in the §3 baseline but is the
2056/// canonical non-baseline tag every WAV-handling tool surfaces; we
2057/// keep it for compatibility with the existing public-API behaviour.
2058/// Sub-IDs outside this set return `None`; their bytes are skipped
2059/// by `parse_info_list` rather than surfaced under a synthetic key.
2060fn info_id_to_key(id: &[u8; 4]) -> Option<&'static str> {
2061    match id {
2062        // RIFF MCI §3 baseline (1991), in spec order.
2063        b"IARL" => Some("archival_location"),
2064        b"IART" => Some("artist"),
2065        b"ICMS" => Some("commissioned"),
2066        b"ICMT" => Some("comment"),
2067        b"ICOP" => Some("copyright"),
2068        b"ICRD" => Some("date"),
2069        b"ICRP" => Some("cropped"),
2070        b"IDIM" => Some("dimensions"),
2071        b"IDPI" => Some("dpi"),
2072        b"IENG" => Some("engineer"),
2073        b"IGNR" => Some("genre"),
2074        b"IKEY" => Some("keywords"),
2075        b"ILGT" => Some("lightness"),
2076        b"IMED" => Some("medium"),
2077        b"INAM" => Some("title"),
2078        b"IPLT" => Some("palette_setting"),
2079        b"IPRD" => Some("album"),
2080        b"ISBJ" => Some("subject"),
2081        b"ISFT" => Some("encoder"),
2082        b"ISHP" => Some("sharpness"),
2083        b"ISRC" => Some("source"),
2084        b"ISRF" => Some("source_form"),
2085        b"ITCH" => Some("technician"),
2086        // Non-baseline but ubiquitous in tag-writer output.
2087        b"ITRK" => Some("track"),
2088        _ => None,
2089    }
2090}
2091
2092/// Fixed (pre-`CodingHistory`) size of the BWF `bext` struct, in bytes.
2093///
2094/// Sum of the field widths from EBU Tech 3285 v2 §2.3 `BROADCAST_EXT`:
2095/// `Description[256] + Originator[32] + OriginatorReference[32] +
2096/// OriginationDate[10] + OriginationTime[8] + TimeReferenceLow(4) +
2097/// TimeReferenceHigh(4) + Version(2) + UMID[64] + LoudnessValue(2) +
2098/// LoudnessRange(2) + MaxTruePeakLevel(2) + MaxMomentaryLoudness(2) +
2099/// MaxShortTermLoudness(2) + Reserved[180]` = 602.
2100const BEXT_FIXED_LEN: usize = 602;
2101
2102/// Trim a fixed-width ASCII field to its value: cut at the first NUL
2103/// (EBU Tech 3285 v2 §2.3 mandates a NUL terminator for under-length
2104/// strings) and strip surrounding whitespace.
2105fn bext_field(raw: &[u8]) -> String {
2106    let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
2107    String::from_utf8_lossy(&raw[..end]).trim().to_string()
2108}
2109
2110/// Parse a BWF `bext` (Broadcast Audio Extension) chunk body and append
2111/// its fields to `out` under `wav:bext.*` keys.
2112///
2113/// Layout per `docs/container/riff/metadata/ebu-tech3285-bwf.pdf`
2114/// (EBU Tech 3285 v2 §2.3, `BROADCAST_EXT` struct). All multi-byte
2115/// integers are little-endian (RIFF convention). The loudness fields
2116/// (`LoudnessValue` … `MaxShortTermLoudness`) are signed 16-bit values
2117/// equal to `round(100 × value)` per §2.4, so they are surfaced divided
2118/// by 100 with two decimal places.
2119///
2120/// The `Version` field selects which fields are meaningful: v0 has none
2121/// of the UMID/loudness fields populated, v1 adds the SMPTE-330M UMID,
2122/// v2 adds the five loudness values (§1.1). The fixed struct is always
2123/// 602 bytes regardless of version, so this parser reads every field
2124/// unconditionally and lets the version key tell the caller which ones
2125/// to trust. `TimeReference` is reassembled as a 64-bit sample count.
2126fn parse_bext_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
2127    if buf.len() < BEXT_FIXED_LEN {
2128        return;
2129    }
2130    let description = bext_field(&buf[0..256]);
2131    let originator = bext_field(&buf[256..288]);
2132    let originator_ref = bext_field(&buf[288..320]);
2133    let origination_date = bext_field(&buf[320..330]);
2134    let origination_time = bext_field(&buf[330..338]);
2135    let time_ref_low = u32::from_le_bytes([buf[338], buf[339], buf[340], buf[341]]);
2136    let time_ref_high = u32::from_le_bytes([buf[342], buf[343], buf[344], buf[345]]);
2137    let time_reference = ((time_ref_high as u64) << 32) | (time_ref_low as u64);
2138    let version = u16::from_le_bytes([buf[346], buf[347]]);
2139
2140    // UMID[64] at [348..412]; only meaningful for v1+. Surface it as a
2141    // hex string only when non-zero so v0 files don't carry a bogus
2142    // all-zero UMID key.
2143    let umid = &buf[348..412];
2144
2145    // Loudness WORDs at [412..422]; signed, ×100. Only meaningful for v2.
2146    let loudness_value = i16::from_le_bytes([buf[412], buf[413]]);
2147    let loudness_range = i16::from_le_bytes([buf[414], buf[415]]);
2148    let max_true_peak = i16::from_le_bytes([buf[416], buf[417]]);
2149    let max_momentary = i16::from_le_bytes([buf[418], buf[419]]);
2150    let max_short_term = i16::from_le_bytes([buf[420], buf[421]]);
2151
2152    // CodingHistory: everything past the 602-byte fixed struct.
2153    let coding_history = if buf.len() > BEXT_FIXED_LEN {
2154        bext_field(&buf[BEXT_FIXED_LEN..])
2155    } else {
2156        String::new()
2157    };
2158
2159    let push = |out: &mut Vec<(String, String)>, key: &str, value: String| {
2160        if !value.is_empty() {
2161            out.push((key.to_string(), value));
2162        }
2163    };
2164
2165    push(out, "wav:bext.description", description);
2166    push(out, "wav:bext.originator", originator);
2167    push(out, "wav:bext.originator_reference", originator_ref);
2168    push(out, "wav:bext.origination_date", origination_date);
2169    push(out, "wav:bext.origination_time", origination_time);
2170    out.push((
2171        "wav:bext.time_reference".to_string(),
2172        time_reference.to_string(),
2173    ));
2174    out.push(("wav:bext.version".to_string(), version.to_string()));
2175
2176    // v1+ : the SMPTE-330M UMID (64 bytes; a 32-byte "basic UMID"
2177    // zero-pads the trailing half per §2.3). Emit only when present.
2178    if umid.iter().any(|&b| b != 0) {
2179        let mut hex = String::with_capacity(umid.len() * 2);
2180        for b in umid {
2181            hex.push_str(&format!("{b:02x}"));
2182        }
2183        out.push(("wav:bext.umid".to_string(), hex));
2184    }
2185
2186    // v2 : loudness metadata (×100 fixed-point → two decimals). Emitted
2187    // only for v2 files since v0/v1 leave these WORDs zero by spec.
2188    if version >= 2 {
2189        out.push((
2190            "wav:bext.loudness_value".to_string(),
2191            fmt_loudness(loudness_value),
2192        ));
2193        out.push((
2194            "wav:bext.loudness_range".to_string(),
2195            fmt_loudness(loudness_range),
2196        ));
2197        out.push((
2198            "wav:bext.max_true_peak_level".to_string(),
2199            fmt_loudness(max_true_peak),
2200        ));
2201        out.push((
2202            "wav:bext.max_momentary_loudness".to_string(),
2203            fmt_loudness(max_momentary),
2204        ));
2205        out.push((
2206            "wav:bext.max_short_term_loudness".to_string(),
2207            fmt_loudness(max_short_term),
2208        ));
2209    }
2210
2211    push(out, "wav:bext.coding_history", coding_history);
2212}
2213
2214/// Render a BWF loudness WORD (signed 16-bit, `round(100 × value)` per
2215/// EBU Tech 3285 v2 §2.4) back to its two-decimal value, e.g. `-2264`
2216/// → `"-22.64"`. The sign is carried on the integer part so values in
2217/// `(-1, 0)` keep their leading minus (`-50` → `"-0.50"`).
2218fn fmt_loudness(v: i16) -> String {
2219    let neg = v < 0;
2220    let abs = (v as i32).unsigned_abs();
2221    let whole = abs / 100;
2222    let frac = abs % 100;
2223    if neg {
2224        format!("-{whole}.{frac:02}")
2225    } else {
2226        format!("{whole}.{frac:02}")
2227    }
2228}
2229
2230#[derive(Clone, Debug)]
2231struct WaveFmt {
2232    format_tag: u16,
2233    channels: u16,
2234    sample_rate: u32,
2235    #[allow(dead_code)]
2236    byte_rate: u32,
2237    block_align: u16,
2238    bits_per_sample: u16,
2239    /// `wValidBitsPerSample` from the 22-byte EXTENSIBLE extension.
2240    /// `None` for plain `WAVEFORMATEX`; `Some(0)` is a writer that left
2241    /// the union zero — the demuxer falls back to `bits_per_sample`.
2242    valid_bits_per_sample: Option<u16>,
2243    /// `dwChannelMask` — SPEAKER_* bitmap. `None` outside EXTENSIBLE.
2244    channel_mask: Option<u32>,
2245    /// 16-byte SubFormat GUID. `None` outside EXTENSIBLE.
2246    subformat: Option<[u8; 16]>,
2247}
2248
2249fn parse_fmt(buf: &[u8]) -> Result<WaveFmt> {
2250    if buf.len() < 16 {
2251        return Err(Error::invalid("fmt chunk too small"));
2252    }
2253    let format_tag = u16::from_le_bytes([buf[0], buf[1]]);
2254    let channels = u16::from_le_bytes([buf[2], buf[3]]);
2255    let sample_rate = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
2256    let byte_rate = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
2257    let block_align = u16::from_le_bytes([buf[12], buf[13]]);
2258    let bits_per_sample = u16::from_le_bytes([buf[14], buf[15]]);
2259    let mut valid_bits_per_sample = None;
2260    let mut channel_mask = None;
2261    let mut subformat = None;
2262    if format_tag == FMT_EXTENSIBLE {
2263        // WAVEFORMATEXTENSIBLE layout per
2264        // docs/container/riff/waveformatextensible/README.md
2265        // §"Structure layout":
2266        //   [16..18]  cbSize             (must be >= 22)
2267        //   [18..20]  wValidBitsPerSample (active union member)
2268        //   [20..24]  dwChannelMask
2269        //   [24..40]  SubFormat GUID
2270        if buf.len() < 40 {
2271            return Err(Error::invalid(
2272                "WAVE_FORMAT_EXTENSIBLE fmt chunk shorter than the 40 bytes \
2273                 mandated by mmreg.h",
2274            ));
2275        }
2276        let cb_size = u16::from_le_bytes([buf[16], buf[17]]);
2277        if (cb_size as usize) < 22 {
2278            return Err(Error::invalid(format!(
2279                "WAVE_FORMAT_EXTENSIBLE cbSize must be >= 22, got {cb_size}"
2280            )));
2281        }
2282        valid_bits_per_sample = Some(u16::from_le_bytes([buf[18], buf[19]]));
2283        channel_mask = Some(u32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]));
2284        let mut g = [0u8; 16];
2285        g.copy_from_slice(&buf[24..40]);
2286        subformat = Some(g);
2287    }
2288    Ok(WaveFmt {
2289        format_tag,
2290        channels,
2291        sample_rate,
2292        byte_rate,
2293        block_align,
2294        bits_per_sample,
2295        valid_bits_per_sample,
2296        channel_mask,
2297        subformat,
2298    })
2299}
2300
2301/// Resolve a legacy `wFormatTag` + decode precision to a concrete PCM /
2302/// G.711 codec id, or `None` if the tag is not one of the formats this
2303/// crate maps directly. `bits` is the active precision (the container
2304/// `wBitsPerSample` on the legacy path, or the EXTENSIBLE union's
2305/// `wValidBitsPerSample` when that is non-zero).
2306///
2307/// Shared by the legacy `WAVEFORMATEX` path and the EXTENSIBLE path: per
2308/// `docs/container/riff/waveformatextensible/ms-converting-format-tags-and-subformat-guids.md`,
2309/// a SubFormat GUID built from `DEFINE_WAVEFORMATEX_GUID(x)` is exactly
2310/// equivalent to the legacy tag `x`, so both routes dispatch identically.
2311fn codec_for_tag(tag: u16, bits: u16) -> Result<Option<CodecId>> {
2312    Ok(match tag {
2313        FMT_PCM => Some(CodecId::new(pcm_int_codec(bits)?)),
2314        FMT_IEEE_FLOAT => Some(CodecId::new(pcm_float_codec(bits)?)),
2315        FMT_ALAW => Some(CodecId::new("pcm_alaw")),
2316        FMT_MULAW => Some(CodecId::new("pcm_mulaw")),
2317        _ => None,
2318    })
2319}
2320
2321fn resolve_codec(fmt: &WaveFmt) -> Result<CodecId> {
2322    if fmt.format_tag != FMT_EXTENSIBLE {
2323        return match codec_for_tag(fmt.format_tag, fmt.bits_per_sample)? {
2324            Some(id) => Ok(id),
2325            None => Err(Error::unsupported(format!(
2326                "unsupported WAV format tag 0x{:04x}",
2327                fmt.format_tag
2328            ))),
2329        };
2330    }
2331
2332    let sub = fmt
2333        .subformat
2334        .ok_or_else(|| Error::invalid("extensible WAV missing subformat"))?;
2335    // Per docs/container/riff/waveformatextensible/README.md
2336    // §"On using these specs": the actual codec precision is the
2337    // SubFormat union's wValidBitsPerSample, NOT the WAVEFORMATEX
2338    // wBitsPerSample (the container size). Fall back to the
2339    // container size only when the union is zero — some writers
2340    // leave it unset.
2341    let depth = fmt
2342        .valid_bits_per_sample
2343        .filter(|&v| v > 0)
2344        .unwrap_or(fmt.bits_per_sample);
2345    // Any SubFormat GUID built from the `KSMedia.h`
2346    // `DEFINE_WAVEFORMATEX_GUID(x)` template carries the legacy
2347    // `wFormatTag` `x` in its leading 16 bits and is, per
2348    // ms-converting-format-tags-and-subformat-guids.md, equivalent to the
2349    // legacy tag — so it dispatches through the SAME `codec_for_tag`
2350    // path the `WAVEFORMATEX` route uses. This generalises the four
2351    // hand-listed GUID constants (PCM 0x0001 / IEEE_FLOAT 0x0003 /
2352    // ALAW 0x0006 / MULAW 0x0007) to every tag-derived GUID. The
2353    // recursion guard (`tag != FMT_EXTENSIBLE`) rejects the degenerate
2354    // GUID whose embedded tag is 0xFFFE itself.
2355    if let Some(tag) = waveformatex_tag(&sub) {
2356        if tag != FMT_EXTENSIBLE {
2357            if let Some(id) = codec_for_tag(tag, depth)? {
2358                return Ok(id);
2359            }
2360        }
2361    }
2362    // Not a (mappable) WAVEFORMATEX-template GUID — synthesise a
2363    // `wav:guid_<text>` id so downstream make_decoder fails naming the
2364    // actual GUID rather than the opaque 0xFFFE tag. Mirrors the
2365    // `avi:guid_<...>` pattern in oxideav-avi.
2366    Ok(CodecId::new(format!("wav:guid_{}", fmt_guid(&sub))))
2367}
2368
2369fn pcm_int_codec(bits: u16) -> Result<&'static str> {
2370    Ok(match bits {
2371        8 => "pcm_u8",
2372        16 => "pcm_s16le",
2373        24 => "pcm_s24le",
2374        32 => "pcm_s32le",
2375        _ => {
2376            return Err(Error::unsupported(format!(
2377                "unsupported WAV integer-PCM bit depth: {bits}"
2378            )));
2379        }
2380    })
2381}
2382
2383fn pcm_float_codec(bits: u16) -> Result<&'static str> {
2384    Ok(match bits {
2385        32 => "pcm_f32le",
2386        64 => "pcm_f64le",
2387        _ => {
2388            return Err(Error::unsupported(format!(
2389                "unsupported WAV IEEE-float bit depth: {bits}"
2390            )));
2391        }
2392    })
2393}
2394
2395/// Decoded sample-format hint for a WAV codec id. The host runtime
2396/// applies the actual decode (A-law/μ-law through `oxideav-g711`); this
2397/// crate only resolves what the *output* of that decode looks like in
2398/// the standard `SampleFormat` enum so callers building pipelines know
2399/// the shape ahead of time.
2400///
2401/// For unknown `wav:guid_<...>` ids the function returns `None` — the
2402/// EXTENSIBLE GUID didn't match any of the well-known SubFormats and
2403/// we don't pretend to know how the codec is decoded.
2404fn decoded_sample_format(id: &CodecId) -> Option<SampleFormat> {
2405    // Plain PCM codec ids forward to the existing helper.
2406    if let Some(fmt) = super::pcm::sample_format_for(id) {
2407        return Some(fmt);
2408    }
2409    match id.as_str() {
2410        // G.711 expands to S16 once decoded (oxideav-g711 alaw/mulaw
2411        // output is S16, matching the round-75 audio_codec_sample_format
2412        // mapping in oxideav-avi).
2413        "pcm_alaw" | "pcm_mulaw" => Some(SampleFormat::S16),
2414        _ => None,
2415    }
2416}
2417
2418/// WAV demuxer.
2419///
2420/// Beyond the `Demuxer` trait, this type exposes round-77 accessors
2421/// for the `WAVEFORMATEXTENSIBLE` side-info — `format_tag`,
2422/// `valid_bits_per_sample`, `channel_mask`, `subformat`. Callers that
2423/// only have a `Box<dyn Demuxer>` should rely on the `wav:fmt.*`
2424/// metadata keys instead.
2425pub struct WavDemuxer {
2426    input: Box<dyn ReadSeek>,
2427    streams: Vec<StreamInfo>,
2428    data_offset: u64,
2429    data_end: u64,
2430    cursor: u64,
2431    block_align: u64,
2432    chunk_frames: u64,
2433    samples_emitted: i64,
2434    metadata: Vec<(String, String)>,
2435    duration_micros: i64,
2436    format_tag: u16,
2437    valid_bits_per_sample: Option<u16>,
2438    channel_mask: Option<u32>,
2439    subformat: Option<[u8; 16]>,
2440    acid: Option<AcidChunk>,
2441}
2442
2443impl WavDemuxer {
2444    /// On-wire `wFormatTag` from the `fmt ` chunk (one of `WAVE_FORMAT_*`).
2445    /// Preserved verbatim for round-trip purposes — the codec id
2446    /// already encodes the decoder dispatch.
2447    pub fn format_tag(&self) -> u16 {
2448        self.format_tag
2449    }
2450
2451    /// `WAVEFORMATEXTENSIBLE.Samples.wValidBitsPerSample` — actual bit
2452    /// precision per sample. `None` for legacy `WAVEFORMATEX` streams
2453    /// (non-EXTENSIBLE `wFormatTag`).
2454    pub fn valid_bits_per_sample(&self) -> Option<u16> {
2455        self.valid_bits_per_sample
2456    }
2457
2458    /// `WAVEFORMATEXTENSIBLE.dwChannelMask` — `SPEAKER_*` bitmap
2459    /// describing the channel ordering of the interleaved PCM byte
2460    /// stream. `None` for non-EXTENSIBLE streams.
2461    ///
2462    /// See `docs/container/riff/waveformatextensible/README.md`
2463    /// §"dwChannelMask bits" for the standard layouts.
2464    pub fn channel_mask(&self) -> Option<u32> {
2465        self.channel_mask
2466    }
2467
2468    /// Human-readable speaker layout decoded from
2469    /// [`Self::channel_mask`] — a `+`-separated list of `SPEAKER_*`
2470    /// positions in canonical least-significant-bit-first order
2471    /// (e.g. `"FRONT_LEFT+FRONT_RIGHT+FRONT_CENTER+LOW_FREQUENCY"`).
2472    ///
2473    /// `None` when the stream is non-EXTENSIBLE, or when the mask is `0`
2474    /// (no assigned speaker positions). The same value is mirrored under
2475    /// the `wav:fmt.channel_layout` metadata key for `dyn Demuxer`
2476    /// consumers. See
2477    /// `docs/container/riff/waveformatextensible/ms-waveformatextensible.html`.
2478    pub fn channel_layout(&self) -> Option<String> {
2479        self.channel_mask.and_then(channel_mask_layout)
2480    }
2481
2482    /// `WAVEFORMATEXTENSIBLE.SubFormat` — 16-byte GUID (the actual
2483    /// codec identifier when `format_tag == WAVE_FORMAT_EXTENSIBLE`).
2484    /// Returned in on-wire byte order (first three groups
2485    /// little-endian, trailing two groups big-endian); use
2486    /// [`Self::subformat_text`] for the canonical text representation.
2487    pub fn subformat(&self) -> Option<&[u8; 16]> {
2488        self.subformat.as_ref()
2489    }
2490
2491    /// `WAVEFORMATEXTENSIBLE.SubFormat` formatted as a canonical
2492    /// `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` GUID string.
2493    pub fn subformat_text(&self) -> Option<String> {
2494        self.subformat.as_ref().map(fmt_guid)
2495    }
2496
2497    /// Typed view of the Acidizer `acid` chunk when the file carried
2498    /// one with a well-formed 24-byte body. `None` when the chunk is
2499    /// absent or truncated. The same fields are mirrored under the
2500    /// `wav:acid.*` metadata keys for `dyn Demuxer` consumers.
2501    pub fn acid(&self) -> Option<&AcidChunk> {
2502        self.acid.as_ref()
2503    }
2504}
2505
2506impl Demuxer for WavDemuxer {
2507    fn format_name(&self) -> &str {
2508        "wav"
2509    }
2510
2511    fn streams(&self) -> &[StreamInfo] {
2512        &self.streams
2513    }
2514
2515    fn next_packet(&mut self) -> Result<Packet> {
2516        if self.cursor >= self.data_end {
2517            return Err(Error::Eof);
2518        }
2519        let remaining = self.data_end - self.cursor;
2520        let want_bytes = (self.chunk_frames * self.block_align).min(remaining);
2521        let want_bytes = (want_bytes / self.block_align) * self.block_align;
2522        if want_bytes == 0 {
2523            return Err(Error::Eof);
2524        }
2525
2526        // Ensure we're positioned correctly (if an upstream operation seeked us).
2527        self.input.seek(SeekFrom::Start(self.cursor))?;
2528        let mut buf = vec![0u8; want_bytes as usize];
2529        self.input.read_exact(&mut buf)?;
2530        self.cursor += want_bytes;
2531
2532        let stream = &self.streams[0];
2533        let frames = want_bytes / self.block_align;
2534        let pts = self.samples_emitted;
2535        self.samples_emitted += frames as i64;
2536
2537        let mut pkt = Packet::new(0, stream.time_base, buf);
2538        pkt.pts = Some(pts);
2539        pkt.dts = Some(pts);
2540        pkt.duration = Some(frames as i64);
2541        pkt.flags.keyframe = true;
2542        Ok(pkt)
2543    }
2544
2545    fn seek_to(&mut self, stream_index: u32, pts: i64) -> Result<i64> {
2546        if stream_index != 0 {
2547            return Err(Error::invalid(format!(
2548                "WAV: stream index {stream_index} out of range"
2549            )));
2550        }
2551        // PCM is keyframe-only and frame-aligned: the target pts is a
2552        // sample-index offset into the data chunk. Clamp to the valid
2553        // range and translate to a byte offset.
2554        let total_samples = (self.data_end - self.data_offset) / self.block_align;
2555        let target = (pts.max(0) as u64).min(total_samples);
2556        let new_cursor = self.data_offset + target * self.block_align;
2557
2558        self.input.seek(SeekFrom::Start(new_cursor))?;
2559        self.cursor = new_cursor;
2560        self.samples_emitted = target as i64;
2561        Ok(target as i64)
2562    }
2563
2564    fn metadata(&self) -> &[(String, String)] {
2565        &self.metadata
2566    }
2567
2568    fn duration_micros(&self) -> Option<i64> {
2569        if self.duration_micros > 0 {
2570            Some(self.duration_micros)
2571        } else {
2572            None
2573        }
2574    }
2575}
2576
2577// --- Muxer -----------------------------------------------------------------
2578
2579fn open_muxer(output: Box<dyn WriteSeek>, streams: &[StreamInfo]) -> Result<Box<dyn Muxer>> {
2580    if streams.len() != 1 {
2581        return Err(Error::unsupported("WAV supports exactly one audio stream"));
2582    }
2583    let s = &streams[0];
2584    if s.params.media_type != MediaType::Audio {
2585        return Err(Error::invalid("WAV stream must be audio"));
2586    }
2587    let channels = s
2588        .params
2589        .channels
2590        .ok_or_else(|| Error::invalid("WAV muxer: missing channels"))?;
2591    let sample_rate = s
2592        .params
2593        .sample_rate
2594        .ok_or_else(|| Error::invalid("WAV muxer: missing sample rate"))?;
2595    // Codec-id directs which `wFormatTag` flavour and on-wire shape the
2596    // muxer writes. A-law / μ-law take the dedicated tag-6/7 paths;
2597    // every other id falls back to the PCM/IEEE-FLOAT sample-format
2598    // lookup. Extensible muxing is opt-in via [`WavMuxOptions`] (see
2599    // [`open_muxer_with`] below) — the default path writes the
2600    // legacy 16-byte `WAVEFORMAT` for maximum compatibility.
2601    let shape = wire_shape_for_params(&s.params)?;
2602    Ok(Box::new(WavMuxer {
2603        output,
2604        channels,
2605        sample_rate,
2606        shape,
2607        extensible: None,
2608        acid: None,
2609        riff_size_offset: 0,
2610        data_size_offset: 0,
2611        fact_size_offset: None,
2612        data_bytes: 0,
2613        header_written: false,
2614        trailer_written: false,
2615    }))
2616}
2617
2618/// Optional muxer configuration: write a `WAVE_FORMAT_EXTENSIBLE`
2619/// (`wFormatTag = 0xFFFE`) header with the supplied `dwChannelMask`,
2620/// `wValidBitsPerSample`, and SubFormat GUID. See
2621/// `docs/container/riff/waveformatextensible/README.md` §"Channel-mask"
2622/// for the standard layouts.
2623///
2624/// When `valid_bits_per_sample` is `None` the muxer reuses the
2625/// container `wBitsPerSample` (computed from the codec's
2626/// `SampleFormat`). When `subformat` is `None` the muxer picks the
2627/// well-known `KSDATAFORMAT_SUBTYPE_*` GUID for the codec id (PCM /
2628/// IEEE_FLOAT / ALAW / MULAW).
2629#[derive(Clone, Debug, Default)]
2630pub struct WavMuxOptions {
2631    extensible: Option<ExtensibleOpts>,
2632    acid: Option<AcidChunk>,
2633}
2634
2635#[derive(Clone, Debug)]
2636struct ExtensibleOpts {
2637    channel_mask: u32,
2638    valid_bits_per_sample: Option<u16>,
2639    subformat: Option<[u8; 16]>,
2640}
2641
2642impl WavMuxOptions {
2643    /// Opt into `WAVE_FORMAT_EXTENSIBLE` muxing with the supplied
2644    /// `dwChannelMask`. The muxer derives `wValidBitsPerSample` and
2645    /// SubFormat from the codec-id automatically; use the
2646    /// finer-grained setters to override.
2647    pub fn with_extensible(mut self, channel_mask: u32) -> Self {
2648        self.extensible = Some(ExtensibleOpts {
2649            channel_mask,
2650            valid_bits_per_sample: None,
2651            subformat: None,
2652        });
2653        self
2654    }
2655
2656    /// Override `wValidBitsPerSample` for an extensible stream. Has no
2657    /// effect unless [`Self::with_extensible`] was also called.
2658    pub fn with_valid_bits_per_sample(mut self, valid_bps: u16) -> Self {
2659        if let Some(opts) = self.extensible.as_mut() {
2660            opts.valid_bits_per_sample = Some(valid_bps);
2661        }
2662        self
2663    }
2664
2665    /// Override the 16-byte SubFormat GUID. Has no effect unless
2666    /// [`Self::with_extensible`] was also called.
2667    pub fn with_subformat(mut self, guid: [u8; 16]) -> Self {
2668        if let Some(opts) = self.extensible.as_mut() {
2669            opts.subformat = Some(guid);
2670        }
2671        self
2672    }
2673
2674    /// Emit an Acidizer `acid` metadata chunk (24-byte body, layout
2675    /// per [`AcidChunk`]) ahead of the `data` chunk.
2676    pub fn with_acid(mut self, acid: AcidChunk) -> Self {
2677        self.acid = Some(acid);
2678        self
2679    }
2680}
2681
2682/// Open the WAV muxer with caller-controlled `WAVEFORMATEXTENSIBLE`
2683/// options. Identical to `open_muxer` when `opts ==
2684/// WavMuxOptions::default()`.
2685pub fn open_muxer_with(
2686    output: Box<dyn WriteSeek>,
2687    streams: &[StreamInfo],
2688    opts: WavMuxOptions,
2689) -> Result<Box<dyn Muxer>> {
2690    if streams.len() != 1 {
2691        return Err(Error::unsupported("WAV supports exactly one audio stream"));
2692    }
2693    let s = &streams[0];
2694    if s.params.media_type != MediaType::Audio {
2695        return Err(Error::invalid("WAV stream must be audio"));
2696    }
2697    let channels = s
2698        .params
2699        .channels
2700        .ok_or_else(|| Error::invalid("WAV muxer: missing channels"))?;
2701    let sample_rate = s
2702        .params
2703        .sample_rate
2704        .ok_or_else(|| Error::invalid("WAV muxer: missing sample rate"))?;
2705    let shape = wire_shape_for_params(&s.params)?;
2706    Ok(Box::new(WavMuxer {
2707        output,
2708        channels,
2709        sample_rate,
2710        shape,
2711        extensible: opts.extensible,
2712        acid: opts.acid,
2713        riff_size_offset: 0,
2714        data_size_offset: 0,
2715        fact_size_offset: None,
2716        data_bytes: 0,
2717        header_written: false,
2718        trailer_written: false,
2719    }))
2720}
2721
2722/// On-wire shape the muxer needs to know per codec — distinguishes
2723/// PCM/IEEE-float (bit-depth driven) from A-law/μ-law (fixed
2724/// 8 bits / sample).
2725#[derive(Clone, Copy, Debug)]
2726enum WireShape {
2727    /// Integer PCM (`wFormatTag = 0x0001`). bits = `wBitsPerSample`.
2728    IntPcm { bits: u16 },
2729    /// IEEE float PCM (`wFormatTag = 0x0003`). bits = 32 or 64.
2730    FloatPcm { bits: u16 },
2731    /// G.711 A-law (`wFormatTag = 0x0006`), 8 bits per sample.
2732    Alaw,
2733    /// G.711 μ-law (`wFormatTag = 0x0007`), 8 bits per sample.
2734    Mulaw,
2735}
2736
2737impl WireShape {
2738    fn bits_per_sample(self) -> u16 {
2739        match self {
2740            WireShape::IntPcm { bits } | WireShape::FloatPcm { bits } => bits,
2741            WireShape::Alaw | WireShape::Mulaw => 8,
2742        }
2743    }
2744
2745    fn format_tag(self) -> u16 {
2746        match self {
2747            WireShape::IntPcm { .. } => FMT_PCM,
2748            WireShape::FloatPcm { .. } => FMT_IEEE_FLOAT,
2749            WireShape::Alaw => FMT_ALAW,
2750            WireShape::Mulaw => FMT_MULAW,
2751        }
2752    }
2753
2754    fn well_known_guid(self) -> [u8; 16] {
2755        match self {
2756            WireShape::IntPcm { .. } => GUID_PCM,
2757            WireShape::FloatPcm { .. } => GUID_IEEE_FLOAT,
2758            WireShape::Alaw => GUID_ALAW,
2759            WireShape::Mulaw => GUID_MULAW,
2760        }
2761    }
2762}
2763
2764fn wire_shape_for_params(p: &CodecParameters) -> Result<WireShape> {
2765    match p.codec_id.as_str() {
2766        "pcm_alaw" => return Ok(WireShape::Alaw),
2767        "pcm_mulaw" => return Ok(WireShape::Mulaw),
2768        _ => {}
2769    }
2770    let fmt = p
2771        .sample_format
2772        .or_else(|| super::pcm::sample_format_for(&p.codec_id))
2773        .ok_or_else(|| Error::unsupported(format!("WAV: unknown PCM codec {}", p.codec_id)))?;
2774    Ok(match fmt {
2775        SampleFormat::U8 => WireShape::IntPcm { bits: 8 },
2776        SampleFormat::S16 => WireShape::IntPcm { bits: 16 },
2777        SampleFormat::S24 => WireShape::IntPcm { bits: 24 },
2778        SampleFormat::S32 => WireShape::IntPcm { bits: 32 },
2779        SampleFormat::F32 => WireShape::FloatPcm { bits: 32 },
2780        SampleFormat::F64 => WireShape::FloatPcm { bits: 64 },
2781        other => {
2782            return Err(Error::unsupported(format!(
2783                "WAV muxer cannot write sample format {:?}",
2784                other
2785            )));
2786        }
2787    })
2788}
2789
2790struct WavMuxer {
2791    output: Box<dyn WriteSeek>,
2792    channels: u16,
2793    sample_rate: u32,
2794    shape: WireShape,
2795    extensible: Option<ExtensibleOpts>,
2796    /// Caller-supplied Acidizer metadata, emitted as a 24-byte `acid`
2797    /// chunk between the format chunks and `data` when present.
2798    acid: Option<AcidChunk>,
2799    riff_size_offset: u64,
2800    data_size_offset: u64,
2801    /// Offset of the `dwFileSize` field inside the `fact` chunk we
2802    /// emit ahead of `data` for non-PCM streams (G.711 A-law / μ-law,
2803    /// and the EXTENSIBLE escape hatch even when the SubFormat
2804    /// resolves to PCM — RIFF MCI §3 "FACT Chunk" requires it for any
2805    /// `wFormatTag != WAVE_FORMAT_PCM`). `None` for PCM streams where
2806    /// the chunk is optional and we skip emitting it to keep PCM
2807    /// files byte-identical to the pre-r193 muxer output.
2808    fact_size_offset: Option<u64>,
2809    data_bytes: u64,
2810    header_written: bool,
2811    trailer_written: bool,
2812}
2813
2814impl Muxer for WavMuxer {
2815    fn format_name(&self) -> &str {
2816        "wav"
2817    }
2818
2819    fn write_header(&mut self) -> Result<()> {
2820        if self.header_written {
2821            return Err(Error::other("WAV header already written"));
2822        }
2823        let bits_per_sample = self.shape.bits_per_sample();
2824        let block_align = (bits_per_sample / 8) * self.channels;
2825        let byte_rate = self.sample_rate * block_align as u32;
2826
2827        // On-wire wFormatTag: caller's extensible opt-in overrides the
2828        // per-codec default (the underlying shape still drives
2829        // wBitsPerSample / block_align / byte_rate).
2830        let format_tag = if self.extensible.is_some() {
2831            FMT_EXTENSIBLE
2832        } else {
2833            self.shape.format_tag()
2834        };
2835
2836        self.output.write_all(b"RIFF")?;
2837        self.riff_size_offset = self.output.stream_position()?;
2838        self.output.write_all(&0u32.to_le_bytes())?; // placeholder
2839        self.output.write_all(b"WAVE")?;
2840
2841        // fmt chunk: 16 bytes for plain WAVEFORMAT, 40 bytes for
2842        // WAVEFORMATEXTENSIBLE (16 + 2 cbSize + 22 ext).
2843        let fmt_size: u32 = if self.extensible.is_some() { 40 } else { 16 };
2844        self.output.write_all(b"fmt ")?;
2845        self.output.write_all(&fmt_size.to_le_bytes())?;
2846        self.output.write_all(&format_tag.to_le_bytes())?;
2847        self.output.write_all(&self.channels.to_le_bytes())?;
2848        self.output.write_all(&self.sample_rate.to_le_bytes())?;
2849        self.output.write_all(&byte_rate.to_le_bytes())?;
2850        self.output.write_all(&block_align.to_le_bytes())?;
2851        self.output.write_all(&bits_per_sample.to_le_bytes())?;
2852
2853        if let Some(opts) = &self.extensible {
2854            // cbSize (22) + 22-byte extension. Layout per
2855            // docs/container/riff/waveformatextensible/README.md
2856            // §"Structure layout".
2857            self.output.write_all(&22u16.to_le_bytes())?;
2858            let valid = opts.valid_bits_per_sample.unwrap_or(bits_per_sample);
2859            self.output.write_all(&valid.to_le_bytes())?;
2860            self.output.write_all(&opts.channel_mask.to_le_bytes())?;
2861            let guid = opts
2862                .subformat
2863                .unwrap_or_else(|| self.shape.well_known_guid());
2864            self.output.write_all(&guid)?;
2865        }
2866
2867        // `fact` chunk: required for any `wFormatTag != WAVE_FORMAT_PCM`
2868        // (RIFF MCI §3 "FACT Chunk"). We emit it for the
2869        // EXTENSIBLE-tagged path too even when the SubFormat resolves to
2870        // PCM, because compliant readers dispatch on the on-wire
2871        // `wFormatTag` first. The 4-byte `dwFileSize` is patched in
2872        // `write_trailer` once we know the per-channel sample count.
2873        if format_tag != FMT_PCM {
2874            self.output.write_all(b"fact")?;
2875            self.output.write_all(&4u32.to_le_bytes())?;
2876            self.fact_size_offset = Some(self.output.stream_position()?);
2877            self.output.write_all(&0u32.to_le_bytes())?; // placeholder dwFileSize
2878        }
2879
2880        // `acid` chunk: caller-supplied Acidizer loop/tempo metadata
2881        // (24-byte fixed body — see [`AcidChunk`]). Even-sized, so no
2882        // pad byte is needed.
2883        if let Some(acid) = &self.acid {
2884            self.output.write_all(b"acid")?;
2885            self.output
2886                .write_all(&(AcidChunk::BODY_LEN as u32).to_le_bytes())?;
2887            self.output.write_all(&acid.to_bytes())?;
2888        }
2889
2890        self.output.write_all(b"data")?;
2891        self.data_size_offset = self.output.stream_position()?;
2892        self.output.write_all(&0u32.to_le_bytes())?; // placeholder
2893
2894        self.header_written = true;
2895        Ok(())
2896    }
2897
2898    fn write_packet(&mut self, packet: &Packet) -> Result<()> {
2899        if !self.header_written {
2900            return Err(Error::other("WAV muxer: write_header not called"));
2901        }
2902        self.output.write_all(&packet.data)?;
2903        self.data_bytes += packet.data.len() as u64;
2904        Ok(())
2905    }
2906
2907    fn write_trailer(&mut self) -> Result<()> {
2908        if self.trailer_written {
2909            return Ok(());
2910        }
2911        // Pad data chunk to even length.
2912        if self.data_bytes % 2 == 1 {
2913            self.output.write_all(&[0u8])?;
2914        }
2915        let end = self.output.stream_position()?;
2916
2917        // Patch "data" chunk size.
2918        let data_size_u32: u32 = self
2919            .data_bytes
2920            .try_into()
2921            .map_err(|_| Error::other("WAV data chunk exceeds 4 GiB"))?;
2922        self.output.seek(SeekFrom::Start(self.data_size_offset))?;
2923        self.output.write_all(&data_size_u32.to_le_bytes())?;
2924
2925        // Patch "fact" `dwFileSize` (per-channel sample count) when the
2926        // chunk was emitted. For PCM/G.711 the on-wire byte rate makes
2927        // `data_bytes / block_align` exact; for the EXTENSIBLE escape
2928        // hatch the muxer only ships pre-framed payload so the same
2929        // identity holds. Compressed bitstreams that ride EXTENSIBLE
2930        // would need a caller-supplied sample count — out of scope for
2931        // this muxer which only writes uncompressed shapes today.
2932        if let Some(off) = self.fact_size_offset {
2933            let bits_per_sample = self.shape.bits_per_sample() as u64;
2934            let block_align = (bits_per_sample / 8) * self.channels as u64;
2935            let sample_count = self.data_bytes.checked_div(block_align).unwrap_or(0);
2936            let sample_count_u32: u32 = sample_count
2937                .try_into()
2938                .map_err(|_| Error::other("WAV fact sample count exceeds u32"))?;
2939            self.output.seek(SeekFrom::Start(off))?;
2940            self.output.write_all(&sample_count_u32.to_le_bytes())?;
2941        }
2942
2943        // Patch "RIFF" size: total file size minus 8 (RIFF + size fields).
2944        let riff_size_u32: u32 = (end - 8)
2945            .try_into()
2946            .map_err(|_| Error::other("WAV RIFF size exceeds 4 GiB"))?;
2947        self.output.seek(SeekFrom::Start(self.riff_size_offset))?;
2948        self.output.write_all(&riff_size_u32.to_le_bytes())?;
2949
2950        self.output.seek(SeekFrom::Start(end))?;
2951        self.output.flush()?;
2952        self.trailer_written = true;
2953        Ok(())
2954    }
2955}
2956
2957#[cfg(test)]
2958mod tests {
2959    use super::*;
2960    use oxideav_core::{CodecParameters, MediaType};
2961
2962    fn make_stream(fmt: SampleFormat, ch: u16, sr: u32) -> StreamInfo {
2963        let mut params = CodecParameters::audio(super::super::pcm::codec_id_for(fmt).unwrap());
2964        params.media_type = MediaType::Audio;
2965        params.channels = Some(ch);
2966        params.sample_rate = Some(sr);
2967        params.sample_format = Some(fmt);
2968        StreamInfo {
2969            index: 0,
2970            time_base: TimeBase::new(1, sr as i64),
2971            duration: None,
2972            start_time: Some(0),
2973            params,
2974        }
2975    }
2976
2977    fn wave_format_tag(p: &CodecParameters) -> Option<u16> {
2978        match p.tag.as_ref()? {
2979            oxideav_core::CodecTag::WaveFormat(t) => Some(*t),
2980            _ => None,
2981        }
2982    }
2983
2984    fn make_g711_stream(codec: &str, ch: u16, sr: u32) -> StreamInfo {
2985        let mut params = CodecParameters::audio(CodecId::new(codec));
2986        params.media_type = MediaType::Audio;
2987        params.channels = Some(ch);
2988        params.sample_rate = Some(sr);
2989        // G.711 expands to S16 once decoded — sample_format describes
2990        // the post-decode shape, matching the round-75 oxideav-avi
2991        // convention. The on-wire packets are 8-bit codewords.
2992        params.sample_format = Some(SampleFormat::S16);
2993        StreamInfo {
2994            index: 0,
2995            time_base: TimeBase::new(1, sr as i64),
2996            duration: None,
2997            start_time: Some(0),
2998            params,
2999        }
3000    }
3001
3002    /// Mux a single-packet stream through `open_muxer_with` to a
3003    /// uniquely-named tmpfile, then return both the encoded bytes and
3004    /// an open demuxer over them. The tmpfile is removed after the
3005    /// read. Avoids `Cursor<&mut Vec<u8>>` lifetime traps —
3006    /// `Box<dyn WriteSeek>` requires `'static`.
3007    fn mux_to_bytes(
3008        stream: &StreamInfo,
3009        payload: &[u8],
3010        opts: WavMuxOptions,
3011        tag: &str,
3012    ) -> Vec<u8> {
3013        let tmp = std::env::temp_dir().join(format!("oxideav-basic-wav-r77-{tag}.wav"));
3014        let _ = std::fs::remove_file(&tmp);
3015        {
3016            let f = std::fs::File::create(&tmp).unwrap();
3017            let ws: Box<dyn WriteSeek> = Box::new(f);
3018            let mut mux = open_muxer_with(ws, std::slice::from_ref(stream), opts).unwrap();
3019            mux.write_header().unwrap();
3020            let pkt = Packet::new(0, stream.time_base, payload.to_vec());
3021            mux.write_packet(&pkt).unwrap();
3022            mux.write_trailer().unwrap();
3023        }
3024        let bytes = std::fs::read(&tmp).unwrap();
3025        let _ = std::fs::remove_file(&tmp);
3026        bytes
3027    }
3028
3029    fn open_demux_from_bytes(bytes: Vec<u8>) -> Box<dyn Demuxer> {
3030        use std::io::Cursor;
3031        let rs: Box<dyn ReadSeek> = Box::new(Cursor::new(bytes));
3032        open_demuxer(rs, &oxideav_core::NullCodecResolver).unwrap()
3033    }
3034
3035    #[test]
3036    fn round_trip_s16_mono() {
3037        // Write then read back a small S16 mono WAV via the public demuxer/muxer paths.
3038        let samples: Vec<i16> = (0..1000).map(|i| ((i * 32) - 16000) as i16).collect();
3039        let mut payload = Vec::with_capacity(samples.len() * 2);
3040        for s in &samples {
3041            payload.extend_from_slice(&s.to_le_bytes());
3042        }
3043
3044        // Mux to a temp file, then demux and compare.
3045        let stream = make_stream(SampleFormat::S16, 1, 48_000);
3046        let tmp = std::env::temp_dir().join("oxideav-basic-wav-test.wav");
3047        {
3048            let f = std::fs::File::create(&tmp).unwrap();
3049            let ws: Box<dyn WriteSeek> = Box::new(f);
3050            let mut mux = open_muxer(ws, std::slice::from_ref(&stream)).unwrap();
3051            mux.write_header().unwrap();
3052            let pkt = Packet::new(0, stream.time_base, payload.clone());
3053            mux.write_packet(&pkt).unwrap();
3054            mux.write_trailer().unwrap();
3055        }
3056        let rs: Box<dyn ReadSeek> = Box::new(std::fs::File::open(&tmp).unwrap());
3057        let mut dmx = open_demuxer(rs, &oxideav_core::NullCodecResolver).unwrap();
3058        assert_eq!(dmx.format_name(), "wav");
3059        assert_eq!(dmx.streams().len(), 1);
3060        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
3061        let mut out_bytes = Vec::new();
3062        loop {
3063            match dmx.next_packet() {
3064                Ok(p) => out_bytes.extend_from_slice(&p.data),
3065                Err(Error::Eof) => break,
3066                Err(e) => panic!("demux error: {e}"),
3067            }
3068        }
3069        assert_eq!(out_bytes, payload);
3070    }
3071
3072    /// `WAVE_FORMAT_ALAW (0x0006)` — codec_id is `pcm_alaw`, on-wire
3073    /// stays 8-bit codewords, sample_format hint is S16 (decoded shape).
3074    #[test]
3075    fn round_trip_alaw_mono() {
3076        // Synthetic A-law codewords — A-law is a byte stream, so any
3077        // distinct-byte sequence exercises the byte-for-byte plumbing.
3078        let payload: Vec<u8> = (0..=255u8).collect();
3079        let stream = make_g711_stream("pcm_alaw", 1, 8_000);
3080        let bytes = mux_to_bytes(&stream, &payload, WavMuxOptions::default(), "alaw-mono");
3081        let mut dmx = open_demux_from_bytes(bytes);
3082        assert_eq!(dmx.streams().len(), 1);
3083        let s = &dmx.streams()[0];
3084        assert_eq!(s.params.codec_id, CodecId::new("pcm_alaw"));
3085        assert_eq!(wave_format_tag(&s.params), Some(FMT_ALAW));
3086        assert_eq!(s.params.sample_format, Some(SampleFormat::S16));
3087        // bit_rate is the on-wire rate (8 bits/sample * channels * rate),
3088        // NOT the post-decode S16 rate.
3089        assert_eq!(s.params.bit_rate, Some(8 * 8_000));
3090        let mut out = Vec::new();
3091        loop {
3092            match dmx.next_packet() {
3093                Ok(p) => out.extend_from_slice(&p.data),
3094                Err(Error::Eof) => break,
3095                Err(e) => panic!("demux error: {e}"),
3096            }
3097        }
3098        assert_eq!(out, payload);
3099    }
3100
3101    /// `WAVE_FORMAT_MULAW (0x0007)` — codec_id is `pcm_mulaw`.
3102    #[test]
3103    fn round_trip_mulaw_stereo() {
3104        let payload: Vec<u8> = (0..512u32).map(|i| (i & 0xFF) as u8).collect();
3105        let stream = make_g711_stream("pcm_mulaw", 2, 8_000);
3106        let bytes = mux_to_bytes(&stream, &payload, WavMuxOptions::default(), "mulaw-stereo");
3107        let mut dmx = open_demux_from_bytes(bytes);
3108        let s = &dmx.streams()[0];
3109        assert_eq!(s.params.codec_id, CodecId::new("pcm_mulaw"));
3110        assert_eq!(wave_format_tag(&s.params), Some(FMT_MULAW));
3111        // stereo G.711 — block_align = 2 bytes (1 byte/channel),
3112        // byte_rate = 16000, bit_rate = 128 kbps.
3113        assert_eq!(s.params.bit_rate, Some(16 * 8_000));
3114        let mut out = Vec::new();
3115        loop {
3116            match dmx.next_packet() {
3117                Ok(p) => out.extend_from_slice(&p.data),
3118                Err(Error::Eof) => break,
3119                Err(e) => panic!("demux error: {e}"),
3120            }
3121        }
3122        assert_eq!(out, payload);
3123    }
3124
3125    /// `WAVE_FORMAT_EXTENSIBLE (0xFFFE)` end-to-end — muxer emits the
3126    /// 40-byte fmt chunk with cbSize = 22, demuxer parses the
3127    /// extension and exposes channel_mask / valid_bits / SubFormat via
3128    /// both metadata keys AND the typed accessors.
3129    #[test]
3130    fn round_trip_extensible_5_1_pcm() {
3131        // 6-channel 5.1-Microsoft layout (FL FR FC LFE BL BR) per
3132        // docs/container/riff/waveformatextensible/README.md
3133        // §"Channel-mask channel ordering". `payload` is one frame of
3134        // 6 distinct s16 samples so we can verify frame boundary
3135        // alignment after the round trip.
3136        const MASK_5_1: u32 = 0x0003F;
3137        let frame: [i16; 6] = [-100, 200, -300, 400, -500, 600];
3138        let mut payload = Vec::new();
3139        for _ in 0..32 {
3140            for s in &frame {
3141                payload.extend_from_slice(&s.to_le_bytes());
3142            }
3143        }
3144
3145        let mut stream = make_stream(SampleFormat::S16, 6, 48_000);
3146        // Build via open_muxer_with so the muxer emits the EXTENSIBLE
3147        // fmt chunk. open_muxer (the registry default) writes the
3148        // legacy 16-byte fmt chunk for maximum compatibility.
3149        stream.params.codec_id = CodecId::new("pcm_s16le");
3150        let opts = WavMuxOptions::default().with_extensible(MASK_5_1);
3151        let buf = mux_to_bytes(&stream, &payload, opts, "ext-5-1-pcm");
3152
3153        // Sanity-check the on-wire fmt chunk size + cbSize.
3154        // RIFF(4)+size(4)+WAVE(4)+"fmt "(4)+size(4) == 20 bytes
3155        // before the fmt body; fmt size at offset 16 must be 40.
3156        assert_eq!(&buf[12..16], b"fmt ");
3157        let fmt_size = u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
3158        assert_eq!(fmt_size, 40, "EXTENSIBLE fmt chunk must be 40 bytes");
3159        // wFormatTag at fmt body[0..2] == 0xFFFE
3160        assert_eq!(u16::from_le_bytes([buf[20], buf[21]]), FMT_EXTENSIBLE);
3161        // cbSize at fmt body[16..18] == 22
3162        assert_eq!(u16::from_le_bytes([buf[36], buf[37]]), 22);
3163
3164        // Demux it back.
3165        let dmx = open_demux_from_bytes(buf);
3166        // SubFormat == KSDATAFORMAT_SUBTYPE_PCM resolves the codec id
3167        // to the legacy pcm_s16le (the round-75 oxideav-avi convention
3168        // — the GUID identifies the codec, not the bit depth).
3169        let s = &dmx.streams()[0];
3170        assert_eq!(s.params.codec_id, CodecId::new("pcm_s16le"));
3171        assert_eq!(wave_format_tag(&s.params), Some(FMT_EXTENSIBLE));
3172
3173        // Metadata round-trip.
3174        let md: std::collections::HashMap<String, String> =
3175            dmx.metadata().iter().cloned().collect();
3176        assert_eq!(
3177            md.get("wav:fmt.channel_mask"),
3178            Some(&format!("0x{MASK_5_1:08X}"))
3179        );
3180        assert_eq!(
3181            md.get("wav:fmt.valid_bits_per_sample"),
3182            Some(&"16".to_string())
3183        );
3184        assert_eq!(
3185            md.get("wav:fmt.subformat"),
3186            Some(&"00000001-0000-0010-8000-00AA00389B71".to_string())
3187        );
3188        // 0x3F == FRONT_LEFT | FRONT_RIGHT | FRONT_CENTER |
3189        // LOW_FREQUENCY | BACK_LEFT | BACK_RIGHT (the canonical 5.1
3190        // layout), decoded LSB-first.
3191        assert_eq!(
3192            md.get("wav:fmt.channel_layout"),
3193            Some(
3194                &"FRONT_LEFT+FRONT_RIGHT+FRONT_CENTER+LOW_FREQUENCY+BACK_LEFT+BACK_RIGHT"
3195                    .to_string()
3196            )
3197        );
3198    }
3199
3200    /// Typed accessors on the concrete `WavDemuxer` carry the same
3201    /// EXTENSIBLE side-info the metadata keys do.
3202    #[test]
3203    fn extensible_accessors_match_metadata() {
3204        const MASK_STEREO: u32 = 0x00003;
3205        let payload = vec![0u8; 4 * 100]; // 100 stereo s16 frames
3206
3207        let mut stream = make_stream(SampleFormat::S16, 2, 44_100);
3208        stream.params.codec_id = CodecId::new("pcm_s16le");
3209
3210        let opts = WavMuxOptions::default().with_extensible(MASK_STEREO);
3211        let bytes = mux_to_bytes(&stream, &payload, opts, "ext-stereo-md");
3212        let dmx = open_demux_from_bytes(bytes.clone());
3213        let md: std::collections::HashMap<String, String> =
3214            dmx.metadata().iter().cloned().collect();
3215        assert_eq!(
3216            md.get("wav:fmt.channel_mask"),
3217            Some(&format!("0x{MASK_STEREO:08X}"))
3218        );
3219        assert_eq!(
3220            md.get("wav:fmt.valid_bits_per_sample"),
3221            Some(&"16".to_string())
3222        );
3223        // The metadata key and the typed accessor agree. Open the same
3224        // bytes through the concrete `WavDemuxer` to reach the typed
3225        // `channel_layout` / `channel_mask` accessors.
3226        assert_eq!(
3227            md.get("wav:fmt.channel_layout"),
3228            Some(&"FRONT_LEFT+FRONT_RIGHT".to_string())
3229        );
3230        let typed = open_wav_demuxer(Box::new(std::io::Cursor::new(bytes))).unwrap();
3231        assert_eq!(
3232            typed.channel_layout(),
3233            Some("FRONT_LEFT+FRONT_RIGHT".to_string())
3234        );
3235        assert_eq!(typed.channel_mask(), Some(MASK_STEREO));
3236    }
3237
3238    /// `channel_mask_layout` decodes the `SPEAKER_*` bitmap exactly per
3239    /// `docs/container/riff/waveformatextensible/ms-waveformatextensible.html`
3240    /// §"dwChannelMask": LSB-first, `+`-joined, with each documented bit
3241    /// mapping to its flag name (0x1..=0x20000).
3242    #[test]
3243    fn channel_mask_layout_decoding() {
3244        // Mask 0 => no assigned positions.
3245        assert_eq!(channel_mask_layout(0), None);
3246
3247        // Single bits across the whole defined range.
3248        assert_eq!(channel_mask_layout(0x1), Some("FRONT_LEFT".to_string()));
3249        assert_eq!(channel_mask_layout(0x8), Some("LOW_FREQUENCY".to_string()));
3250        assert_eq!(
3251            channel_mask_layout(0x20000),
3252            Some("TOP_BACK_RIGHT".to_string())
3253        );
3254
3255        // Mono (FRONT_CENTER only).
3256        assert_eq!(channel_mask_layout(0x4), Some("FRONT_CENTER".to_string()));
3257
3258        // Quad (FL+FR+BL+BR) — note ordering follows bit significance,
3259        // not the mask literal's textual order.
3260        assert_eq!(
3261            channel_mask_layout(0x33),
3262            Some("FRONT_LEFT+FRONT_RIGHT+BACK_LEFT+BACK_RIGHT".to_string())
3263        );
3264
3265        // 7.1 (FL+FR+FC+LFE+BL+BR+SL+SR == 0x63F).
3266        assert_eq!(
3267            channel_mask_layout(0x63F),
3268            Some(
3269                "FRONT_LEFT+FRONT_RIGHT+FRONT_CENTER+LOW_FREQUENCY\
3270                 +BACK_LEFT+BACK_RIGHT+SIDE_LEFT+SIDE_RIGHT"
3271                    .to_string()
3272            )
3273        );
3274
3275        // Bits above the highest defined flag are surfaced verbatim so
3276        // the round-trip information isn't lost.
3277        assert_eq!(
3278            channel_mask_layout(0x40001),
3279            Some("FRONT_LEFT+UNKNOWN(0x40000)".to_string())
3280        );
3281        assert_eq!(
3282            channel_mask_layout(0x80000000),
3283            Some("UNKNOWN(0x80000000)".to_string())
3284        );
3285    }
3286
3287    /// EXTENSIBLE muxing of A-law: muxer writes `wFormatTag = 0xFFFE`
3288    /// with the KSDATAFORMAT_SUBTYPE_ALAW GUID; demuxer resolves
3289    /// codec_id to `pcm_alaw` via the GUID path.
3290    #[test]
3291    fn extensible_alaw_through_guid() {
3292        let payload: Vec<u8> = (0..=255u8).collect();
3293        let stream = make_g711_stream("pcm_alaw", 1, 8_000);
3294
3295        let opts = WavMuxOptions::default().with_extensible(0x00004); // FRONT_CENTER
3296        let bytes = mux_to_bytes(&stream, &payload, opts, "ext-alaw");
3297        let mut dmx = open_demux_from_bytes(bytes);
3298        let s = &dmx.streams()[0];
3299        // The legacy wFormatTag is preserved (0xFFFE) but the codec_id
3300        // resolves through the SubFormat GUID — the demuxer must
3301        // dispatch G.711 even though wFormatTag is the EXTENSIBLE
3302        // escape hatch.
3303        assert_eq!(s.params.codec_id, CodecId::new("pcm_alaw"));
3304        assert_eq!(wave_format_tag(&s.params), Some(FMT_EXTENSIBLE));
3305        let mut out = Vec::new();
3306        loop {
3307            match dmx.next_packet() {
3308                Ok(p) => out.extend_from_slice(&p.data),
3309                Err(Error::Eof) => break,
3310                Err(e) => panic!("demux error: {e}"),
3311            }
3312        }
3313        assert_eq!(out, payload);
3314    }
3315
3316    /// EXTENSIBLE with an unknown SubFormat GUID — the demuxer must
3317    /// synthesise a `wav:guid_<canonical>` codec id so downstream
3318    /// `make_decoder` lookups fail naming the actual GUID rather than
3319    /// the opaque `0xFFFE` tag.
3320    #[test]
3321    fn extensible_unknown_guid_synthesised_id() {
3322        // Hand-build a minimal EXTENSIBLE WAV with a bogus GUID, then
3323        // feed it to the demuxer. We bypass the muxer so we can pick
3324        // an unknown SubFormat directly.
3325        let bogus_guid: [u8; 16] = [
3326            0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC,
3327            0xDE, 0xF0,
3328        ];
3329        let mut buf = Vec::new();
3330        buf.extend_from_slice(b"RIFF");
3331        buf.extend_from_slice(&0u32.to_le_bytes()); // riff size placeholder
3332        buf.extend_from_slice(b"WAVE");
3333        buf.extend_from_slice(b"fmt ");
3334        buf.extend_from_slice(&40u32.to_le_bytes()); // fmt size
3335        buf.extend_from_slice(&FMT_EXTENSIBLE.to_le_bytes()); // wFormatTag
3336        buf.extend_from_slice(&1u16.to_le_bytes()); // channels
3337        buf.extend_from_slice(&44_100u32.to_le_bytes()); // sample_rate
3338        buf.extend_from_slice(&88_200u32.to_le_bytes()); // byte_rate
3339        buf.extend_from_slice(&2u16.to_le_bytes()); // block_align
3340        buf.extend_from_slice(&16u16.to_le_bytes()); // bits_per_sample
3341        buf.extend_from_slice(&22u16.to_le_bytes()); // cbSize
3342        buf.extend_from_slice(&16u16.to_le_bytes()); // wValidBitsPerSample
3343        buf.extend_from_slice(&0x00004u32.to_le_bytes()); // dwChannelMask (FC)
3344        buf.extend_from_slice(&bogus_guid); // SubFormat
3345        buf.extend_from_slice(b"data");
3346        buf.extend_from_slice(&0u32.to_le_bytes()); // empty data chunk
3347
3348        use std::io::Cursor;
3349        let rs: Box<dyn ReadSeek> = Box::new(Cursor::new(buf));
3350        let dmx_res = open_demuxer(rs, &oxideav_core::NullCodecResolver);
3351        let dmx = dmx_res.expect("unknown-GUID extensible stream still parses");
3352        let id = dmx.streams()[0].params.codec_id.as_str().to_string();
3353        assert!(
3354            id.starts_with("wav:guid_"),
3355            "unknown GUID must synthesise wav:guid_<text>, got {id:?}"
3356        );
3357        // Canonical text form: lowercase hex-dump in the on-wire
3358        // little-endian first-three-groups layout.
3359        assert!(
3360            id.contains("EFBEADDE-FECA-BEBA"),
3361            "synthesised id must carry the canonical GUID text, got {id:?}"
3362        );
3363    }
3364
3365    /// Hand-build a minimal EXTENSIBLE WAV carrying `guid` as its
3366    /// SubFormat, `bits` as both `wBitsPerSample` and the union's
3367    /// `wValidBitsPerSample`, and an empty `data` chunk. Bypasses the
3368    /// muxer so the SubFormat GUID can be chosen directly.
3369    fn extensible_wav_with_guid(guid: &[u8; 16], bits: u16) -> Vec<u8> {
3370        let block_align = bits / 8;
3371        let mut buf = Vec::new();
3372        buf.extend_from_slice(b"RIFF");
3373        buf.extend_from_slice(&0u32.to_le_bytes());
3374        buf.extend_from_slice(b"WAVE");
3375        buf.extend_from_slice(b"fmt ");
3376        buf.extend_from_slice(&40u32.to_le_bytes());
3377        buf.extend_from_slice(&FMT_EXTENSIBLE.to_le_bytes()); // wFormatTag
3378        buf.extend_from_slice(&1u16.to_le_bytes()); // channels
3379        buf.extend_from_slice(&44_100u32.to_le_bytes()); // sample_rate
3380        buf.extend_from_slice(&(44_100 * block_align as u32).to_le_bytes()); // byte_rate
3381        buf.extend_from_slice(&block_align.to_le_bytes()); // block_align
3382        buf.extend_from_slice(&bits.to_le_bytes()); // bits_per_sample
3383        buf.extend_from_slice(&22u16.to_le_bytes()); // cbSize
3384        buf.extend_from_slice(&bits.to_le_bytes()); // wValidBitsPerSample
3385        buf.extend_from_slice(&0x00004u32.to_le_bytes()); // dwChannelMask (FC)
3386        buf.extend_from_slice(guid); // SubFormat
3387        buf.extend_from_slice(b"data");
3388        buf.extend_from_slice(&0u32.to_le_bytes());
3389        buf
3390    }
3391
3392    /// `waveformatex_tag` extracts the embedded legacy `wFormatTag` from
3393    /// any GUID built by the `KSMedia.h` `DEFINE_WAVEFORMATEX_GUID(x)`
3394    /// template, and returns `None` for a GUID with a non-matching tail.
3395    /// Per
3396    /// docs/container/riff/waveformatextensible/ms-converting-format-tags-and-subformat-guids.md.
3397    #[test]
3398    fn waveformatex_tag_extracts_embedded_format_tag() {
3399        assert_eq!(waveformatex_tag(&GUID_PCM), Some(0x0001));
3400        assert_eq!(waveformatex_tag(&GUID_IEEE_FLOAT), Some(0x0003));
3401        assert_eq!(waveformatex_tag(&GUID_ALAW), Some(0x0006));
3402        assert_eq!(waveformatex_tag(&GUID_MULAW), Some(0x0007));
3403        // A tag never hand-listed as a constant — MP3 (0x0055) — still
3404        // resolves through the generic template.
3405        let mut mp3_guid = GUID_PCM;
3406        mp3_guid[0] = 0x55;
3407        mp3_guid[1] = 0x00;
3408        assert_eq!(waveformatex_tag(&mp3_guid), Some(0x0055));
3409        // A GUID whose tail does not match the WAVEFORMATEX base is not
3410        // a template GUID at all.
3411        let bogus: [u8; 16] = [
3412            0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38,
3413            0x9B, 0x72, // last byte differs from the 0x71 base
3414        ];
3415        assert_eq!(waveformatex_tag(&bogus), None);
3416    }
3417
3418    /// An EXTENSIBLE stream whose SubFormat is the IEEE-float template
3419    /// GUID dispatches identically to the legacy `WAVE_FORMAT_IEEE_FLOAT`
3420    /// path — i.e. through the shared `codec_for_tag` route, not the four
3421    /// hand-listed constants. Surfaces `wav:fmt.subformat_tag = 0x0003`.
3422    #[test]
3423    fn extensible_template_guid_dispatches_through_legacy_tag() {
3424        let buf = extensible_wav_with_guid(&GUID_IEEE_FLOAT, 32);
3425        use std::io::Cursor;
3426        let rs: Box<dyn ReadSeek> = Box::new(Cursor::new(buf));
3427        let dmx = open_demuxer(rs, &oxideav_core::NullCodecResolver)
3428            .expect("IEEE-float template GUID resolves");
3429        let s = &dmx.streams()[0];
3430        assert_eq!(s.params.codec_id.as_str(), "pcm_f32le");
3431        let md: std::collections::HashMap<_, _> = dmx.metadata().iter().cloned().collect();
3432        assert_eq!(
3433            md.get("wav:fmt.subformat_tag").map(String::as_str),
3434            Some("0x0003")
3435        );
3436    }
3437
3438    /// A WAVEFORMATEX-template GUID carrying a legacy `wFormatTag` this
3439    /// crate does not map directly (e.g. MP3, 0x0055) is NOT mistaken for
3440    /// PCM: it falls through to a synthesised `wav:guid_<text>` id, while
3441    /// still surfacing `wav:fmt.subformat_tag = 0x0055` so a downstream
3442    /// tool can identify it.
3443    #[test]
3444    fn extensible_template_guid_unmapped_tag_surfaces_tag() {
3445        let mut mp3_guid = GUID_PCM;
3446        mp3_guid[0] = 0x55; // 0x0055 == MP3
3447        let buf = extensible_wav_with_guid(&mp3_guid, 16);
3448        use std::io::Cursor;
3449        let rs: Box<dyn ReadSeek> = Box::new(Cursor::new(buf));
3450        let dmx = open_demuxer(rs, &oxideav_core::NullCodecResolver)
3451            .expect("MP3 template GUID still parses");
3452        let s = &dmx.streams()[0];
3453        assert!(
3454            s.params.codec_id.as_str().starts_with("wav:guid_"),
3455            "unmapped tag must synthesise wav:guid_<text>, got {:?}",
3456            s.params.codec_id.as_str()
3457        );
3458        let md: std::collections::HashMap<_, _> = dmx.metadata().iter().cloned().collect();
3459        assert_eq!(
3460            md.get("wav:fmt.subformat_tag").map(String::as_str),
3461            Some("0x0055")
3462        );
3463    }
3464
3465    /// `WAVE_FORMAT_EXTENSIBLE` with cbSize < 22 must reject — the spec
3466    /// mandates a 22-byte extension. Per
3467    /// docs/container/riff/waveformatextensible/README.md §"Structure
3468    /// layout": "RIFF `fmt` chunks carrying it are 40 bytes (38 bytes
3469    /// of struct payload + 2-byte `cbSize = 22`)."
3470    #[test]
3471    fn extensible_short_cbsize_rejected() {
3472        let mut buf = Vec::new();
3473        buf.extend_from_slice(b"RIFF");
3474        buf.extend_from_slice(&0u32.to_le_bytes());
3475        buf.extend_from_slice(b"WAVE");
3476        buf.extend_from_slice(b"fmt ");
3477        // 40-byte fmt body but cbSize = 10 (insufficient).
3478        buf.extend_from_slice(&40u32.to_le_bytes());
3479        buf.extend_from_slice(&FMT_EXTENSIBLE.to_le_bytes());
3480        buf.extend_from_slice(&1u16.to_le_bytes());
3481        buf.extend_from_slice(&44_100u32.to_le_bytes());
3482        buf.extend_from_slice(&88_200u32.to_le_bytes());
3483        buf.extend_from_slice(&2u16.to_le_bytes());
3484        buf.extend_from_slice(&16u16.to_le_bytes());
3485        buf.extend_from_slice(&10u16.to_le_bytes()); // cbSize too small
3486        buf.extend_from_slice(&[0u8; 20]); // padding to reach 40 fmt body bytes
3487        buf.extend_from_slice(b"data");
3488        buf.extend_from_slice(&0u32.to_le_bytes());
3489
3490        use std::io::Cursor;
3491        let rs: Box<dyn ReadSeek> = Box::new(Cursor::new(buf));
3492        let err = open_demuxer(rs, &oxideav_core::NullCodecResolver).err();
3493        assert!(
3494            matches!(err, Some(Error::InvalidData(_))),
3495            "short cbSize must yield Error::InvalidData, got {err:?}"
3496        );
3497    }
3498
3499    /// Build a minimal valid PCM WAV with a caller-supplied raw `bext`
3500    /// chunk body inserted between `fmt ` and `data`. Returns the file
3501    /// bytes ready for `open_demux_from_bytes`. RIFF size is left at 0
3502    /// (the demuxer doesn't validate it).
3503    fn wav_with_bext(bext_body: &[u8]) -> Vec<u8> {
3504        let mut buf = Vec::new();
3505        buf.extend_from_slice(b"RIFF");
3506        buf.extend_from_slice(&0u32.to_le_bytes());
3507        buf.extend_from_slice(b"WAVE");
3508        // fmt : 16-byte PCM s16 mono 8000 Hz.
3509        buf.extend_from_slice(b"fmt ");
3510        buf.extend_from_slice(&16u32.to_le_bytes());
3511        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
3512        buf.extend_from_slice(&1u16.to_le_bytes()); // channels
3513        buf.extend_from_slice(&8_000u32.to_le_bytes()); // sample_rate
3514        buf.extend_from_slice(&16_000u32.to_le_bytes()); // byte_rate
3515        buf.extend_from_slice(&2u16.to_le_bytes()); // block_align
3516        buf.extend_from_slice(&16u16.to_le_bytes()); // bits_per_sample
3517                                                     // bext chunk.
3518        buf.extend_from_slice(b"bext");
3519        buf.extend_from_slice(&(bext_body.len() as u32).to_le_bytes());
3520        buf.extend_from_slice(bext_body);
3521        if bext_body.len() % 2 == 1 {
3522            buf.push(0); // RIFF word-alignment pad
3523        }
3524        // empty data chunk.
3525        buf.extend_from_slice(b"data");
3526        buf.extend_from_slice(&0u32.to_le_bytes());
3527        buf
3528    }
3529
3530    /// Assemble a 602-byte BWF v2 `bext` fixed struct plus a coding
3531    /// history string. Field offsets follow EBU Tech 3285 v2 §2.3.
3532    #[allow(clippy::too_many_arguments)]
3533    fn make_bext_v2(
3534        description: &str,
3535        originator: &str,
3536        originator_ref: &str,
3537        date: &str,
3538        time: &str,
3539        time_reference: u64,
3540        umid: &[u8; 64],
3541        loudness: [i16; 5],
3542        coding_history: &str,
3543    ) -> Vec<u8> {
3544        fn put_ascii(buf: &mut Vec<u8>, s: &str, width: usize) {
3545            let bytes = s.as_bytes();
3546            let n = bytes.len().min(width);
3547            buf.extend_from_slice(&bytes[..n]);
3548            buf.resize(buf.len() + (width - n), 0);
3549        }
3550        let mut b = Vec::new();
3551        put_ascii(&mut b, description, 256);
3552        put_ascii(&mut b, originator, 32);
3553        put_ascii(&mut b, originator_ref, 32);
3554        put_ascii(&mut b, date, 10);
3555        put_ascii(&mut b, time, 8);
3556        b.extend_from_slice(&(time_reference as u32).to_le_bytes()); // low
3557        b.extend_from_slice(&((time_reference >> 32) as u32).to_le_bytes()); // high
3558        b.extend_from_slice(&2u16.to_le_bytes()); // Version = 2
3559        b.extend_from_slice(umid);
3560        for v in &loudness {
3561            b.extend_from_slice(&v.to_le_bytes());
3562        }
3563        b.resize(BEXT_FIXED_LEN, 0); // Reserved[180] zero-fill to 602
3564        assert_eq!(b.len(), BEXT_FIXED_LEN);
3565        b.extend_from_slice(coding_history.as_bytes());
3566        b
3567    }
3568
3569    /// `fmt_loudness` mirrors the EBU Tech 3285 v2 §2.4 round-to-nearest
3570    /// fixed-point: the stored WORD is `round(100 × value)`, so dividing
3571    /// by 100 with two decimals recovers the displayed value. The §2.4
3572    /// negative-number examples (`-22.64` / `-22.65` / `-22.66` stored
3573    /// as `-2264` / `-2265` / `-2265`) are exercised in reverse here.
3574    #[test]
3575    fn bext_loudness_formatting() {
3576        assert_eq!(fmt_loudness(-2264), "-22.64");
3577        assert_eq!(fmt_loudness(-2265), "-22.65");
3578        assert_eq!(fmt_loudness(0), "0.00");
3579        assert_eq!(fmt_loudness(2300), "23.00");
3580        assert_eq!(fmt_loudness(-50), "-0.50"); // sub-unity negative keeps the sign
3581        assert_eq!(fmt_loudness(7), "0.07");
3582        assert_eq!(fmt_loudness(-1), "-0.01");
3583    }
3584
3585    /// Full BWF v2 `bext` round-trip: every field surfaces under its
3586    /// `wav:bext.*` metadata key, loudness fields decode to two
3587    /// decimals, the 64-bit TimeReference reassembles, and the UMID is
3588    /// hex-encoded.
3589    #[test]
3590    fn bext_v2_full_metadata() {
3591        let mut umid = [0u8; 64];
3592        umid[0] = 0x06;
3593        umid[1] = 0x0a;
3594        umid[63] = 0xff;
3595        let body = make_bext_v2(
3596            "Scene 1 take 3",
3597            "OxideAV Recorder",
3598            "USABC2400001",
3599            "2026-05-23",
3600            "14:30:00",
3601            // 48000 samples/s × 90061 s ≈ a value that spans 32 bits.
3602            0x0000_0001_2345_6789,
3603            &umid,
3604            [-2305, 700, -120, -1850, -2010],
3605            "A=PCM,F=48000,W=24,M=stereo,T=OxideAV\r\n",
3606        );
3607        let bytes = wav_with_bext(&body);
3608        let dmx = open_demux_from_bytes(bytes);
3609        let md: std::collections::HashMap<String, String> =
3610            dmx.metadata().iter().cloned().collect();
3611
3612        assert_eq!(
3613            md.get("wav:bext.description"),
3614            Some(&"Scene 1 take 3".to_string())
3615        );
3616        assert_eq!(
3617            md.get("wav:bext.originator"),
3618            Some(&"OxideAV Recorder".to_string())
3619        );
3620        assert_eq!(
3621            md.get("wav:bext.originator_reference"),
3622            Some(&"USABC2400001".to_string())
3623        );
3624        assert_eq!(
3625            md.get("wav:bext.origination_date"),
3626            Some(&"2026-05-23".to_string())
3627        );
3628        assert_eq!(
3629            md.get("wav:bext.origination_time"),
3630            Some(&"14:30:00".to_string())
3631        );
3632        assert_eq!(
3633            md.get("wav:bext.time_reference"),
3634            Some(&0x0000_0001_2345_6789u64.to_string())
3635        );
3636        assert_eq!(md.get("wav:bext.version"), Some(&"2".to_string()));
3637        assert_eq!(
3638            md.get("wav:bext.loudness_value"),
3639            Some(&"-23.05".to_string())
3640        );
3641        assert_eq!(md.get("wav:bext.loudness_range"), Some(&"7.00".to_string()));
3642        assert_eq!(
3643            md.get("wav:bext.max_true_peak_level"),
3644            Some(&"-1.20".to_string())
3645        );
3646        assert_eq!(
3647            md.get("wav:bext.max_momentary_loudness"),
3648            Some(&"-18.50".to_string())
3649        );
3650        assert_eq!(
3651            md.get("wav:bext.max_short_term_loudness"),
3652            Some(&"-20.10".to_string())
3653        );
3654        // UMID hex begins with the bytes we set and ends with 0xff.
3655        let umid_hex = md.get("wav:bext.umid").expect("umid present");
3656        assert!(umid_hex.starts_with("060a"), "umid hex {umid_hex:?}");
3657        assert!(umid_hex.ends_with("ff"), "umid hex {umid_hex:?}");
3658        assert_eq!(umid_hex.len(), 128); // 64 bytes × 2 hex chars
3659        assert_eq!(
3660            md.get("wav:bext.coding_history"),
3661            Some(&"A=PCM,F=48000,W=24,M=stereo,T=OxideAV".to_string())
3662        );
3663    }
3664
3665    /// BWF v0 `bext`: no UMID, no loudness fields. Version = 0 so the
3666    /// loudness keys must be absent and the (all-zero) UMID suppressed,
3667    /// while the text + TimeReference fields still surface.
3668    #[test]
3669    fn bext_v0_omits_umid_and_loudness() {
3670        // 602-byte fixed struct, version 0, all UMID/loudness zero.
3671        let mut body = vec![0u8; BEXT_FIXED_LEN];
3672        // Description "Field recording".
3673        let desc = b"Field recording";
3674        body[..desc.len()].copy_from_slice(desc);
3675        // TimeReferenceLow = 12345 (offset 338).
3676        body[338..342].copy_from_slice(&12_345u32.to_le_bytes());
3677        // Version = 0 (offset 346) — already zero.
3678        let bytes = wav_with_bext(&body);
3679        let dmx = open_demux_from_bytes(bytes);
3680        let md: std::collections::HashMap<String, String> =
3681            dmx.metadata().iter().cloned().collect();
3682
3683        assert_eq!(
3684            md.get("wav:bext.description"),
3685            Some(&"Field recording".to_string())
3686        );
3687        assert_eq!(md.get("wav:bext.version"), Some(&"0".to_string()));
3688        assert_eq!(
3689            md.get("wav:bext.time_reference"),
3690            Some(&"12345".to_string())
3691        );
3692        // v0 must not emit loudness or UMID.
3693        assert!(!md.contains_key("wav:bext.umid"));
3694        assert!(!md.contains_key("wav:bext.loudness_value"));
3695        assert!(!md.contains_key("wav:bext.max_true_peak_level"));
3696    }
3697
3698    /// A `bext` chunk shorter than the 602-byte fixed struct is
3699    /// malformed; the parser must skip it without panicking and the
3700    /// stream must still open (the chunk is treated as opaque).
3701    #[test]
3702    fn bext_truncated_is_skipped() {
3703        let body = vec![0u8; 100]; // < 602
3704        let bytes = wav_with_bext(&body);
3705        let dmx = open_demux_from_bytes(bytes);
3706        let md: std::collections::HashMap<String, String> =
3707            dmx.metadata().iter().cloned().collect();
3708        assert!(md.keys().all(|k| !k.starts_with("wav:bext.")));
3709        // Stream still resolves to PCM s16.
3710        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
3711    }
3712
3713    /// Build a minimal valid PCM WAV with a caller-supplied raw `cue `
3714    /// chunk and optional `LIST adtl` body inserted between `fmt ` and
3715    /// `data`. Returns the file bytes ready for `open_demux_from_bytes`.
3716    fn wav_with_cue_and_adtl(cue_body: &[u8], adtl_body: Option<&[u8]>) -> Vec<u8> {
3717        let mut buf = Vec::new();
3718        buf.extend_from_slice(b"RIFF");
3719        buf.extend_from_slice(&0u32.to_le_bytes());
3720        buf.extend_from_slice(b"WAVE");
3721        // fmt : 16-byte PCM s16 mono 8000 Hz.
3722        buf.extend_from_slice(b"fmt ");
3723        buf.extend_from_slice(&16u32.to_le_bytes());
3724        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
3725        buf.extend_from_slice(&1u16.to_le_bytes());
3726        buf.extend_from_slice(&8_000u32.to_le_bytes());
3727        buf.extend_from_slice(&16_000u32.to_le_bytes());
3728        buf.extend_from_slice(&2u16.to_le_bytes());
3729        buf.extend_from_slice(&16u16.to_le_bytes());
3730        // cue chunk
3731        buf.extend_from_slice(b"cue ");
3732        buf.extend_from_slice(&(cue_body.len() as u32).to_le_bytes());
3733        buf.extend_from_slice(cue_body);
3734        if cue_body.len() % 2 == 1 {
3735            buf.push(0);
3736        }
3737        // optional LIST adtl chunk
3738        if let Some(adtl) = adtl_body {
3739            // LIST chunk wraps a 4-byte form-type 'adtl' + sub-chunks.
3740            buf.extend_from_slice(b"LIST");
3741            buf.extend_from_slice(&((adtl.len() + 4) as u32).to_le_bytes());
3742            buf.extend_from_slice(b"adtl");
3743            buf.extend_from_slice(adtl);
3744            if (adtl.len() + 4) % 2 == 1 {
3745                buf.push(0);
3746            }
3747        }
3748        // empty data chunk
3749        buf.extend_from_slice(b"data");
3750        buf.extend_from_slice(&0u32.to_le_bytes());
3751        buf
3752    }
3753
3754    /// Build a single 24-byte `<cue-point>` record per
3755    /// `docs/container/riff/metadata/microsoft-riffmci.pdf` §3.
3756    fn cue_point(
3757        dw_name: u32,
3758        dw_position: u32,
3759        fcc_chunk: &[u8; 4],
3760        dw_chunk_start: u32,
3761        dw_block_start: u32,
3762        dw_sample_offset: u32,
3763    ) -> Vec<u8> {
3764        let mut b = Vec::with_capacity(24);
3765        b.extend_from_slice(&dw_name.to_le_bytes());
3766        b.extend_from_slice(&dw_position.to_le_bytes());
3767        b.extend_from_slice(fcc_chunk);
3768        b.extend_from_slice(&dw_chunk_start.to_le_bytes());
3769        b.extend_from_slice(&dw_block_start.to_le_bytes());
3770        b.extend_from_slice(&dw_sample_offset.to_le_bytes());
3771        b
3772    }
3773
3774    /// Build a `labl` or `note` sub-chunk body (without the chunk
3775    /// header) per RIFF MCI §3 "Label and Note Information".
3776    fn adtl_text_subchunk(id: &[u8; 4], dw_name: u32, text: &str) -> Vec<u8> {
3777        let mut b = Vec::new();
3778        b.extend_from_slice(id);
3779        // Body = 4-byte dwName + ZSTR (text + NUL terminator).
3780        let body_len = 4 + text.len() + 1;
3781        b.extend_from_slice(&(body_len as u32).to_le_bytes());
3782        b.extend_from_slice(&dw_name.to_le_bytes());
3783        b.extend_from_slice(text.as_bytes());
3784        b.push(0);
3785        if body_len % 2 == 1 {
3786            b.push(0);
3787        }
3788        b
3789    }
3790
3791    /// Full `cue ` + `LIST adtl` round-trip: two cue points, each with
3792    /// a `labl` and a `note`, surface under the documented metadata
3793    /// keys.
3794    #[test]
3795    fn cue_and_adtl_full_metadata() {
3796        // Two cue points (id=1 at sample 0, id=2 at sample 12345).
3797        let mut cue_body = Vec::new();
3798        cue_body.extend_from_slice(&2u32.to_le_bytes()); // dwCuePoints
3799        cue_body.extend(cue_point(1, 0, b"data", 0, 0, 0));
3800        cue_body.extend(cue_point(2, 12_345, b"data", 0, 0, 12_345));
3801
3802        // LIST adtl with labl + note for each cue point.
3803        let mut adtl = Vec::new();
3804        adtl.extend(adtl_text_subchunk(b"labl", 1, "Intro"));
3805        adtl.extend(adtl_text_subchunk(b"note", 1, "Fade-in"));
3806        adtl.extend(adtl_text_subchunk(b"labl", 2, "Verse"));
3807        adtl.extend(adtl_text_subchunk(b"note", 2, "Vocal entry"));
3808
3809        let bytes = wav_with_cue_and_adtl(&cue_body, Some(&adtl));
3810        let dmx = open_demux_from_bytes(bytes);
3811        let md: std::collections::HashMap<String, String> =
3812            dmx.metadata().iter().cloned().collect();
3813
3814        assert_eq!(md.get("wav:cue.count"), Some(&"2".to_string()));
3815        assert_eq!(md.get("wav:cue.1.position"), Some(&"0".to_string()));
3816        assert_eq!(md.get("wav:cue.1.fcc_chunk"), Some(&"data".to_string()));
3817        assert_eq!(md.get("wav:cue.1.chunk_start"), Some(&"0".to_string()));
3818        assert_eq!(md.get("wav:cue.1.block_start"), Some(&"0".to_string()));
3819        assert_eq!(md.get("wav:cue.1.sample_offset"), Some(&"0".to_string()));
3820        assert_eq!(md.get("wav:cue.2.position"), Some(&"12345".to_string()));
3821        assert_eq!(
3822            md.get("wav:cue.2.sample_offset"),
3823            Some(&"12345".to_string())
3824        );
3825
3826        assert_eq!(md.get("wav:adtl.labl.1"), Some(&"Intro".to_string()));
3827        assert_eq!(md.get("wav:adtl.note.1"), Some(&"Fade-in".to_string()));
3828        assert_eq!(md.get("wav:adtl.labl.2"), Some(&"Verse".to_string()));
3829        assert_eq!(md.get("wav:adtl.note.2"), Some(&"Vocal entry".to_string()));
3830    }
3831
3832    /// `ltxt` sub-chunk surfaces dwSampleLength, FOURCC purpose, and
3833    /// text under `wav:adtl.ltxt.<dwName>.*`.
3834    #[test]
3835    fn adtl_ltxt_segment_metadata() {
3836        // Single cue point so the ltxt reference is meaningful.
3837        let mut cue_body = Vec::new();
3838        cue_body.extend_from_slice(&1u32.to_le_bytes());
3839        cue_body.extend(cue_point(7, 1000, b"data", 0, 0, 1000));
3840
3841        // ltxt chunk for cue 7: 4410-sample segment, 'scrp' (script) text.
3842        let mut ltxt_body = Vec::new();
3843        ltxt_body.extend_from_slice(&7u32.to_le_bytes()); // dwName
3844        ltxt_body.extend_from_slice(&4410u32.to_le_bytes()); // dwSampleLength
3845        ltxt_body.extend_from_slice(b"scrp"); // dwPurpose
3846        ltxt_body.extend_from_slice(&44u16.to_le_bytes()); // wCountry (United Kingdom)
3847        ltxt_body.extend_from_slice(&9u16.to_le_bytes()); // wLanguage (English)
3848        ltxt_body.extend_from_slice(&2u16.to_le_bytes()); // wDialect (UK)
3849        ltxt_body.extend_from_slice(&1252u16.to_le_bytes()); // wCodePage
3850        ltxt_body.extend_from_slice(b"Hello world");
3851        ltxt_body.push(0);
3852
3853        let mut adtl = Vec::new();
3854        adtl.extend_from_slice(b"ltxt");
3855        adtl.extend_from_slice(&(ltxt_body.len() as u32).to_le_bytes());
3856        adtl.extend_from_slice(&ltxt_body);
3857        if ltxt_body.len() % 2 == 1 {
3858            adtl.push(0);
3859        }
3860
3861        let bytes = wav_with_cue_and_adtl(&cue_body, Some(&adtl));
3862        let dmx = open_demux_from_bytes(bytes);
3863        let md: std::collections::HashMap<String, String> =
3864            dmx.metadata().iter().cloned().collect();
3865
3866        assert_eq!(md.get("wav:adtl.ltxt.7.length"), Some(&"4410".to_string()));
3867        assert_eq!(md.get("wav:adtl.ltxt.7.purpose"), Some(&"scrp".to_string()));
3868        assert_eq!(
3869            md.get("wav:adtl.ltxt.7.text"),
3870            Some(&"Hello world".to_string())
3871        );
3872        // §3 locale WORDs: raw decimals plus the Chapter-2 table
3873        // resolutions shared with CSET.
3874        assert_eq!(md.get("wav:adtl.ltxt.7.country"), Some(&"44".to_string()));
3875        assert_eq!(
3876            md.get("wav:adtl.ltxt.7.country_name"),
3877            Some(&"United Kingdom".to_string())
3878        );
3879        assert_eq!(md.get("wav:adtl.ltxt.7.language"), Some(&"9".to_string()));
3880        assert_eq!(md.get("wav:adtl.ltxt.7.dialect"), Some(&"2".to_string()));
3881        assert_eq!(
3882            md.get("wav:adtl.ltxt.7.language_name"),
3883            Some(&"UK English".to_string())
3884        );
3885        assert_eq!(
3886            md.get("wav:adtl.ltxt.7.code_page"),
3887            Some(&"1252".to_string())
3888        );
3889    }
3890
3891    /// `ltxt` locale fields left at zero still surface their raw
3892    /// decimals (zero = "use the default" per the CSET zero-value
3893    /// semantics) and resolve to the tables' explicit zero rows.
3894    #[test]
3895    fn adtl_ltxt_zero_locale_fields_surface() {
3896        let mut cue_body = Vec::new();
3897        cue_body.extend_from_slice(&1u32.to_le_bytes());
3898        cue_body.extend(cue_point(3, 0, b"data", 0, 0, 0));
3899
3900        let mut ltxt_body = Vec::new();
3901        ltxt_body.extend_from_slice(&3u32.to_le_bytes()); // dwName
3902        ltxt_body.extend_from_slice(&100u32.to_le_bytes()); // dwSampleLength
3903        ltxt_body.extend_from_slice(b"capt"); // dwPurpose
3904        ltxt_body.extend_from_slice(&[0u8; 8]); // four zero WORDs
3905
3906        let mut adtl = Vec::new();
3907        adtl.extend_from_slice(b"ltxt");
3908        adtl.extend_from_slice(&(ltxt_body.len() as u32).to_le_bytes());
3909        adtl.extend_from_slice(&ltxt_body);
3910
3911        let bytes = wav_with_cue_and_adtl(&cue_body, Some(&adtl));
3912        let dmx = open_demux_from_bytes(bytes);
3913        let md: std::collections::HashMap<String, String> =
3914            dmx.metadata().iter().cloned().collect();
3915
3916        assert_eq!(md.get("wav:adtl.ltxt.3.country"), Some(&"0".to_string()));
3917        assert_eq!(
3918            md.get("wav:adtl.ltxt.3.country_name"),
3919            Some(&"None".to_string())
3920        );
3921        assert_eq!(md.get("wav:adtl.ltxt.3.language"), Some(&"0".to_string()));
3922        assert_eq!(md.get("wav:adtl.ltxt.3.dialect"), Some(&"0".to_string()));
3923        assert_eq!(
3924            md.get("wav:adtl.ltxt.3.language_name"),
3925            Some(&"None".to_string())
3926        );
3927        assert_eq!(md.get("wav:adtl.ltxt.3.code_page"), Some(&"0".to_string()));
3928        // No text payload past the 20-byte fixed header → no text key.
3929        assert_eq!(md.get("wav:adtl.ltxt.3.text"), None);
3930    }
3931
3932    /// `file` sub-chunk (§3 "Embedded File Information") surfaces the
3933    /// media type FOURCC and the embedded payload length under
3934    /// `wav:adtl.file.<dwName>.*` without exposing the payload bytes.
3935    #[test]
3936    fn adtl_file_subchunk_metadata() {
3937        let mut cue_body = Vec::new();
3938        cue_body.extend_from_slice(&1u32.to_le_bytes());
3939        cue_body.extend(cue_point(5, 0, b"data", 0, 0, 0));
3940
3941        // file chunk for cue 5: an embedded 'RDIB' form of 11 bytes
3942        // (the spec's own example of an embeddable RIFF form type).
3943        let mut file_body = Vec::new();
3944        file_body.extend_from_slice(&5u32.to_le_bytes()); // dwName
3945        file_body.extend_from_slice(b"RDIB"); // dwMedType
3946        file_body.extend_from_slice(&[0xAAu8; 11]); // fileData
3947
3948        let mut adtl = Vec::new();
3949        adtl.extend_from_slice(b"file");
3950        adtl.extend_from_slice(&(file_body.len() as u32).to_le_bytes());
3951        adtl.extend_from_slice(&file_body);
3952        if file_body.len() % 2 == 1 {
3953            adtl.push(0);
3954        }
3955        // A second file chunk (dwName 6) with the spec-allowed zero
3956        // dwMedType and no fileData payload.
3957        let mut file2_body = Vec::new();
3958        file2_body.extend_from_slice(&6u32.to_le_bytes()); // dwName
3959        file2_body.extend_from_slice(&0u32.to_le_bytes()); // dwMedType = 0
3960        adtl.extend_from_slice(b"file");
3961        adtl.extend_from_slice(&(file2_body.len() as u32).to_le_bytes());
3962        adtl.extend_from_slice(&file2_body);
3963
3964        let bytes = wav_with_cue_and_adtl(&cue_body, Some(&adtl));
3965        let dmx = open_demux_from_bytes(bytes);
3966        let md: std::collections::HashMap<String, String> =
3967            dmx.metadata().iter().cloned().collect();
3968
3969        assert_eq!(
3970            md.get("wav:adtl.file.5.med_type"),
3971            Some(&"RDIB".to_string())
3972        );
3973        assert_eq!(md.get("wav:adtl.file.5.body_len"), Some(&"11".to_string()));
3974        // Zero med_type renders as plain "0"; empty fileData → 0 len.
3975        assert_eq!(md.get("wav:adtl.file.6.med_type"), Some(&"0".to_string()));
3976        assert_eq!(md.get("wav:adtl.file.6.body_len"), Some(&"0".to_string()));
3977    }
3978
3979    /// A `file` sub-chunk shorter than its 8-byte fixed header is
3980    /// skipped as opaque — no keys, no panic.
3981    #[test]
3982    fn adtl_file_truncated_is_skipped() {
3983        let cue_body = 0u32.to_le_bytes().to_vec();
3984        let mut adtl = Vec::new();
3985        adtl.extend_from_slice(b"file");
3986        adtl.extend_from_slice(&6u32.to_le_bytes()); // 6 < 8-byte header
3987        adtl.extend_from_slice(&[0u8; 6]);
3988        let bytes = wav_with_cue_and_adtl(&cue_body, Some(&adtl));
3989        let dmx = open_demux_from_bytes(bytes);
3990        assert!(dmx
3991            .metadata()
3992            .iter()
3993            .all(|(k, _)| !k.starts_with("wav:adtl.file.")));
3994        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
3995    }
3996
3997    /// A `cue ` chunk whose `dwCuePoints` count exceeds the body length
3998    /// must not panic — the parser surfaces only the records that
3999    /// actually fit in the body.
4000    #[test]
4001    fn cue_truncated_count_is_clamped() {
4002        // Claim 5 points, ship 1.
4003        let mut cue_body = Vec::new();
4004        cue_body.extend_from_slice(&5u32.to_le_bytes());
4005        cue_body.extend(cue_point(42, 100, b"data", 0, 0, 100));
4006        let bytes = wav_with_cue_and_adtl(&cue_body, None);
4007        let dmx = open_demux_from_bytes(bytes);
4008        let md: std::collections::HashMap<String, String> =
4009            dmx.metadata().iter().cloned().collect();
4010        assert_eq!(md.get("wav:cue.count"), Some(&"1".to_string()));
4011        assert_eq!(md.get("wav:cue.42.position"), Some(&"100".to_string()));
4012        // Stream still opens cleanly.
4013        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4014    }
4015
4016    /// An `adtl` list without a matching `cue ` chunk still emits its
4017    /// `wav:adtl.*` keys — the spec doesn't require the cue chunk to
4018    /// precede the adtl list, and downstream consumers can cross-
4019    /// reference dwName values themselves.
4020    #[test]
4021    fn adtl_without_cue_still_surfaces() {
4022        let mut adtl = Vec::new();
4023        adtl.extend(adtl_text_subchunk(b"labl", 99, "Orphan label"));
4024        // cue body with zero points exercises the count=0 path.
4025        let cue_body = 0u32.to_le_bytes().to_vec();
4026        let bytes = wav_with_cue_and_adtl(&cue_body, Some(&adtl));
4027        let dmx = open_demux_from_bytes(bytes);
4028        let md: std::collections::HashMap<String, String> =
4029            dmx.metadata().iter().cloned().collect();
4030        assert_eq!(md.get("wav:cue.count"), Some(&"0".to_string()));
4031        assert_eq!(
4032            md.get("wav:adtl.labl.99"),
4033            Some(&"Orphan label".to_string())
4034        );
4035    }
4036
4037    /// Build a minimal valid PCM WAV with caller-supplied raw `smpl`
4038    /// and/or `inst` chunks inserted between `fmt ` and `data`. Mirrors
4039    /// `wav_with_cue_and_adtl` but for the sampler/instrument chunk
4040    /// pair.
4041    fn wav_with_smpl_and_inst(smpl_body: Option<&[u8]>, inst_body: Option<&[u8]>) -> Vec<u8> {
4042        let mut buf = Vec::new();
4043        buf.extend_from_slice(b"RIFF");
4044        buf.extend_from_slice(&0u32.to_le_bytes());
4045        buf.extend_from_slice(b"WAVE");
4046        // fmt : 16-byte PCM s16 mono 8000 Hz.
4047        buf.extend_from_slice(b"fmt ");
4048        buf.extend_from_slice(&16u32.to_le_bytes());
4049        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
4050        buf.extend_from_slice(&1u16.to_le_bytes());
4051        buf.extend_from_slice(&8_000u32.to_le_bytes());
4052        buf.extend_from_slice(&16_000u32.to_le_bytes());
4053        buf.extend_from_slice(&2u16.to_le_bytes());
4054        buf.extend_from_slice(&16u16.to_le_bytes());
4055        if let Some(smpl) = smpl_body {
4056            buf.extend_from_slice(b"smpl");
4057            buf.extend_from_slice(&(smpl.len() as u32).to_le_bytes());
4058            buf.extend_from_slice(smpl);
4059            if smpl.len() % 2 == 1 {
4060                buf.push(0);
4061            }
4062        }
4063        if let Some(inst) = inst_body {
4064            buf.extend_from_slice(b"inst");
4065            buf.extend_from_slice(&(inst.len() as u32).to_le_bytes());
4066            buf.extend_from_slice(inst);
4067            if inst.len() % 2 == 1 {
4068                buf.push(0);
4069            }
4070        }
4071        // empty data chunk
4072        buf.extend_from_slice(b"data");
4073        buf.extend_from_slice(&0u32.to_le_bytes());
4074        buf
4075    }
4076
4077    /// Build a `smpl` fixed header (36 bytes) followed by N sample-loop
4078    /// records (24 bytes each).
4079    #[allow(clippy::too_many_arguments)]
4080    fn smpl_body(
4081        manufacturer: u32,
4082        product: u32,
4083        sample_period: u32,
4084        midi_unity_note: u32,
4085        midi_pitch_fraction: u32,
4086        smpte_format: u32,
4087        smpte_offset: u32,
4088        c_sample_loops_claimed: u32,
4089        cb_sampler_data: u32,
4090        loops: &[(u32, u32, u32, u32, u32, u32)],
4091    ) -> Vec<u8> {
4092        let mut b = Vec::new();
4093        for v in [
4094            manufacturer,
4095            product,
4096            sample_period,
4097            midi_unity_note,
4098            midi_pitch_fraction,
4099            smpte_format,
4100            smpte_offset,
4101            c_sample_loops_claimed,
4102            cb_sampler_data,
4103        ] {
4104            b.extend_from_slice(&v.to_le_bytes());
4105        }
4106        for &(id, ty, start, end, frac, count) in loops {
4107            for v in [id, ty, start, end, frac, count] {
4108                b.extend_from_slice(&v.to_le_bytes());
4109            }
4110        }
4111        b
4112    }
4113
4114    /// Full `smpl` round-trip: one loop, every fixed-header field and
4115    /// the per-loop record surface under the documented metadata keys.
4116    /// SMPTE offset is decoded as `HH:MM:SS:FF`.
4117    #[test]
4118    fn smpl_full_metadata() {
4119        // SMPTE offset 0x01020304 → 01:02:03:04 (HH MM SS FF).
4120        let body = smpl_body(
4121            0x1234,                   // manufacturer
4122            0xDEAD_BEEF,              // product
4123            22_675,                   // sample period (ns; ≈ 44.1 kHz)
4124            60,                       // MIDI middle-C
4125            0x8000_0000,              // MIDI pitch fraction (½ semitone)
4126            30,                       // SMPTE 30 fps
4127            0x01_02_03_04,            // SMPTE offset HH:MM:SS:FF
4128            1,                        // one sample loop
4129            0,                        // no sampler-specific data
4130            &[(7, 0, 0, 1000, 0, 0)], // cue id 7, fwd loop, 0..1000, infinite
4131        );
4132        let bytes = wav_with_smpl_and_inst(Some(&body), None);
4133        let dmx = open_demux_from_bytes(bytes);
4134        let md: std::collections::HashMap<String, String> =
4135            dmx.metadata().iter().cloned().collect();
4136        assert_eq!(md.get("wav:smpl.manufacturer"), Some(&"4660".to_string()));
4137        assert_eq!(md.get("wav:smpl.product"), Some(&"3735928559".to_string()));
4138        assert_eq!(md.get("wav:smpl.sample_period"), Some(&"22675".to_string()));
4139        assert_eq!(md.get("wav:smpl.midi_unity_note"), Some(&"60".to_string()));
4140        assert_eq!(
4141            md.get("wav:smpl.midi_pitch_fraction"),
4142            Some(&"2147483648".to_string())
4143        );
4144        assert_eq!(md.get("wav:smpl.smpte_format"), Some(&"30".to_string()));
4145        assert_eq!(
4146            md.get("wav:smpl.smpte_offset"),
4147            Some(&"01:02:03:04".to_string())
4148        );
4149        assert_eq!(md.get("wav:smpl.sampler_data_len"), Some(&"0".to_string()));
4150        assert_eq!(md.get("wav:smpl.num_sample_loops"), Some(&"1".to_string()));
4151        assert_eq!(
4152            md.get("wav:smpl.loop.0.cue_point_id"),
4153            Some(&"7".to_string())
4154        );
4155        assert_eq!(md.get("wav:smpl.loop.0.type"), Some(&"0".to_string()));
4156        assert_eq!(md.get("wav:smpl.loop.0.start"), Some(&"0".to_string()));
4157        assert_eq!(md.get("wav:smpl.loop.0.end"), Some(&"1000".to_string()));
4158        assert_eq!(md.get("wav:smpl.loop.0.fraction"), Some(&"0".to_string()));
4159        assert_eq!(md.get("wav:smpl.loop.0.play_count"), Some(&"0".to_string()));
4160        // Stream still opens cleanly.
4161        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4162    }
4163
4164    /// A `smpl` chunk whose `cSampleLoops` count exceeds the records
4165    /// that actually fit in the chunk body is clamped to the records
4166    /// the body carries — defensive vs. writers that lie about the
4167    /// count (mirrors the `cue ` chunk's clamping behaviour).
4168    #[test]
4169    fn smpl_loop_count_clamped_to_body() {
4170        // Claim 5 loops but provide only 1 — parser must surface
4171        // num_sample_loops=1.
4172        let body = smpl_body(
4173            0,
4174            0,
4175            0,
4176            60,
4177            0,
4178            0,
4179            0,
4180            /* claim */ 5,
4181            0,
4182            &[(1, 0, 0, 100, 0, 0)],
4183        );
4184        let bytes = wav_with_smpl_and_inst(Some(&body), None);
4185        let dmx = open_demux_from_bytes(bytes);
4186        let md: std::collections::HashMap<String, String> =
4187            dmx.metadata().iter().cloned().collect();
4188        assert_eq!(md.get("wav:smpl.num_sample_loops"), Some(&"1".to_string()));
4189        // Only loop.0 should be present; loop.1.. must not be emitted.
4190        assert!(md.contains_key("wav:smpl.loop.0.cue_point_id"));
4191        assert!(!md.contains_key("wav:smpl.loop.1.cue_point_id"));
4192        assert!(!md.contains_key("wav:smpl.loop.4.cue_point_id"));
4193    }
4194
4195    /// A `smpl` chunk shorter than the 36-byte fixed struct is
4196    /// malformed; the parser must skip it without panicking and the
4197    /// stream must still open.
4198    #[test]
4199    fn smpl_truncated_is_skipped() {
4200        let body = vec![0u8; 20]; // < 36
4201        let bytes = wav_with_smpl_and_inst(Some(&body), None);
4202        let dmx = open_demux_from_bytes(bytes);
4203        let md: std::collections::HashMap<String, String> =
4204            dmx.metadata().iter().cloned().collect();
4205        assert!(md.keys().all(|k| !k.starts_with("wav:smpl.")));
4206        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4207    }
4208
4209    /// Full `inst` round-trip: signed `FineTune` / `Gain` are decoded
4210    /// as i8 (so `-1` shows as `-1`, not `255`), MIDI note fields are
4211    /// unsigned.
4212    #[test]
4213    fn inst_full_metadata() {
4214        // FineTune = -3 cents (0xFD), Gain = -6 dB (0xFA).
4215        let body: Vec<u8> = vec![60, 0xFD, 0xFA, 36, 96, 1, 127];
4216        let bytes = wav_with_smpl_and_inst(None, Some(&body));
4217        let dmx = open_demux_from_bytes(bytes);
4218        let md: std::collections::HashMap<String, String> =
4219            dmx.metadata().iter().cloned().collect();
4220        assert_eq!(md.get("wav:inst.unshifted_note"), Some(&"60".to_string()));
4221        assert_eq!(md.get("wav:inst.fine_tune"), Some(&"-3".to_string()));
4222        assert_eq!(md.get("wav:inst.gain"), Some(&"-6".to_string()));
4223        assert_eq!(md.get("wav:inst.low_note"), Some(&"36".to_string()));
4224        assert_eq!(md.get("wav:inst.high_note"), Some(&"96".to_string()));
4225        assert_eq!(md.get("wav:inst.low_velocity"), Some(&"1".to_string()));
4226        assert_eq!(md.get("wav:inst.high_velocity"), Some(&"127".to_string()));
4227        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4228    }
4229
4230    /// An `inst` chunk shorter than the 7-byte fixed struct is skipped
4231    /// as opaque and the stream still resolves.
4232    #[test]
4233    fn inst_truncated_is_skipped() {
4234        let body = vec![0u8; 5]; // < 7
4235        let bytes = wav_with_smpl_and_inst(None, Some(&body));
4236        let dmx = open_demux_from_bytes(bytes);
4237        let md: std::collections::HashMap<String, String> =
4238            dmx.metadata().iter().cloned().collect();
4239        assert!(md.keys().all(|k| !k.starts_with("wav:inst.")));
4240        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4241    }
4242
4243    /// Both `smpl` and `inst` chunks present in the same file surface
4244    /// under their respective key namespaces without colliding. The
4245    /// odd-length `inst` chunk forces the 1-byte word-pad path; the
4246    /// `data` chunk that follows must still be located.
4247    #[test]
4248    fn smpl_and_inst_coexist_with_padding() {
4249        let smpl = smpl_body(0, 0, 0, 64, 0, 0, 0, 0, 0, &[]);
4250        let inst: Vec<u8> = vec![64, 0, 0, 0, 127, 1, 127]; // 7 bytes → odd
4251        let bytes = wav_with_smpl_and_inst(Some(&smpl), Some(&inst));
4252        let dmx = open_demux_from_bytes(bytes);
4253        let md: std::collections::HashMap<String, String> =
4254            dmx.metadata().iter().cloned().collect();
4255        assert_eq!(md.get("wav:smpl.midi_unity_note"), Some(&"64".to_string()));
4256        assert_eq!(md.get("wav:inst.unshifted_note"), Some(&"64".to_string()));
4257        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4258    }
4259
4260    /// Build a minimal valid PCM WAV with a caller-supplied raw `acid`
4261    /// chunk inserted between `fmt ` and `data`. Mirrors
4262    /// `wav_with_smpl_and_inst`.
4263    fn wav_with_acid(acid_body: &[u8]) -> Vec<u8> {
4264        let mut buf = Vec::new();
4265        buf.extend_from_slice(b"RIFF");
4266        buf.extend_from_slice(&0u32.to_le_bytes());
4267        buf.extend_from_slice(b"WAVE");
4268        buf.extend_from_slice(b"fmt ");
4269        buf.extend_from_slice(&16u32.to_le_bytes());
4270        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
4271        buf.extend_from_slice(&1u16.to_le_bytes());
4272        buf.extend_from_slice(&8_000u32.to_le_bytes());
4273        buf.extend_from_slice(&16_000u32.to_le_bytes());
4274        buf.extend_from_slice(&2u16.to_le_bytes());
4275        buf.extend_from_slice(&16u16.to_le_bytes());
4276        buf.extend_from_slice(b"acid");
4277        buf.extend_from_slice(&(acid_body.len() as u32).to_le_bytes());
4278        buf.extend_from_slice(acid_body);
4279        if acid_body.len() % 2 == 1 {
4280            buf.push(0);
4281        }
4282        buf.extend_from_slice(b"data");
4283        buf.extend_from_slice(&0u32.to_le_bytes());
4284        buf
4285    }
4286
4287    /// `AcidChunk::to_bytes` is pinned byte-for-byte against the
4288    /// documented little-endian layout (flags @0, root note @4,
4289    /// reserved @6, beats @12, meter @16, tempo @20) and
4290    /// `AcidChunk::parse` inverts it exactly.
4291    #[test]
4292    fn acid_chunk_byte_layout_pinned() {
4293        let acid = AcidChunk {
4294            flags: ACID_FLAG_ROOT_NOTE_SET | ACID_FLAG_STRETCH,
4295            root_note: 57, // A
4296            reserved: [0x80, 0x00, 0x01, 0x02, 0x03, 0x04],
4297            num_beats: 16,
4298            meter: 4,
4299            tempo: 120.5,
4300        };
4301        let bytes = acid.to_bytes();
4302        #[rustfmt::skip]
4303        let expected: [u8; 24] = [
4304            0x06, 0x00, 0x00, 0x00,             // flags = 0x00000006
4305            0x39, 0x00,                         // root note = 57
4306            0x80, 0x00, 0x01, 0x02, 0x03, 0x04, // reserved, verbatim
4307            0x10, 0x00, 0x00, 0x00,             // beats = 16
4308            0x04, 0x00, 0x00, 0x00,             // meter = 4
4309            0x00, 0x00, 0xF1, 0x42,             // 120.5f32 LE
4310        ];
4311        assert_eq!(bytes, expected);
4312        assert_eq!(AcidChunk::parse(&bytes), Some(acid));
4313    }
4314
4315    /// Full `acid` read path: every documented field surfaces under the
4316    /// `wav:acid.*` metadata keys, flag bits decode per the staged
4317    /// Acidizer table, and the root-note name table resolves.
4318    #[test]
4319    fn acid_full_metadata() {
4320        let acid = AcidChunk {
4321            flags: ACID_FLAG_ONE_SHOT | ACID_FLAG_ROOT_NOTE_SET | ACID_FLAG_HIGH_OCTAVE,
4322            root_note: 60, // High C
4323            reserved: [0; 6],
4324            num_beats: 32,
4325            meter: 4,
4326            tempo: 95.0,
4327        };
4328        let bytes = wav_with_acid(&acid.to_bytes());
4329        let dmx = open_demux_from_bytes(bytes);
4330        let md: std::collections::HashMap<String, String> =
4331            dmx.metadata().iter().cloned().collect();
4332        assert_eq!(md.get("wav:acid.flags"), Some(&"0x00000013".to_string()));
4333        assert_eq!(md.get("wav:acid.one_shot"), Some(&"1".to_string()));
4334        assert_eq!(md.get("wav:acid.root_note_set"), Some(&"1".to_string()));
4335        assert_eq!(md.get("wav:acid.stretch"), Some(&"0".to_string()));
4336        assert_eq!(md.get("wav:acid.disk_based"), Some(&"0".to_string()));
4337        assert_eq!(md.get("wav:acid.high_octave"), Some(&"1".to_string()));
4338        assert_eq!(md.get("wav:acid.root_note"), Some(&"60".to_string()));
4339        assert_eq!(
4340            md.get("wav:acid.root_note_name"),
4341            Some(&"High C".to_string())
4342        );
4343        assert_eq!(md.get("wav:acid.num_beats"), Some(&"32".to_string()));
4344        assert_eq!(md.get("wav:acid.meter"), Some(&"4".to_string()));
4345        assert_eq!(md.get("wav:acid.tempo"), Some(&"95".to_string()));
4346        // All-zero reserved bytes → no reserved key, exact-size body →
4347        // no body_len key.
4348        assert_eq!(md.get("wav:acid.reserved"), None);
4349        assert_eq!(md.get("wav:acid.body_len"), None);
4350        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4351    }
4352
4353    /// Out-of-table root note (not in 48..=71) surfaces the raw value
4354    /// but no name; nonzero reserved bytes and oversize bodies surface
4355    /// their observability keys.
4356    #[test]
4357    fn acid_out_of_table_root_note_and_extras() {
4358        let acid = AcidChunk {
4359            flags: 0,
4360            root_note: 0,
4361            reserved: [0xAA, 0, 0, 0, 0, 0xBB],
4362            num_beats: 8,
4363            meter: 4,
4364            tempo: 133.25,
4365        };
4366        assert_eq!(acid.root_note_name(), None);
4367        let mut body = acid.to_bytes().to_vec();
4368        body.extend_from_slice(&[0xEE, 0xFF]); // future-extension bytes
4369        let bytes = wav_with_acid(&body);
4370        let dmx = open_demux_from_bytes(bytes);
4371        let md: std::collections::HashMap<String, String> =
4372            dmx.metadata().iter().cloned().collect();
4373        assert_eq!(md.get("wav:acid.root_note"), Some(&"0".to_string()));
4374        assert_eq!(md.get("wav:acid.root_note_name"), None);
4375        assert_eq!(md.get("wav:acid.tempo"), Some(&"133.25".to_string()));
4376        assert_eq!(
4377            md.get("wav:acid.reserved"),
4378            Some(&"AA00000000BB".to_string())
4379        );
4380        assert_eq!(md.get("wav:acid.body_len"), Some(&"26".to_string()));
4381    }
4382
4383    /// A body shorter than the 24-byte fixed struct is opaque-skipped:
4384    /// no `wav:acid.*` keys, stream still opens.
4385    #[test]
4386    fn acid_truncated_is_skipped() {
4387        let bytes = wav_with_acid(&[0u8; 23]);
4388        let dmx = open_demux_from_bytes(bytes);
4389        assert!(!dmx
4390            .metadata()
4391            .iter()
4392            .any(|(k, _)| k.starts_with("wav:acid")));
4393        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4394    }
4395
4396    /// Write→read round-trip through the public muxer/demuxer paths:
4397    /// `WavMuxOptions::with_acid` emits the chunk, the demuxer's typed
4398    /// accessor and metadata keys return the identical values, and the
4399    /// PCM payload is untouched.
4400    #[test]
4401    fn acid_round_trip() {
4402        let acid = AcidChunk {
4403            flags: ACID_FLAG_ROOT_NOTE_SET,
4404            root_note: 50, // D
4405            reserved: [0; 6],
4406            num_beats: 64,
4407            meter: 4,
4408            tempo: 174.0,
4409        };
4410        let payload: Vec<u8> = (0..400u32).flat_map(|i| (i as i16).to_le_bytes()).collect();
4411        let stream = make_stream(SampleFormat::S16, 1, 44_100);
4412        let opts = WavMuxOptions::default().with_acid(acid);
4413        let bytes = mux_to_bytes(&stream, &payload, opts, "acid-rt");
4414        // The serialized chunk (header + 24-byte body) appears verbatim.
4415        let mut chunk = b"acid".to_vec();
4416        chunk.extend_from_slice(&24u32.to_le_bytes());
4417        chunk.extend_from_slice(&acid.to_bytes());
4418        assert!(bytes.windows(chunk.len()).any(|w| w == &chunk[..]));
4419
4420        let mut dmx = open_demux_from_bytes(bytes);
4421        let md: std::collections::HashMap<String, String> =
4422            dmx.metadata().iter().cloned().collect();
4423        assert_eq!(md.get("wav:acid.root_note"), Some(&"50".to_string()));
4424        assert_eq!(md.get("wav:acid.root_note_name"), Some(&"D".to_string()));
4425        assert_eq!(md.get("wav:acid.num_beats"), Some(&"64".to_string()));
4426        assert_eq!(md.get("wav:acid.tempo"), Some(&"174".to_string()));
4427        let mut out = Vec::new();
4428        loop {
4429            match dmx.next_packet() {
4430                Ok(p) => out.extend_from_slice(&p.data),
4431                Err(Error::Eof) => break,
4432                Err(e) => panic!("demux error: {e}"),
4433            }
4434        }
4435        assert_eq!(out, payload);
4436    }
4437
4438    /// Typed `WavDemuxer::acid()` accessor (via the concrete
4439    /// `open_wav_demuxer` path) returns the parsed struct field-for-
4440    /// field, including the verbatim reserved bytes; absent chunk →
4441    /// `None`.
4442    #[test]
4443    fn acid_typed_accessor() {
4444        let acid = AcidChunk {
4445            flags: ACID_FLAG_ONE_SHOT | ACID_FLAG_DISK_BASED,
4446            root_note: 71, // High B — last entry of the table
4447            reserved: [1, 2, 3, 4, 5, 6],
4448            num_beats: 4,
4449            meter: 3,
4450            tempo: 60.0,
4451        };
4452        let bytes = wav_with_acid(&acid.to_bytes());
4453        use std::io::Cursor;
4454        let dmx = open_wav_demuxer(Box::new(Cursor::new(bytes))).unwrap();
4455        assert_eq!(dmx.acid(), Some(&acid));
4456        let got = dmx.acid().unwrap();
4457        assert!(got.one_shot() && got.disk_based());
4458        assert!(!got.root_note_set() && !got.stretch() && !got.high_octave());
4459        assert_eq!(got.root_note_name(), Some("High B"));
4460        let md: std::collections::HashMap<String, String> =
4461            dmx.metadata().iter().cloned().collect();
4462        assert_eq!(
4463            md.get("wav:acid.reserved"),
4464            Some(&"010203040506".to_string())
4465        );
4466
4467        // No `acid` chunk → typed accessor is None.
4468        let plain = wav_with_smpl_and_inst(None, None);
4469        let dmx = open_wav_demuxer(Box::new(Cursor::new(plain))).unwrap();
4470        assert_eq!(dmx.acid(), None);
4471    }
4472
4473    /// Build a minimal valid PCM WAV with a caller-supplied raw `plst`
4474    /// chunk inserted between `fmt ` and `data`. Mirrors
4475    /// `wav_with_cue_and_adtl` but for the playlist chunk alone — the
4476    /// playlist references cue ids but is parsed independently of any
4477    /// preceding `cue ` chunk.
4478    fn wav_with_plst(plst_body: &[u8]) -> Vec<u8> {
4479        let mut buf = Vec::new();
4480        buf.extend_from_slice(b"RIFF");
4481        buf.extend_from_slice(&0u32.to_le_bytes());
4482        buf.extend_from_slice(b"WAVE");
4483        // fmt : 16-byte PCM s16 mono 8000 Hz.
4484        buf.extend_from_slice(b"fmt ");
4485        buf.extend_from_slice(&16u32.to_le_bytes());
4486        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
4487        buf.extend_from_slice(&1u16.to_le_bytes());
4488        buf.extend_from_slice(&8_000u32.to_le_bytes());
4489        buf.extend_from_slice(&16_000u32.to_le_bytes());
4490        buf.extend_from_slice(&2u16.to_le_bytes());
4491        buf.extend_from_slice(&16u16.to_le_bytes());
4492        // plst chunk
4493        buf.extend_from_slice(b"plst");
4494        buf.extend_from_slice(&(plst_body.len() as u32).to_le_bytes());
4495        buf.extend_from_slice(plst_body);
4496        if plst_body.len() % 2 == 1 {
4497            buf.push(0);
4498        }
4499        // empty data chunk
4500        buf.extend_from_slice(b"data");
4501        buf.extend_from_slice(&0u32.to_le_bytes());
4502        buf
4503    }
4504
4505    /// Build a single 12-byte `<play-segment>` record per
4506    /// `docs/container/riff/metadata/microsoft-riffmci.pdf` §3.
4507    fn plst_segment(dw_name: u32, dw_length: u32, dw_loops: u32) -> Vec<u8> {
4508        let mut b = Vec::with_capacity(12);
4509        b.extend_from_slice(&dw_name.to_le_bytes());
4510        b.extend_from_slice(&dw_length.to_le_bytes());
4511        b.extend_from_slice(&dw_loops.to_le_bytes());
4512        b
4513    }
4514
4515    /// Full `plst` round-trip: three play segments referencing cue ids
4516    /// 1, 2, 1 (replaying cue 1) surface under index-keyed metadata.
4517    /// The replay case is the reason segments are indexed by position
4518    /// rather than by `dwName`.
4519    #[test]
4520    fn plst_full_metadata() {
4521        let mut plst_body = Vec::new();
4522        plst_body.extend_from_slice(&3u32.to_le_bytes()); // dwSegments
4523        plst_body.extend(plst_segment(1, 4410, 1)); // 0.1s of cue 1
4524        plst_body.extend(plst_segment(2, 8820, 2)); // 0.2s of cue 2, twice
4525        plst_body.extend(plst_segment(1, 4410, 1)); // replay cue 1
4526
4527        let bytes = wav_with_plst(&plst_body);
4528        let dmx = open_demux_from_bytes(bytes);
4529        let md: std::collections::HashMap<String, String> =
4530            dmx.metadata().iter().cloned().collect();
4531
4532        assert_eq!(md.get("wav:plst.count"), Some(&"3".to_string()));
4533        assert_eq!(md.get("wav:plst.0.cue_id"), Some(&"1".to_string()));
4534        assert_eq!(md.get("wav:plst.0.length"), Some(&"4410".to_string()));
4535        assert_eq!(md.get("wav:plst.0.loops"), Some(&"1".to_string()));
4536        assert_eq!(md.get("wav:plst.1.cue_id"), Some(&"2".to_string()));
4537        assert_eq!(md.get("wav:plst.1.length"), Some(&"8820".to_string()));
4538        assert_eq!(md.get("wav:plst.1.loops"), Some(&"2".to_string()));
4539        assert_eq!(md.get("wav:plst.2.cue_id"), Some(&"1".to_string()));
4540        assert_eq!(md.get("wav:plst.2.length"), Some(&"4410".to_string()));
4541        assert_eq!(md.get("wav:plst.2.loops"), Some(&"1".to_string()));
4542    }
4543
4544    /// A `plst` chunk whose `dwSegments` count exceeds the body length
4545    /// must not panic — the parser surfaces only the records that
4546    /// actually fit in the body (defensive against writers that lie
4547    /// about the count, matching the `cue ` clamp behaviour).
4548    #[test]
4549    fn plst_truncated_count_is_clamped() {
4550        // Claim 10 segments, ship 1.
4551        let mut plst_body = Vec::new();
4552        plst_body.extend_from_slice(&10u32.to_le_bytes());
4553        plst_body.extend(plst_segment(42, 1000, 1));
4554        let bytes = wav_with_plst(&plst_body);
4555        let dmx = open_demux_from_bytes(bytes);
4556        let md: std::collections::HashMap<String, String> =
4557            dmx.metadata().iter().cloned().collect();
4558        assert_eq!(md.get("wav:plst.count"), Some(&"1".to_string()));
4559        assert_eq!(md.get("wav:plst.0.cue_id"), Some(&"42".to_string()));
4560        // Stream still opens cleanly.
4561        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4562    }
4563
4564    /// A `plst` chunk shorter than the 4-byte `dwSegments` header is
4565    /// treated as opaque and skipped — no metadata keys emitted, stream
4566    /// still opens.
4567    #[test]
4568    fn plst_truncated_header_is_opaque() {
4569        let plst_body = vec![0u8, 0]; // < 4 bytes
4570        let bytes = wav_with_plst(&plst_body);
4571        let dmx = open_demux_from_bytes(bytes);
4572        let md: std::collections::HashMap<String, String> =
4573            dmx.metadata().iter().cloned().collect();
4574        assert!(md.keys().all(|k| !k.starts_with("wav:plst.")));
4575        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4576    }
4577
4578    /// A zero-segment `plst` chunk surfaces `wav:plst.count = 0` with
4579    /// no per-segment keys.
4580    #[test]
4581    fn plst_zero_segments() {
4582        let plst_body = 0u32.to_le_bytes().to_vec();
4583        let bytes = wav_with_plst(&plst_body);
4584        let dmx = open_demux_from_bytes(bytes);
4585        let md: std::collections::HashMap<String, String> =
4586            dmx.metadata().iter().cloned().collect();
4587        assert_eq!(md.get("wav:plst.count"), Some(&"0".to_string()));
4588        assert!(md
4589            .keys()
4590            .all(|k| !k.starts_with("wav:plst.") || k == "wav:plst.count"));
4591    }
4592
4593    /// An odd-length `plst` body forces a pad byte; the `data` chunk
4594    /// that follows must still be located correctly.
4595    #[test]
4596    fn plst_odd_body_padding() {
4597        // One 12-byte segment + an extra trailing byte → 17 bytes (odd).
4598        let mut plst_body = Vec::new();
4599        plst_body.extend_from_slice(&1u32.to_le_bytes());
4600        plst_body.extend(plst_segment(5, 100, 1));
4601        plst_body.push(0xAA);
4602        let bytes = wav_with_plst(&plst_body);
4603        let dmx = open_demux_from_bytes(bytes);
4604        let md: std::collections::HashMap<String, String> =
4605            dmx.metadata().iter().cloned().collect();
4606        assert_eq!(md.get("wav:plst.count"), Some(&"1".to_string()));
4607        assert_eq!(md.get("wav:plst.0.cue_id"), Some(&"5".to_string()));
4608        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4609    }
4610
4611    /// Build a minimal valid PCM WAV with a caller-supplied raw `fact`
4612    /// chunk inserted between `fmt ` and `data`. Mirrors
4613    /// `wav_with_plst` but for the `fact` chunk — exercised separately
4614    /// so the fact-chunk tests don't depend on the muxer also writing
4615    /// one (which the muxer skips for PCM by design).
4616    fn wav_with_fact(fact_body: &[u8], data_payload: &[u8]) -> Vec<u8> {
4617        let mut buf = Vec::new();
4618        buf.extend_from_slice(b"RIFF");
4619        buf.extend_from_slice(&0u32.to_le_bytes());
4620        buf.extend_from_slice(b"WAVE");
4621        // fmt : 16-byte PCM s16 mono 8000 Hz.
4622        buf.extend_from_slice(b"fmt ");
4623        buf.extend_from_slice(&16u32.to_le_bytes());
4624        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
4625        buf.extend_from_slice(&1u16.to_le_bytes());
4626        buf.extend_from_slice(&8_000u32.to_le_bytes());
4627        buf.extend_from_slice(&16_000u32.to_le_bytes());
4628        buf.extend_from_slice(&2u16.to_le_bytes());
4629        buf.extend_from_slice(&16u16.to_le_bytes());
4630        // fact chunk
4631        buf.extend_from_slice(b"fact");
4632        buf.extend_from_slice(&(fact_body.len() as u32).to_le_bytes());
4633        buf.extend_from_slice(fact_body);
4634        if fact_body.len() % 2 == 1 {
4635            buf.push(0);
4636        }
4637        // data chunk with caller-supplied payload (S16 = 2 bytes/sample).
4638        buf.extend_from_slice(b"data");
4639        buf.extend_from_slice(&(data_payload.len() as u32).to_le_bytes());
4640        buf.extend_from_slice(data_payload);
4641        if data_payload.len() % 2 == 1 {
4642            buf.push(0);
4643        }
4644        buf
4645    }
4646
4647    /// Spec-minimum `fact` body — the 4-byte `dwFileSize` field. A PCM
4648    /// file with a `fact` chunk whose sample count matches the
4649    /// `data / block_align` heuristic should surface
4650    /// `wav:fact.sample_count` and *no* `wav:fact.mismatch` key. This
4651    /// is the well-formed case some WAV writers emit even for PCM
4652    /// (common for large files past the 2 GiB envelope, and emitted
4653    /// unconditionally by several DAW writers).
4654    #[test]
4655    fn fact_minimum_body_matches_data() {
4656        // 100 mono S16 samples → 200 data bytes; fact says 100 too.
4657        let payload: Vec<u8> = (0..200u32).map(|i| (i & 0xFF) as u8).collect();
4658        let fact_body = 100u32.to_le_bytes().to_vec();
4659        let bytes = wav_with_fact(&fact_body, &payload);
4660        let dmx = open_demux_from_bytes(bytes);
4661        let md: std::collections::HashMap<String, String> =
4662            dmx.metadata().iter().cloned().collect();
4663        assert_eq!(md.get("wav:fact.sample_count"), Some(&"100".to_string()));
4664        assert!(!md.contains_key("wav:fact.mismatch"));
4665        assert!(!md.contains_key("wav:fact.body_len"));
4666        // Duration reflects the fact-chunk sample count (here matching
4667        // the heuristic, so the public Duration:sample_count is 100).
4668        assert_eq!(dmx.streams()[0].duration, Some(100));
4669    }
4670
4671    /// `fact` `dwFileSize` that disagrees with the `data / block_align`
4672    /// heuristic surfaces `wav:fact.mismatch` and the duration follows
4673    /// the fact value. This is the canonical compressed-WAV path
4674    /// (e.g. a hypothetical ADPCM stream whose nibble-packed `data`
4675    /// chunk yields fewer-than-bytes samples).
4676    #[test]
4677    fn fact_mismatch_surfaces_diagnostic_and_overrides_duration() {
4678        // 200 data bytes, fact claims only 50 samples → mismatch.
4679        let payload: Vec<u8> = (0..200u32).map(|i| (i & 0xFF) as u8).collect();
4680        let fact_body = 50u32.to_le_bytes().to_vec();
4681        let bytes = wav_with_fact(&fact_body, &payload);
4682        let dmx = open_demux_from_bytes(bytes);
4683        let md: std::collections::HashMap<String, String> =
4684            dmx.metadata().iter().cloned().collect();
4685        assert_eq!(md.get("wav:fact.sample_count"), Some(&"50".to_string()));
4686        assert_eq!(
4687            md.get("wav:fact.mismatch"),
4688            Some(&"block_samples=100 fact_samples=50".to_string())
4689        );
4690        assert_eq!(dmx.streams()[0].duration, Some(50));
4691    }
4692
4693    /// A `fact` chunk longer than the spec-minimum 4 bytes is
4694    /// tolerated per RIFF MCI §3 ("Added fields will appear following
4695    /// the `dwFileSize` field. Applications can use the chunk size
4696    /// field to determine which fields are present.") — the parser
4697    /// reads `dwFileSize`, surfaces `wav:fact.body_len` so callers
4698    /// can see extension bytes are present, and ignores the rest. The
4699    /// `data` chunk that follows must still be located correctly.
4700    #[test]
4701    fn fact_extension_bytes_preserved_in_body_len() {
4702        // 200 data bytes; fact has 4 + 8 = 12 bytes (future-extension
4703        // bytes are opaque per spec but the body_len surfaces them).
4704        let payload: Vec<u8> = (0..200u32).map(|i| (i & 0xFF) as u8).collect();
4705        let mut fact_body = 100u32.to_le_bytes().to_vec();
4706        fact_body.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04]);
4707        let bytes = wav_with_fact(&fact_body, &payload);
4708        let dmx = open_demux_from_bytes(bytes);
4709        let md: std::collections::HashMap<String, String> =
4710            dmx.metadata().iter().cloned().collect();
4711        assert_eq!(md.get("wav:fact.sample_count"), Some(&"100".to_string()));
4712        assert_eq!(md.get("wav:fact.body_len"), Some(&"12".to_string()));
4713        // Stream still opens cleanly — the 12-byte fact body is even
4714        // so no pad byte; the data chunk that follows is intact.
4715        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4716    }
4717
4718    /// A `fact` chunk shorter than the 4-byte fixed `dwFileSize` field
4719    /// is treated as opaque and skipped — no metadata keys emitted,
4720    /// the stream still opens cleanly. The `data` chunk that follows
4721    /// must still be located (validates the chunk-walk pad-byte
4722    /// arithmetic for the < 4-byte case).
4723    #[test]
4724    fn fact_truncated_body_is_opaque() {
4725        let payload: Vec<u8> = vec![0u8, 0u8];
4726        let fact_body = vec![0u8, 0u8]; // < 4 bytes → opaque
4727        let bytes = wav_with_fact(&fact_body, &payload);
4728        let dmx = open_demux_from_bytes(bytes);
4729        let md: std::collections::HashMap<String, String> =
4730            dmx.metadata().iter().cloned().collect();
4731        assert!(md.keys().all(|k| !k.starts_with("wav:fact.")));
4732        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4733    }
4734
4735    /// An odd-length `fact` body forces a pad byte; the `data` chunk
4736    /// that follows must still be located correctly (regression guard
4737    /// matching `plst_odd_body_padding`). RIFF MCI §2 "Chunks"
4738    /// requires all chunks to be word-aligned, with an implicit pad
4739    /// byte appended when the body is odd. The pad byte is NOT part
4740    /// of the body length carried in the chunk header.
4741    #[test]
4742    fn fact_odd_body_padding() {
4743        // 4-byte dwFileSize + 1 future-extension byte = 5 bytes (odd).
4744        let payload: Vec<u8> = vec![0xAA, 0x55];
4745        let mut fact_body = 1u32.to_le_bytes().to_vec();
4746        fact_body.push(0x42);
4747        let bytes = wav_with_fact(&fact_body, &payload);
4748        let dmx = open_demux_from_bytes(bytes);
4749        let md: std::collections::HashMap<String, String> =
4750            dmx.metadata().iter().cloned().collect();
4751        assert_eq!(md.get("wav:fact.sample_count"), Some(&"1".to_string()));
4752        assert_eq!(md.get("wav:fact.body_len"), Some(&"5".to_string()));
4753        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4754    }
4755
4756    /// Round-trip via the muxer: an A-law stream must carry a `fact`
4757    /// chunk per RIFF MCI §3 ("The 'fact' chunk is required ... for
4758    /// all compressed audio formats"). The demuxer surfaces the
4759    /// sample count under `wav:fact.sample_count` and the duration
4760    /// reflects it. For G.711 mono one byte == one per-channel
4761    /// sample so the value matches the heuristic — the mismatch key
4762    /// must be absent.
4763    #[test]
4764    fn fact_chunk_round_trip_alaw_mono() {
4765        let payload: Vec<u8> = (0..=255u8).collect(); // 256 mono A-law samples
4766        let stream = make_g711_stream("pcm_alaw", 1, 8_000);
4767        let bytes = mux_to_bytes(&stream, &payload, WavMuxOptions::default(), "alaw-fact");
4768        // The muxer should have emitted a `fact` chunk between fmt and data.
4769        assert!(
4770            find_chunk(&bytes, b"fact").is_some(),
4771            "muxer must emit fact chunk for non-PCM wFormatTag"
4772        );
4773        let dmx = open_demux_from_bytes(bytes);
4774        let md: std::collections::HashMap<String, String> =
4775            dmx.metadata().iter().cloned().collect();
4776        assert_eq!(md.get("wav:fact.sample_count"), Some(&"256".to_string()));
4777        assert!(!md.contains_key("wav:fact.mismatch"));
4778        assert_eq!(dmx.streams()[0].duration, Some(256));
4779    }
4780
4781    /// Round-trip via the muxer: a plain PCM stream MUST NOT carry a
4782    /// `fact` chunk (per RIFF MCI §3 "The chunk is not required for
4783    /// PCM files using the 'data' chunk format") — we skip emitting
4784    /// it to keep the post-r193 PCM muxer output byte-identical to
4785    /// pre-r193. Regression guard against accidentally emitting it
4786    /// for `wFormatTag = WAVE_FORMAT_PCM`.
4787    #[test]
4788    fn fact_chunk_not_emitted_for_pcm() {
4789        let samples: Vec<i16> = (0..100).map(|i| (i * 100) as i16).collect();
4790        let mut payload = Vec::with_capacity(samples.len() * 2);
4791        for s in &samples {
4792            payload.extend_from_slice(&s.to_le_bytes());
4793        }
4794        let stream = make_stream(SampleFormat::S16, 1, 8_000);
4795        let bytes = mux_to_bytes(&stream, &payload, WavMuxOptions::default(), "pcm-no-fact");
4796        assert!(
4797            find_chunk(&bytes, b"fact").is_none(),
4798            "PCM muxer output must not carry a fact chunk"
4799        );
4800    }
4801
4802    /// Round-trip via the muxer: an EXTENSIBLE stream carries a `fact`
4803    /// chunk too — compliant readers dispatch on the on-wire
4804    /// `wFormatTag` first (which is `0xFFFE`, not PCM), so the chunk
4805    /// is required regardless of which SubFormat GUID the muxer
4806    /// selects.
4807    #[test]
4808    fn fact_chunk_round_trip_extensible() {
4809        let samples: Vec<i16> = (0..200).map(|i| (i * 50) as i16).collect();
4810        let mut payload = Vec::with_capacity(samples.len() * 2);
4811        for s in &samples {
4812            payload.extend_from_slice(&s.to_le_bytes());
4813        }
4814        let stream = make_stream(SampleFormat::S16, 1, 8_000);
4815        let opts = WavMuxOptions::default().with_extensible(0x4); // SPEAKER_FRONT_CENTER
4816        let bytes = mux_to_bytes(&stream, &payload, opts, "ext-fact");
4817        assert!(
4818            find_chunk(&bytes, b"fact").is_some(),
4819            "EXTENSIBLE muxer output must carry a fact chunk"
4820        );
4821        let dmx = open_demux_from_bytes(bytes);
4822        let md: std::collections::HashMap<String, String> =
4823            dmx.metadata().iter().cloned().collect();
4824        assert_eq!(md.get("wav:fact.sample_count"), Some(&"200".to_string()));
4825        assert!(!md.contains_key("wav:fact.mismatch"));
4826    }
4827
4828    /// Build a minimal valid PCM WAV with a caller-supplied raw `iXML`
4829    /// chunk inserted between `fmt ` and `data`. Mirrors `wav_with_fact`
4830    /// for the third-party metadata block documented in
4831    /// `docs/container/riff/metadata/exiftool-riff-tags.html` § `iXML`.
4832    fn wav_with_ixml(ixml_body: &[u8]) -> Vec<u8> {
4833        let mut buf = Vec::new();
4834        buf.extend_from_slice(b"RIFF");
4835        buf.extend_from_slice(&0u32.to_le_bytes());
4836        buf.extend_from_slice(b"WAVE");
4837        // fmt : 16-byte PCM s16 mono 8000 Hz.
4838        buf.extend_from_slice(b"fmt ");
4839        buf.extend_from_slice(&16u32.to_le_bytes());
4840        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
4841        buf.extend_from_slice(&1u16.to_le_bytes());
4842        buf.extend_from_slice(&8_000u32.to_le_bytes());
4843        buf.extend_from_slice(&16_000u32.to_le_bytes());
4844        buf.extend_from_slice(&2u16.to_le_bytes());
4845        buf.extend_from_slice(&16u16.to_le_bytes());
4846        // iXML chunk.
4847        buf.extend_from_slice(b"iXML");
4848        buf.extend_from_slice(&(ixml_body.len() as u32).to_le_bytes());
4849        buf.extend_from_slice(ixml_body);
4850        if ixml_body.len() % 2 == 1 {
4851            buf.push(0);
4852        }
4853        // empty data chunk
4854        buf.extend_from_slice(b"data");
4855        buf.extend_from_slice(&0u32.to_le_bytes());
4856        buf
4857    }
4858
4859    /// A canonical Sound-Devices-style `iXML` document round-trips: the
4860    /// XML text surfaces verbatim under `wav:ixml`, and the raw chunk-
4861    /// body length surfaces under `wav:ixml.body_len`. The stream still
4862    /// opens cleanly.
4863    #[test]
4864    fn ixml_canonical_document_round_trips() {
4865        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
4866<BWFXML>
4867  <IXML_VERSION>2.10</IXML_VERSION>
4868  <PROJECT>OxideAV Round 205</PROJECT>
4869  <SCENE>scn-001</SCENE>
4870  <TAKE>1</TAKE>
4871  <NOTE>iXML canonical fixture</NOTE>
4872  <TRACK_LIST>
4873    <TRACK_COUNT>1</TRACK_COUNT>
4874    <TRACK>
4875      <CHANNEL_INDEX>1</CHANNEL_INDEX>
4876      <NAME>Boom</NAME>
4877      <FUNCTION>Dialog</FUNCTION>
4878    </TRACK>
4879  </TRACK_LIST>
4880</BWFXML>"#;
4881        let bytes = wav_with_ixml(xml.as_bytes());
4882        let dmx = open_demux_from_bytes(bytes);
4883        let md: std::collections::HashMap<String, String> =
4884            dmx.metadata().iter().cloned().collect();
4885        assert_eq!(
4886            md.get("wav:ixml.body_len"),
4887            Some(&xml.len().to_string()),
4888            "raw chunk-body length must surface verbatim"
4889        );
4890        assert_eq!(
4891            md.get("wav:ixml").map(|s| s.as_str()),
4892            Some(xml.trim()),
4893            "iXML text payload must round-trip"
4894        );
4895        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4896    }
4897
4898    /// A NUL-padded `iXML` body (writers commonly reserve a fixed-size
4899    /// block then NUL-fill the trailing space) surfaces only the
4900    /// pre-NUL text under `wav:ixml`; the raw `body_len` still reflects
4901    /// the on-wire byte count so the trailing reserved bytes are not
4902    /// silently lost.
4903    #[test]
4904    fn ixml_trailing_nuls_trimmed_in_text_but_body_len_kept() {
4905        let mut body = b"<BWFXML><PROJECT>OAV</PROJECT></BWFXML>".to_vec();
4906        // Reserve another 64 bytes of NUL pad — emulates writers that
4907        // size the iXML region for in-place editing.
4908        body.resize(body.len() + 64, 0);
4909        let bytes = wav_with_ixml(&body);
4910        let dmx = open_demux_from_bytes(bytes);
4911        let md: std::collections::HashMap<String, String> =
4912            dmx.metadata().iter().cloned().collect();
4913        assert_eq!(
4914            md.get("wav:ixml"),
4915            Some(&"<BWFXML><PROJECT>OAV</PROJECT></BWFXML>".to_string())
4916        );
4917        assert_eq!(md.get("wav:ixml.body_len"), Some(&body.len().to_string()));
4918    }
4919
4920    /// An `iXML` chunk whose body is empty (zero bytes between the
4921    /// 8-byte header and the next chunk) surfaces `wav:ixml.body_len = 0`
4922    /// but no `wav:ixml` text key. Defensive against writers that emit a
4923    /// placeholder iXML header without filling it.
4924    #[test]
4925    fn ixml_empty_body_surfaces_only_body_len() {
4926        let bytes = wav_with_ixml(&[]);
4927        let dmx = open_demux_from_bytes(bytes);
4928        let md: std::collections::HashMap<String, String> =
4929            dmx.metadata().iter().cloned().collect();
4930        assert_eq!(md.get("wav:ixml.body_len"), Some(&"0".to_string()));
4931        assert!(!md.contains_key("wav:ixml"));
4932        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4933    }
4934
4935    /// An `iXML` chunk whose body is entirely NUL / whitespace (e.g.
4936    /// "padding awaiting a writer") surfaces `body_len` but no text key.
4937    #[test]
4938    fn ixml_whitespace_only_body_omits_text_key() {
4939        let body = b"   \t\r\n   \0\0\0".to_vec();
4940        let bytes = wav_with_ixml(&body);
4941        let dmx = open_demux_from_bytes(bytes);
4942        let md: std::collections::HashMap<String, String> =
4943            dmx.metadata().iter().cloned().collect();
4944        assert_eq!(md.get("wav:ixml.body_len"), Some(&body.len().to_string()));
4945        assert!(!md.contains_key("wav:ixml"));
4946    }
4947
4948    /// An odd-length `iXML` body forces a 1-byte pad; the `data` chunk
4949    /// that follows must still be located correctly (regression guard
4950    /// matching `plst_odd_body_padding` / `fact_odd_body_padding`).
4951    #[test]
4952    fn ixml_odd_body_padding() {
4953        // 17 bytes of XML — odd, so the chunk-walk pad-byte path is
4954        // exercised.
4955        let body = b"<X>1</X><Y>2</Y>!".to_vec();
4956        assert_eq!(body.len() % 2, 1);
4957        let bytes = wav_with_ixml(&body);
4958        let dmx = open_demux_from_bytes(bytes);
4959        let md: std::collections::HashMap<String, String> =
4960            dmx.metadata().iter().cloned().collect();
4961        assert_eq!(md.get("wav:ixml"), Some(&"<X>1</X><Y>2</Y>!".to_string()));
4962        assert_eq!(md.get("wav:ixml.body_len"), Some(&"17".to_string()));
4963        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
4964    }
4965
4966    /// Build a minimal valid PCM WAV with a caller-supplied raw `<axml>`
4967    /// chunk inserted between `fmt ` and `data`. Mirrors `wav_with_ixml`
4968    /// for the BWF supplement-5 XML metadata block documented in
4969    /// `docs/container/riff/metadata/ebu-tech3285s5-ADM.pdf` §3.
4970    fn wav_with_axml(axml_body: &[u8]) -> Vec<u8> {
4971        let mut buf = Vec::new();
4972        buf.extend_from_slice(b"RIFF");
4973        buf.extend_from_slice(&0u32.to_le_bytes());
4974        buf.extend_from_slice(b"WAVE");
4975        // fmt : 16-byte PCM s16 mono 8000 Hz.
4976        buf.extend_from_slice(b"fmt ");
4977        buf.extend_from_slice(&16u32.to_le_bytes());
4978        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
4979        buf.extend_from_slice(&1u16.to_le_bytes());
4980        buf.extend_from_slice(&8_000u32.to_le_bytes());
4981        buf.extend_from_slice(&16_000u32.to_le_bytes());
4982        buf.extend_from_slice(&2u16.to_le_bytes());
4983        buf.extend_from_slice(&16u16.to_le_bytes());
4984        // axml chunk.
4985        buf.extend_from_slice(b"axml");
4986        buf.extend_from_slice(&(axml_body.len() as u32).to_le_bytes());
4987        buf.extend_from_slice(axml_body);
4988        if axml_body.len() % 2 == 1 {
4989            buf.push(0);
4990        }
4991        // empty data chunk
4992        buf.extend_from_slice(b"data");
4993        buf.extend_from_slice(&0u32.to_le_bytes());
4994        buf
4995    }
4996
4997    /// A canonical EBUCore-wrapped ADM document round-trips: the XML
4998    /// text surfaces verbatim under `wav:axml`, and the raw chunk-body
4999    /// length surfaces under `wav:axml.body_len`. The fixture is the
5000    /// `<axml>` payload pattern from
5001    /// `docs/container/riff/metadata/ebu-tech3285s5-ADM.pdf` §4.2 with
5002    /// the inner element set trimmed to a single
5003    /// `<audioProgramme>` reference — enough to exercise the parser
5004    /// without dragging the full HOA pack into the fixture.
5005    #[test]
5006    fn axml_canonical_ebucore_adm_document_round_trips() {
5007        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
5008<ebuCoreMain xmlns="urn:ebu:metadata-schema:ebucore"
5009    xmlns:dc="http://purl.org/dc/elements/1.1/">
5010  <coreMetadata>
5011    <format>
5012      <audioFormatExtended>
5013        <audioProgramme audioProgrammeID="APR_1001"
5014            audioProgrammeName="OxideAV Round 258 demo">
5015          <audioContentIDRef>ACO_1001</audioContentIDRef>
5016        </audioProgramme>
5017      </audioFormatExtended>
5018    </format>
5019  </coreMetadata>
5020</ebuCoreMain>"#;
5021        let bytes = wav_with_axml(xml.as_bytes());
5022        let dmx = open_demux_from_bytes(bytes);
5023        let md: std::collections::HashMap<String, String> =
5024            dmx.metadata().iter().cloned().collect();
5025        assert_eq!(
5026            md.get("wav:axml.body_len"),
5027            Some(&xml.len().to_string()),
5028            "raw chunk-body length must surface verbatim"
5029        );
5030        assert_eq!(
5031            md.get("wav:axml").map(|s| s.as_str()),
5032            Some(xml.trim()),
5033            "axml text payload must round-trip"
5034        );
5035        // The chunk-walk must still resolve fmt + data after the
5036        // axml hop — regression guard matching ixml_canonical.
5037        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5038    }
5039
5040    /// An ISRC identifier `<axml>` payload (§4.1 example) round-trips
5041    /// just like the ADM one — the parser is schema-agnostic.
5042    #[test]
5043    fn axml_isrc_identifier_document_round_trips() {
5044        let xml = r#"<ebuCoreMain xmlns:dc="http://purl.org/dc/elements/1.1/"
5045    xmlns="urn:ebu:metadata-schema:ebucore">
5046  <coreMetadata>
5047    <identifier typeLabel="GUID" typeDefinition="Globally Unique Identifier"
5048        formatLabel="ISRC" formatDefinition="International Standard Recording Code">
5049      <dc:identifier>ISRC:NOX001212345</dc:identifier>
5050    </identifier>
5051  </coreMetadata>
5052</ebuCoreMain>"#;
5053        let bytes = wav_with_axml(xml.as_bytes());
5054        let dmx = open_demux_from_bytes(bytes);
5055        let md: std::collections::HashMap<String, String> =
5056            dmx.metadata().iter().cloned().collect();
5057        assert_eq!(md.get("wav:axml").map(|s| s.as_str()), Some(xml.trim()));
5058        assert!(
5059            md.get("wav:axml")
5060                .map(|s| s.contains("ISRC:NOX001212345"))
5061                .unwrap_or(false),
5062            "ISRC identifier must survive the round-trip"
5063        );
5064    }
5065
5066    /// A NUL-padded `<axml>` body (writers commonly reserve a
5067    /// fixed-size block then NUL-fill the trailing space to keep the
5068    /// ADM document mutable in-place) surfaces only the pre-NUL text
5069    /// under `wav:axml`; the raw `body_len` still reflects the on-wire
5070    /// byte count so the trailing reserved bytes are not silently
5071    /// lost. Mirrors `ixml_trailing_nuls_trimmed_in_text_but_body_len_kept`.
5072    #[test]
5073    fn axml_trailing_nuls_trimmed_in_text_but_body_len_kept() {
5074        let mut body = b"<ebuCoreMain><id>X</id></ebuCoreMain>".to_vec();
5075        body.resize(body.len() + 128, 0);
5076        let bytes = wav_with_axml(&body);
5077        let dmx = open_demux_from_bytes(bytes);
5078        let md: std::collections::HashMap<String, String> =
5079            dmx.metadata().iter().cloned().collect();
5080        assert_eq!(
5081            md.get("wav:axml"),
5082            Some(&"<ebuCoreMain><id>X</id></ebuCoreMain>".to_string())
5083        );
5084        assert_eq!(md.get("wav:axml.body_len"), Some(&body.len().to_string()));
5085    }
5086
5087    /// An `<axml>` chunk whose body is empty (zero bytes between the
5088    /// 8-byte header and the next chunk) surfaces
5089    /// `wav:axml.body_len = 0` but no `wav:axml` text key. Defensive
5090    /// against writers that emit a placeholder header without filling
5091    /// it; the §3 "shall be ignored" rule for unintelligible content
5092    /// is a schema-level concern, not a byte-level one — the body
5093    /// length stays observable so the placeholder is discoverable.
5094    #[test]
5095    fn axml_empty_body_surfaces_only_body_len() {
5096        let bytes = wav_with_axml(&[]);
5097        let dmx = open_demux_from_bytes(bytes);
5098        let md: std::collections::HashMap<String, String> =
5099            dmx.metadata().iter().cloned().collect();
5100        assert_eq!(md.get("wav:axml.body_len"), Some(&"0".to_string()));
5101        assert!(!md.contains_key("wav:axml"));
5102        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5103    }
5104
5105    /// An `<axml>` chunk whose body is entirely NUL / whitespace
5106    /// (placeholder reserved by a writer ahead of an ADM authoring
5107    /// pass) surfaces `body_len` but no text key.
5108    #[test]
5109    fn axml_whitespace_only_body_omits_text_key() {
5110        let body = b"   \t\r\n   \0\0\0".to_vec();
5111        let bytes = wav_with_axml(&body);
5112        let dmx = open_demux_from_bytes(bytes);
5113        let md: std::collections::HashMap<String, String> =
5114            dmx.metadata().iter().cloned().collect();
5115        assert_eq!(md.get("wav:axml.body_len"), Some(&body.len().to_string()));
5116        assert!(!md.contains_key("wav:axml"));
5117    }
5118
5119    /// An odd-length `<axml>` body forces a 1-byte pad; the `data`
5120    /// chunk that follows must still be located correctly (regression
5121    /// guard matching `ixml_odd_body_padding`).
5122    #[test]
5123    fn axml_odd_body_padding() {
5124        // 25 bytes of XML — odd, so the chunk-walk pad-byte path is
5125        // exercised. The inner content is deliberately short but
5126        // schema-recognisable.
5127        let body = b"<root><id>z</id></root>!!".to_vec();
5128        assert_eq!(body.len() % 2, 1);
5129        let bytes = wav_with_axml(&body);
5130        let dmx = open_demux_from_bytes(bytes);
5131        let md: std::collections::HashMap<String, String> =
5132            dmx.metadata().iter().cloned().collect();
5133        assert_eq!(
5134            md.get("wav:axml"),
5135            Some(&"<root><id>z</id></root>!!".to_string())
5136        );
5137        assert_eq!(md.get("wav:axml.body_len"), Some(&"25".to_string()));
5138        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5139    }
5140
5141    /// Build a minimal valid PCM WAV with a caller-supplied raw `_PMX`
5142    /// (XMP packet) chunk inserted between `fmt ` and `data`. Mirrors
5143    /// `wav_with_axml` / `wav_with_ixml` for the third-party XMP
5144    /// metadata block catalogued in
5145    /// `docs/container/riff/metadata/exiftool-riff-tags.html` § "RIFF
5146    /// Main tags" (entry `'_PMX'`, scope "AVI and WAV files").
5147    fn wav_with_pmx(pmx_body: &[u8]) -> Vec<u8> {
5148        let mut buf = Vec::new();
5149        buf.extend_from_slice(b"RIFF");
5150        buf.extend_from_slice(&0u32.to_le_bytes());
5151        buf.extend_from_slice(b"WAVE");
5152        // fmt : 16-byte PCM s16 mono 8000 Hz.
5153        buf.extend_from_slice(b"fmt ");
5154        buf.extend_from_slice(&16u32.to_le_bytes());
5155        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
5156        buf.extend_from_slice(&1u16.to_le_bytes());
5157        buf.extend_from_slice(&8_000u32.to_le_bytes());
5158        buf.extend_from_slice(&16_000u32.to_le_bytes());
5159        buf.extend_from_slice(&2u16.to_le_bytes());
5160        buf.extend_from_slice(&16u16.to_le_bytes());
5161        // _PMX chunk.
5162        buf.extend_from_slice(b"_PMX");
5163        buf.extend_from_slice(&(pmx_body.len() as u32).to_le_bytes());
5164        buf.extend_from_slice(pmx_body);
5165        if pmx_body.len() % 2 == 1 {
5166            buf.push(0);
5167        }
5168        // empty data chunk
5169        buf.extend_from_slice(b"data");
5170        buf.extend_from_slice(&0u32.to_le_bytes());
5171        buf
5172    }
5173
5174    /// A canonical Adobe-style XMP packet round-trips: the UTF-8 XMP
5175    /// text surfaces verbatim under `wav:xmp`, and the raw chunk-body
5176    /// length surfaces under `wav:xmp.body_len`. The wrapping
5177    /// `<?xpacket begin=...?>` / `<?xpacket end=...?>` processing
5178    /// instructions are passed through unchanged — the parser is
5179    /// schema-agnostic by design.
5180    #[test]
5181    fn pmx_canonical_xmp_packet_round_trips() {
5182        let xml = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
5183<x:xmpmeta xmlns:x="adobe:ns:meta/">
5184  <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
5185    <rdf:Description rdf:about=""
5186        xmlns:dc="http://purl.org/dc/elements/1.1/">
5187      <dc:title>
5188        <rdf:Alt>
5189          <rdf:li xml:lang="x-default">OxideAV Round 263 Fixture</rdf:li>
5190        </rdf:Alt>
5191      </dc:title>
5192    </rdf:Description>
5193  </rdf:RDF>
5194</x:xmpmeta>
5195<?xpacket end="w"?>"#;
5196        let bytes = wav_with_pmx(xml.as_bytes());
5197        let dmx = open_demux_from_bytes(bytes);
5198        let md: std::collections::HashMap<String, String> =
5199            dmx.metadata().iter().cloned().collect();
5200        assert_eq!(
5201            md.get("wav:xmp.body_len"),
5202            Some(&xml.len().to_string()),
5203            "raw chunk-body length must surface verbatim"
5204        );
5205        assert_eq!(
5206            md.get("wav:xmp").map(|s| s.as_str()),
5207            Some(xml.trim()),
5208            "XMP packet text must round-trip"
5209        );
5210        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5211    }
5212
5213    /// A NUL-padded `_PMX` body (writers commonly reserve a fixed-size
5214    /// block for in-place XMP editing) surfaces only the pre-NUL text
5215    /// under `wav:xmp`; the raw `body_len` still reflects the on-wire
5216    /// byte count so the trailing reserved bytes are observable.
5217    #[test]
5218    fn pmx_trailing_nuls_trimmed_in_text_but_body_len_kept() {
5219        let mut body = b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>".to_vec();
5220        // Reserve another 96 bytes of NUL pad — emulates writers that
5221        // size the XMP region for in-place editing.
5222        body.resize(body.len() + 96, 0);
5223        let bytes = wav_with_pmx(&body);
5224        let dmx = open_demux_from_bytes(bytes);
5225        let md: std::collections::HashMap<String, String> =
5226            dmx.metadata().iter().cloned().collect();
5227        assert_eq!(
5228            md.get("wav:xmp"),
5229            Some(&"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>".to_string())
5230        );
5231        assert_eq!(md.get("wav:xmp.body_len"), Some(&body.len().to_string()));
5232    }
5233
5234    /// A `_PMX` chunk whose body is empty (zero bytes between the
5235    /// 8-byte header and the next chunk) surfaces `wav:xmp.body_len = 0`
5236    /// but no `wav:xmp` text key. Defensive against writers that emit a
5237    /// placeholder XMP header without filling it.
5238    #[test]
5239    fn pmx_empty_body_surfaces_only_body_len() {
5240        let bytes = wav_with_pmx(&[]);
5241        let dmx = open_demux_from_bytes(bytes);
5242        let md: std::collections::HashMap<String, String> =
5243            dmx.metadata().iter().cloned().collect();
5244        assert_eq!(md.get("wav:xmp.body_len"), Some(&"0".to_string()));
5245        assert!(!md.contains_key("wav:xmp"));
5246        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5247    }
5248
5249    /// A `_PMX` chunk whose body is entirely NUL / whitespace
5250    /// (placeholder awaiting an XMP-aware writer) surfaces `body_len`
5251    /// but no text key.
5252    #[test]
5253    fn pmx_whitespace_only_body_omits_text_key() {
5254        let body = b"   \t\r\n   \0\0\0".to_vec();
5255        let bytes = wav_with_pmx(&body);
5256        let dmx = open_demux_from_bytes(bytes);
5257        let md: std::collections::HashMap<String, String> =
5258            dmx.metadata().iter().cloned().collect();
5259        assert_eq!(md.get("wav:xmp.body_len"), Some(&body.len().to_string()));
5260        assert!(!md.contains_key("wav:xmp"));
5261    }
5262
5263    /// An odd-length `_PMX` body forces a 1-byte pad; the `data` chunk
5264    /// that follows must still be located correctly (regression guard
5265    /// matching `axml_odd_body_padding` / `ixml_odd_body_padding`).
5266    #[test]
5267    fn pmx_odd_body_padding() {
5268        // 27 bytes — odd, exercises the chunk-walk pad-byte path.
5269        let body = b"<x:xmpmeta xmlns:x=\"a:n\"/>!".to_vec();
5270        assert_eq!(body.len() % 2, 1);
5271        let bytes = wav_with_pmx(&body);
5272        let dmx = open_demux_from_bytes(bytes);
5273        let md: std::collections::HashMap<String, String> =
5274            dmx.metadata().iter().cloned().collect();
5275        assert_eq!(
5276            md.get("wav:xmp"),
5277            Some(&"<x:xmpmeta xmlns:x=\"a:n\"/>!".to_string())
5278        );
5279        assert_eq!(md.get("wav:xmp.body_len"), Some(&"27".to_string()));
5280        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5281    }
5282
5283    /// A file with no `_PMX` chunk surfaces no `wav:xmp.*` keys at all
5284    /// — absence is observable. Mirrors the matching absence guards for
5285    /// `iXML`, `axml`, and `JUNK`.
5286    #[test]
5287    fn pmx_absent_chunk_emits_no_xmp_keys() {
5288        // Reuse the wav_with_axml helper but pass through with NO axml
5289        // body to avoid coincidental key emissions — we want a file with
5290        // neither axml nor _PMX, which the iXML/JUNK suites already do.
5291        // Build the minimal "fmt + data only" PCM file directly.
5292        let mut buf = Vec::new();
5293        buf.extend_from_slice(b"RIFF");
5294        buf.extend_from_slice(&0u32.to_le_bytes());
5295        buf.extend_from_slice(b"WAVE");
5296        buf.extend_from_slice(b"fmt ");
5297        buf.extend_from_slice(&16u32.to_le_bytes());
5298        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
5299        buf.extend_from_slice(&1u16.to_le_bytes());
5300        buf.extend_from_slice(&8_000u32.to_le_bytes());
5301        buf.extend_from_slice(&16_000u32.to_le_bytes());
5302        buf.extend_from_slice(&2u16.to_le_bytes());
5303        buf.extend_from_slice(&16u16.to_le_bytes());
5304        buf.extend_from_slice(b"data");
5305        buf.extend_from_slice(&0u32.to_le_bytes());
5306        let dmx = open_demux_from_bytes(buf);
5307        let md: std::collections::HashMap<String, String> =
5308            dmx.metadata().iter().cloned().collect();
5309        assert!(!md.contains_key("wav:xmp"));
5310        assert!(!md.contains_key("wav:xmp.body_len"));
5311    }
5312
5313    /// Build a minimal valid PCM WAV with a caller-supplied raw `CSET`
5314    /// chunk inserted between `fmt ` and `data`. Mirrors `wav_with_ixml`
5315    /// for the character-set declaration documented in
5316    /// `docs/container/riff/metadata/microsoft-riffmci.pdf` §3
5317    /// "CSET (Character Set) Chunk".
5318    fn wav_with_cset(cset_body: &[u8]) -> Vec<u8> {
5319        let mut buf = Vec::new();
5320        buf.extend_from_slice(b"RIFF");
5321        buf.extend_from_slice(&0u32.to_le_bytes());
5322        buf.extend_from_slice(b"WAVE");
5323        // fmt : 16-byte PCM s16 mono 8000 Hz.
5324        buf.extend_from_slice(b"fmt ");
5325        buf.extend_from_slice(&16u32.to_le_bytes());
5326        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
5327        buf.extend_from_slice(&1u16.to_le_bytes());
5328        buf.extend_from_slice(&8_000u32.to_le_bytes());
5329        buf.extend_from_slice(&16_000u32.to_le_bytes());
5330        buf.extend_from_slice(&2u16.to_le_bytes());
5331        buf.extend_from_slice(&16u16.to_le_bytes());
5332        // CSET chunk.
5333        buf.extend_from_slice(b"CSET");
5334        buf.extend_from_slice(&(cset_body.len() as u32).to_le_bytes());
5335        buf.extend_from_slice(cset_body);
5336        if cset_body.len() % 2 == 1 {
5337            buf.push(0);
5338        }
5339        // empty data chunk
5340        buf.extend_from_slice(b"data");
5341        buf.extend_from_slice(&0u32.to_le_bytes());
5342        buf
5343    }
5344
5345    /// Build a canonical 8-byte CSET body from its four `u16` fields,
5346    /// mirroring the spec layout `(code_page, country, language, dialect)`.
5347    fn cset_body(code_page: u16, country: u16, language: u16, dialect: u16) -> Vec<u8> {
5348        let mut body = Vec::with_capacity(8);
5349        body.extend_from_slice(&code_page.to_le_bytes());
5350        body.extend_from_slice(&country.to_le_bytes());
5351        body.extend_from_slice(&language.to_le_bytes());
5352        body.extend_from_slice(&dialect.to_le_bytes());
5353        body
5354    }
5355
5356    /// A canonical CSET chunk for Windows-1252 / UK English / United
5357    /// Kingdom round-trips: every raw field surfaces under the matching
5358    /// `wav:cset.*` key, the human-readable lookups resolve, and the
5359    /// `body_len` reflects the 8-byte canonical struct.
5360    #[test]
5361    fn cset_canonical_uk_english_round_trips() {
5362        // wCodePage = 1252 (Windows Western European), wCountryCode = 44
5363        // (United Kingdom), wLanguageCode = 9 / wDialect = 2 (UK English).
5364        let body = cset_body(1252, 44, 9, 2);
5365        let bytes = wav_with_cset(&body);
5366        let dmx = open_demux_from_bytes(bytes);
5367        let md: std::collections::HashMap<String, String> =
5368            dmx.metadata().iter().cloned().collect();
5369        assert_eq!(md.get("wav:cset.body_len"), Some(&"8".to_string()));
5370        assert_eq!(md.get("wav:cset.code_page"), Some(&"1252".to_string()));
5371        assert_eq!(md.get("wav:cset.country"), Some(&"44".to_string()));
5372        assert_eq!(
5373            md.get("wav:cset.country_name"),
5374            Some(&"United Kingdom".to_string())
5375        );
5376        assert_eq!(md.get("wav:cset.language"), Some(&"9".to_string()));
5377        assert_eq!(md.get("wav:cset.dialect"), Some(&"2".to_string()));
5378        assert_eq!(
5379            md.get("wav:cset.language_name"),
5380            Some(&"UK English".to_string())
5381        );
5382        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5383    }
5384
5385    /// All-zero CSET — the spec-mandated "use defaults" form — must
5386    /// surface the raw zeros plus the human-readable "None" placeholders
5387    /// from the country / language tables. The language pair `(0, _)`
5388    /// resolves to `None` per the §3 enumeration.
5389    #[test]
5390    fn cset_all_zero_uses_spec_defaults() {
5391        let body = cset_body(0, 0, 0, 0);
5392        let bytes = wav_with_cset(&body);
5393        let dmx = open_demux_from_bytes(bytes);
5394        let md: std::collections::HashMap<String, String> =
5395            dmx.metadata().iter().cloned().collect();
5396        assert_eq!(md.get("wav:cset.code_page"), Some(&"0".to_string()));
5397        assert_eq!(md.get("wav:cset.country"), Some(&"0".to_string()));
5398        assert_eq!(
5399            md.get("wav:cset.country_name"),
5400            Some(&"None".to_string()),
5401            "wCountryCode = 0 must resolve to the §3 'None' placeholder"
5402        );
5403        assert_eq!(md.get("wav:cset.language"), Some(&"0".to_string()));
5404        assert_eq!(md.get("wav:cset.dialect"), Some(&"0".to_string()));
5405        assert_eq!(
5406            md.get("wav:cset.language_name"),
5407            Some(&"None".to_string()),
5408            "wLanguageCode = 0 must resolve to the §3 'None' placeholder regardless of dialect"
5409        );
5410    }
5411
5412    /// Out-of-table code-page / country / language values still surface
5413    /// their raw numeric value; the human-readable lookups are simply
5414    /// absent. Defensive guard for vendor extensions and future code
5415    /// pages (e.g. 65001 / UTF-8 is not in the 1991 enumeration).
5416    #[test]
5417    fn cset_unknown_codes_emit_raw_values_only() {
5418        // 65001 (UTF-8 / not in the §3 enumeration), 999 (not a defined
5419        // country code), 99 / 99 (not a defined language pair).
5420        let body = cset_body(65001, 999, 99, 99);
5421        let bytes = wav_with_cset(&body);
5422        let dmx = open_demux_from_bytes(bytes);
5423        let md: std::collections::HashMap<String, String> =
5424            dmx.metadata().iter().cloned().collect();
5425        assert_eq!(md.get("wav:cset.code_page"), Some(&"65001".to_string()));
5426        assert_eq!(md.get("wav:cset.country"), Some(&"999".to_string()));
5427        assert!(
5428            !md.contains_key("wav:cset.country_name"),
5429            "unknown country must not synthesise a human-readable name"
5430        );
5431        assert_eq!(md.get("wav:cset.language"), Some(&"99".to_string()));
5432        assert_eq!(md.get("wav:cset.dialect"), Some(&"99".to_string()));
5433        assert!(
5434            !md.contains_key("wav:cset.language_name"),
5435            "unknown language pair must not synthesise a human-readable name"
5436        );
5437    }
5438
5439    /// A CSET body shorter than the canonical 8-byte struct is treated
5440    /// as opaque: only `wav:cset.body_len` is emitted. Defensive against
5441    /// truncated writers; the chunk-walk loop still advances correctly.
5442    #[test]
5443    fn cset_short_body_treated_as_opaque() {
5444        // 4 bytes — half the spec struct. No `code_page` / `country` /
5445        // language pair should surface (incomplete fields would be a
5446        // guess, not a read).
5447        let body = vec![0x52, 0x04, 0x00, 0x00];
5448        let bytes = wav_with_cset(&body);
5449        let dmx = open_demux_from_bytes(bytes);
5450        let md: std::collections::HashMap<String, String> =
5451            dmx.metadata().iter().cloned().collect();
5452        assert_eq!(md.get("wav:cset.body_len"), Some(&"4".to_string()));
5453        assert!(!md.contains_key("wav:cset.code_page"));
5454        assert!(!md.contains_key("wav:cset.country"));
5455        assert!(!md.contains_key("wav:cset.language"));
5456        assert!(!md.contains_key("wav:cset.dialect"));
5457        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5458    }
5459
5460    /// A CSET body longer than the canonical 8 bytes tolerates the
5461    /// trailing region (forward-compat) — every documented field still
5462    /// surfaces, and `body_len` reflects the actual on-wire size so the
5463    /// extra payload is observable.
5464    #[test]
5465    fn cset_oversized_body_tolerates_trailing_bytes() {
5466        // 8-byte canonical struct + 4 trailing bytes a hypothetical
5467        // future extension might reserve.
5468        let mut body = cset_body(932, 81, 17, 1); // Shift-JIS / Japan / Japanese
5469        body.extend_from_slice(&[0xFE, 0xCA, 0xAD, 0xDE]);
5470        assert_eq!(body.len(), 12);
5471        let bytes = wav_with_cset(&body);
5472        let dmx = open_demux_from_bytes(bytes);
5473        let md: std::collections::HashMap<String, String> =
5474            dmx.metadata().iter().cloned().collect();
5475        assert_eq!(md.get("wav:cset.body_len"), Some(&"12".to_string()));
5476        assert_eq!(md.get("wav:cset.code_page"), Some(&"932".to_string()));
5477        assert_eq!(md.get("wav:cset.country"), Some(&"81".to_string()));
5478        assert_eq!(md.get("wav:cset.country_name"), Some(&"Japan".to_string()));
5479        assert_eq!(md.get("wav:cset.language"), Some(&"17".to_string()));
5480        assert_eq!(md.get("wav:cset.dialect"), Some(&"1".to_string()));
5481        assert_eq!(
5482            md.get("wav:cset.language_name"),
5483            Some(&"Japanese".to_string())
5484        );
5485    }
5486
5487    /// CSET coexists with `LIST INFO` without disrupting the existing
5488    /// INFO sub-ID parser — the CSET fields surface alongside the INFO
5489    /// title and the file still parses end-to-end. Regression guard for
5490    /// the chunk-walk ordering CSET → LIST(INFO).
5491    #[test]
5492    fn cset_coexists_with_list_info() {
5493        // Hand-build: RIFF / WAVE / fmt / CSET / LIST(INFO INAM "T") /
5494        // data(empty). CSET says Windows-1252 / France / French.
5495        let mut buf = Vec::new();
5496        buf.extend_from_slice(b"RIFF");
5497        buf.extend_from_slice(&0u32.to_le_bytes());
5498        buf.extend_from_slice(b"WAVE");
5499        buf.extend_from_slice(b"fmt ");
5500        buf.extend_from_slice(&16u32.to_le_bytes());
5501        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
5502        buf.extend_from_slice(&1u16.to_le_bytes());
5503        buf.extend_from_slice(&8_000u32.to_le_bytes());
5504        buf.extend_from_slice(&16_000u32.to_le_bytes());
5505        buf.extend_from_slice(&2u16.to_le_bytes());
5506        buf.extend_from_slice(&16u16.to_le_bytes());
5507        // CSET: 1252 / 33 (France) / 12 / 1 (French).
5508        let cset = cset_body(1252, 33, 12, 1);
5509        buf.extend_from_slice(b"CSET");
5510        buf.extend_from_slice(&(cset.len() as u32).to_le_bytes());
5511        buf.extend_from_slice(&cset);
5512        // LIST INFO with INAM = "T" (1 byte, odd-length → 1 byte pad).
5513        // INFO header (4) + INAM(4) + size(4) + payload(1) + pad(1) = 14.
5514        // Total LIST body = "INFO" + (INAM + size + "T" + pad) = 4 + 10 = 14.
5515        let mut list_body = Vec::new();
5516        list_body.extend_from_slice(b"INFO");
5517        list_body.extend_from_slice(b"INAM");
5518        list_body.extend_from_slice(&1u32.to_le_bytes());
5519        list_body.extend_from_slice(b"T");
5520        list_body.push(0); // ZSTR NUL terminator (consumed as the in-body pad).
5521        buf.extend_from_slice(b"LIST");
5522        buf.extend_from_slice(&(list_body.len() as u32).to_le_bytes());
5523        buf.extend_from_slice(&list_body);
5524        // empty data chunk.
5525        buf.extend_from_slice(b"data");
5526        buf.extend_from_slice(&0u32.to_le_bytes());
5527
5528        let dmx = open_demux_from_bytes(buf);
5529        let md: std::collections::HashMap<String, String> =
5530            dmx.metadata().iter().cloned().collect();
5531        assert_eq!(md.get("wav:cset.code_page"), Some(&"1252".to_string()));
5532        assert_eq!(md.get("wav:cset.country_name"), Some(&"France".to_string()));
5533        assert_eq!(
5534            md.get("wav:cset.language_name"),
5535            Some(&"French".to_string())
5536        );
5537        assert_eq!(md.get("title"), Some(&"T".to_string()));
5538    }
5539
5540    /// An odd-length CSET body forces a 1-byte pad; the `data` chunk
5541    /// that follows must still be located correctly (regression guard
5542    /// matching `ixml_odd_body_padding`).
5543    #[test]
5544    fn cset_odd_body_padding() {
5545        // 9-byte CSET body: 8 canonical bytes + 1 trailing sentinel.
5546        let mut body = cset_body(1252, 1, 9, 1);
5547        body.push(0xAA);
5548        assert_eq!(body.len() % 2, 1);
5549        let bytes = wav_with_cset(&body);
5550        let dmx = open_demux_from_bytes(bytes);
5551        let md: std::collections::HashMap<String, String> =
5552            dmx.metadata().iter().cloned().collect();
5553        assert_eq!(md.get("wav:cset.body_len"), Some(&"9".to_string()));
5554        assert_eq!(md.get("wav:cset.code_page"), Some(&"1252".to_string()));
5555        assert_eq!(md.get("wav:cset.country_name"), Some(&"USA".to_string()));
5556        assert_eq!(
5557            md.get("wav:cset.language_name"),
5558            Some(&"US English".to_string())
5559        );
5560        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5561    }
5562
5563    /// `cset_country_name` covers the spec's enumerated country codes;
5564    /// unknown codes return `None`. Spot-check the boundary codes (the
5565    /// three-digit Portugal / Luxembourg / Iceland / Finland entries)
5566    /// plus a representative one-digit code (USA).
5567    #[test]
5568    fn cset_country_name_table_spot_checks() {
5569        assert_eq!(cset_country_name(1), Some("USA"));
5570        assert_eq!(cset_country_name(44), Some("United Kingdom"));
5571        assert_eq!(cset_country_name(351), Some("Portugal"));
5572        assert_eq!(cset_country_name(358), Some("Finland"));
5573        assert_eq!(cset_country_name(0), Some("None"));
5574        assert_eq!(cset_country_name(500), None);
5575    }
5576
5577    /// `cset_language_name` covers the spec's enumerated `(language,
5578    /// dialect)` pairs. Spot-check the dialect-disambiguated rows
5579    /// (English UK/US, French Belgian/Canadian/Swiss, Serbo-Croatian
5580    /// Latin/Cyrillic) — they are the entries the table exists *for*.
5581    #[test]
5582    fn cset_language_name_table_spot_checks() {
5583        assert_eq!(cset_language_name(9, 1), Some("US English"));
5584        assert_eq!(cset_language_name(9, 2), Some("UK English"));
5585        assert_eq!(cset_language_name(12, 2), Some("Belgian French"));
5586        assert_eq!(cset_language_name(12, 3), Some("Canadian French"));
5587        assert_eq!(cset_language_name(12, 4), Some("Swiss French"));
5588        assert_eq!(cset_language_name(26, 1), Some("Serbo-Croatian (Latin)"));
5589        assert_eq!(cset_language_name(26, 2), Some("Serbo-Croatian (Cyrillic)"));
5590        assert_eq!(cset_language_name(0, 0), Some("None"));
5591        assert_eq!(cset_language_name(0, 1), Some("None"));
5592        // Defined language, undefined dialect — must NOT silently fall
5593        // back to dialect 1.
5594        assert_eq!(cset_language_name(9, 9), None);
5595    }
5596
5597    /// Build a minimal valid PCM WAV with a caller-supplied number of
5598    /// `JUNK` chunks, each with the given body size and a fixed
5599    /// per-chunk fill byte, inserted between `fmt ` and `data`. Mirrors
5600    /// `wav_with_cset` / `wav_with_ixml` for the filler chunk
5601    /// documented in Microsoft RIFF MCI §2 "JUNK (Filler) Chunk".
5602    fn wav_with_junk(junk_sizes: &[usize]) -> Vec<u8> {
5603        let mut buf = Vec::new();
5604        buf.extend_from_slice(b"RIFF");
5605        buf.extend_from_slice(&0u32.to_le_bytes());
5606        buf.extend_from_slice(b"WAVE");
5607        // fmt : 16-byte PCM s16 mono 8000 Hz.
5608        buf.extend_from_slice(b"fmt ");
5609        buf.extend_from_slice(&16u32.to_le_bytes());
5610        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
5611        buf.extend_from_slice(&1u16.to_le_bytes());
5612        buf.extend_from_slice(&8_000u32.to_le_bytes());
5613        buf.extend_from_slice(&16_000u32.to_le_bytes());
5614        buf.extend_from_slice(&2u16.to_le_bytes());
5615        buf.extend_from_slice(&16u16.to_le_bytes());
5616        for (i, &sz) in junk_sizes.iter().enumerate() {
5617            buf.extend_from_slice(b"JUNK");
5618            buf.extend_from_slice(&(sz as u32).to_le_bytes());
5619            // Fill byte is the chunk index — lets a debugger see which
5620            // JUNK the bytes belong to without affecting parsing
5621            // behaviour (the parser must not depend on the contents).
5622            buf.extend(std::iter::repeat_n(i as u8, sz));
5623            if sz % 2 == 1 {
5624                buf.push(0);
5625            }
5626        }
5627        // empty data chunk
5628        buf.extend_from_slice(b"data");
5629        buf.extend_from_slice(&0u32.to_le_bytes());
5630        buf
5631    }
5632
5633    /// A single 16-byte `JUNK` chunk surfaces its count, total payload
5634    /// bytes and per-chunk body length under the `wav:junk.*` key
5635    /// shape. The body contents are not surfaced (the spec defines
5636    /// them as "no relevant data"). The chunk-walk still locates the
5637    /// `data` chunk that follows.
5638    #[test]
5639    fn junk_single_chunk_surfaces_accounting_metadata() {
5640        let bytes = wav_with_junk(&[16]);
5641        let dmx = open_demux_from_bytes(bytes);
5642        let md: std::collections::HashMap<String, String> =
5643            dmx.metadata().iter().cloned().collect();
5644        assert_eq!(md.get("wav:junk.count"), Some(&"1".to_string()));
5645        assert_eq!(md.get("wav:junk.total_bytes"), Some(&"16".to_string()));
5646        assert_eq!(md.get("wav:junk.0.body_len"), Some(&"16".to_string()));
5647        // Body contents must not leak into metadata under any key.
5648        assert!(
5649            !md.keys()
5650                .any(|k| k.starts_with("wav:junk") && k.ends_with(".body")),
5651            "JUNK chunk body must not be surfaced (Microsoft RIFF MCI §2)"
5652        );
5653        // The fmt + data path still works end-to-end.
5654        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5655    }
5656
5657    /// Multiple `JUNK` chunks accumulate into the `count` /
5658    /// `total_bytes` aggregates and each surfaces its own
5659    /// `wav:junk.<n>.body_len`. The §2 spec allows arbitrary repetition;
5660    /// many real writers reserve one slot ahead of `LIST INFO` and a
5661    /// second ahead of `data` for in-place editing.
5662    #[test]
5663    fn junk_multiple_chunks_accumulate() {
5664        let bytes = wav_with_junk(&[32, 8, 100]);
5665        let dmx = open_demux_from_bytes(bytes);
5666        let md: std::collections::HashMap<String, String> =
5667            dmx.metadata().iter().cloned().collect();
5668        assert_eq!(md.get("wav:junk.count"), Some(&"3".to_string()));
5669        assert_eq!(
5670            md.get("wav:junk.total_bytes"),
5671            Some(&(32u64 + 8 + 100).to_string())
5672        );
5673        assert_eq!(md.get("wav:junk.0.body_len"), Some(&"32".to_string()));
5674        assert_eq!(md.get("wav:junk.1.body_len"), Some(&"8".to_string()));
5675        assert_eq!(md.get("wav:junk.2.body_len"), Some(&"100".to_string()));
5676        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5677    }
5678
5679    /// A zero-length `JUNK` chunk is in-range per the §2 "arbitrary
5680    /// size" language and still increments the count. The
5681    /// `wav:junk.0.body_len = 0` entry distinguishes "an empty JUNK
5682    /// was present" from "no JUNK was present at all" — the latter
5683    /// emits no `wav:junk.*` keys whatsoever.
5684    #[test]
5685    fn junk_empty_body_still_counts() {
5686        let bytes = wav_with_junk(&[0]);
5687        let dmx = open_demux_from_bytes(bytes);
5688        let md: std::collections::HashMap<String, String> =
5689            dmx.metadata().iter().cloned().collect();
5690        assert_eq!(md.get("wav:junk.count"), Some(&"1".to_string()));
5691        assert_eq!(md.get("wav:junk.total_bytes"), Some(&"0".to_string()));
5692        assert_eq!(md.get("wav:junk.0.body_len"), Some(&"0".to_string()));
5693    }
5694
5695    /// A file with no `JUNK` chunks must not synthesise any
5696    /// `wav:junk.*` keys (absence is observable: zero `count` keys is
5697    /// stronger than `count = 0` because it costs no bytes). Baseline
5698    /// regression guard against a future refactor that initialises the
5699    /// counter unconditionally.
5700    #[test]
5701    fn junk_absent_emits_no_keys() {
5702        let bytes = wav_with_junk(&[]);
5703        let dmx = open_demux_from_bytes(bytes);
5704        let md: std::collections::HashMap<String, String> =
5705            dmx.metadata().iter().cloned().collect();
5706        assert!(
5707            !md.keys().any(|k| k.starts_with("wav:junk")),
5708            "no JUNK chunk → no wav:junk.* metadata"
5709        );
5710        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5711    }
5712
5713    /// An odd-length `JUNK` body forces a 1-byte word-align pad; the
5714    /// `data` chunk that follows must still be located correctly
5715    /// (regression guard matching `ixml_odd_body_padding` /
5716    /// `cset_odd_body_padding`). RIFF MCI §2 "Chunks" requires all
5717    /// chunks to be word-aligned with an implicit pad byte when the
5718    /// body is odd; the pad byte is NOT part of `ckSize`.
5719    #[test]
5720    fn junk_odd_body_padding() {
5721        let bytes = wav_with_junk(&[7]); // odd
5722        let dmx = open_demux_from_bytes(bytes);
5723        let md: std::collections::HashMap<String, String> =
5724            dmx.metadata().iter().cloned().collect();
5725        assert_eq!(md.get("wav:junk.0.body_len"), Some(&"7".to_string()));
5726        assert_eq!(md.get("wav:junk.total_bytes"), Some(&"7".to_string()));
5727        // data chunk located correctly past the pad.
5728        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5729    }
5730
5731    /// `JUNK` coexists with `LIST INFO` and `CSET` without disrupting
5732    /// the rest of the metadata surface. Regression guard for the
5733    /// chunk-walk ordering JUNK → CSET → LIST(INFO) → JUNK → data
5734    /// (a realistic shape when a writer reserves filler ahead of both
5735    /// the metadata block and the audio payload).
5736    #[test]
5737    fn junk_coexists_with_other_chunks() {
5738        let mut buf = Vec::new();
5739        buf.extend_from_slice(b"RIFF");
5740        buf.extend_from_slice(&0u32.to_le_bytes());
5741        buf.extend_from_slice(b"WAVE");
5742        buf.extend_from_slice(b"fmt ");
5743        buf.extend_from_slice(&16u32.to_le_bytes());
5744        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
5745        buf.extend_from_slice(&1u16.to_le_bytes());
5746        buf.extend_from_slice(&8_000u32.to_le_bytes());
5747        buf.extend_from_slice(&16_000u32.to_le_bytes());
5748        buf.extend_from_slice(&2u16.to_le_bytes());
5749        buf.extend_from_slice(&16u16.to_le_bytes());
5750        // First JUNK: 12 bytes of filler.
5751        buf.extend_from_slice(b"JUNK");
5752        buf.extend_from_slice(&12u32.to_le_bytes());
5753        buf.extend(std::iter::repeat_n(0xAAu8, 12));
5754        // CSET: Windows-1252 / USA / US English (canonical 8-byte body).
5755        let cset = cset_body(1252, 1, 9, 1);
5756        buf.extend_from_slice(b"CSET");
5757        buf.extend_from_slice(&(cset.len() as u32).to_le_bytes());
5758        buf.extend_from_slice(&cset);
5759        // LIST INFO with INAM = "T" (1 byte + NUL terminator).
5760        let mut list_body = Vec::new();
5761        list_body.extend_from_slice(b"INFO");
5762        list_body.extend_from_slice(b"INAM");
5763        list_body.extend_from_slice(&1u32.to_le_bytes());
5764        list_body.extend_from_slice(b"T");
5765        list_body.push(0);
5766        buf.extend_from_slice(b"LIST");
5767        buf.extend_from_slice(&(list_body.len() as u32).to_le_bytes());
5768        buf.extend_from_slice(&list_body);
5769        // Second JUNK: 4 bytes of filler ahead of `data`.
5770        buf.extend_from_slice(b"JUNK");
5771        buf.extend_from_slice(&4u32.to_le_bytes());
5772        buf.extend(std::iter::repeat_n(0xBBu8, 4));
5773        // empty data chunk.
5774        buf.extend_from_slice(b"data");
5775        buf.extend_from_slice(&0u32.to_le_bytes());
5776
5777        let dmx = open_demux_from_bytes(buf);
5778        let md: std::collections::HashMap<String, String> =
5779            dmx.metadata().iter().cloned().collect();
5780        // Both JUNK chunks counted; aggregates reflect both.
5781        assert_eq!(md.get("wav:junk.count"), Some(&"2".to_string()));
5782        assert_eq!(md.get("wav:junk.total_bytes"), Some(&"16".to_string()));
5783        assert_eq!(md.get("wav:junk.0.body_len"), Some(&"12".to_string()));
5784        assert_eq!(md.get("wav:junk.1.body_len"), Some(&"4".to_string()));
5785        // Other chunks survived the interleaved JUNK chunks intact.
5786        assert_eq!(md.get("wav:cset.code_page"), Some(&"1252".to_string()));
5787        assert_eq!(md.get("title"), Some(&"T".to_string()));
5788        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5789    }
5790
5791    /// Build a minimal valid WAV file carrying the supplied top-level
5792    /// `slnt` (silence) chunks. Each `&[u8]` body is written verbatim as
5793    /// the chunk payload (canonically a 4-byte LE `dwSamples`, but the
5794    /// helper lets a test feed a short/long body to exercise the
5795    /// opaque-body path). `fmt ` is PCM-S16 mono so the demuxer accepts
5796    /// the file; an empty `data` chunk closes the chunk-walk.
5797    fn wav_with_slnt(slnt_bodies: &[&[u8]]) -> Vec<u8> {
5798        let mut buf = Vec::new();
5799        buf.extend_from_slice(b"RIFF");
5800        buf.extend_from_slice(&0u32.to_le_bytes());
5801        buf.extend_from_slice(b"WAVE");
5802        buf.extend_from_slice(b"fmt ");
5803        buf.extend_from_slice(&16u32.to_le_bytes());
5804        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
5805        buf.extend_from_slice(&1u16.to_le_bytes());
5806        buf.extend_from_slice(&8_000u32.to_le_bytes());
5807        buf.extend_from_slice(&16_000u32.to_le_bytes());
5808        buf.extend_from_slice(&2u16.to_le_bytes());
5809        buf.extend_from_slice(&16u16.to_le_bytes());
5810        for body in slnt_bodies {
5811            buf.extend_from_slice(b"slnt");
5812            buf.extend_from_slice(&(body.len() as u32).to_le_bytes());
5813            buf.extend_from_slice(body);
5814            if body.len() % 2 == 1 {
5815                buf.push(0);
5816            }
5817        }
5818        buf.extend_from_slice(b"data");
5819        buf.extend_from_slice(&0u32.to_le_bytes());
5820        buf
5821    }
5822
5823    /// A single canonical `slnt` chunk surfaces its `dwSamples` count
5824    /// under the `wav:slnt.*` accounting keys per Microsoft RIFF MCI §3
5825    /// "Wave Data". No real silence is synthesised into the decoded
5826    /// stream; the chunk-walk still locates the `data` chunk that
5827    /// follows.
5828    #[test]
5829    fn slnt_single_chunk_surfaces_sample_count() {
5830        let bytes = wav_with_slnt(&[&1_000u32.to_le_bytes()]);
5831        let dmx = open_demux_from_bytes(bytes);
5832        let md: std::collections::HashMap<String, String> =
5833            dmx.metadata().iter().cloned().collect();
5834        assert_eq!(md.get("wav:slnt.count"), Some(&"1".to_string()));
5835        assert_eq!(md.get("wav:slnt.total_samples"), Some(&"1000".to_string()));
5836        assert_eq!(md.get("wav:slnt.0.samples"), Some(&"1000".to_string()));
5837        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5838    }
5839
5840    /// Multiple top-level `slnt` chunks accumulate into the `count` /
5841    /// `total_samples` aggregates and each surfaces its own
5842    /// `wav:slnt.<n>.samples`. The §3 grammar allows the silence chunk
5843    /// to repeat (the `wavl` alternating-data form); the demuxer
5844    /// accounts for every occurrence it sees at the top level.
5845    #[test]
5846    fn slnt_multiple_chunks_accumulate() {
5847        let bytes = wav_with_slnt(&[
5848            &500u32.to_le_bytes(),
5849            &250u32.to_le_bytes(),
5850            &44_100u32.to_le_bytes(),
5851        ]);
5852        let dmx = open_demux_from_bytes(bytes);
5853        let md: std::collections::HashMap<String, String> =
5854            dmx.metadata().iter().cloned().collect();
5855        assert_eq!(md.get("wav:slnt.count"), Some(&"3".to_string()));
5856        assert_eq!(
5857            md.get("wav:slnt.total_samples"),
5858            Some(&(500u64 + 250 + 44_100).to_string())
5859        );
5860        assert_eq!(md.get("wav:slnt.0.samples"), Some(&"500".to_string()));
5861        assert_eq!(md.get("wav:slnt.1.samples"), Some(&"250".to_string()));
5862        assert_eq!(md.get("wav:slnt.2.samples"), Some(&"44100".to_string()));
5863        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5864    }
5865
5866    /// A `slnt` chunk whose `dwSamples` field is `0` is in-range (a
5867    /// zero-length silence run) and still increments the count. The
5868    /// per-chunk `samples = 0` entry distinguishes "an explicit empty
5869    /// silence run" from "no slnt chunk at all".
5870    #[test]
5871    fn slnt_zero_samples_still_counts() {
5872        let bytes = wav_with_slnt(&[&0u32.to_le_bytes()]);
5873        let dmx = open_demux_from_bytes(bytes);
5874        let md: std::collections::HashMap<String, String> =
5875            dmx.metadata().iter().cloned().collect();
5876        assert_eq!(md.get("wav:slnt.count"), Some(&"1".to_string()));
5877        assert_eq!(md.get("wav:slnt.total_samples"), Some(&"0".to_string()));
5878        assert_eq!(md.get("wav:slnt.0.samples"), Some(&"0".to_string()));
5879    }
5880
5881    /// A file with no `slnt` chunk must not synthesise any `wav:slnt.*`
5882    /// keys — absence is observable (zero keys is stronger than
5883    /// `count = 0`). Regression guard against a future refactor that
5884    /// initialises the counter unconditionally.
5885    #[test]
5886    fn slnt_absent_emits_no_keys() {
5887        let bytes = wav_with_slnt(&[]);
5888        let dmx = open_demux_from_bytes(bytes);
5889        let md: std::collections::HashMap<String, String> =
5890            dmx.metadata().iter().cloned().collect();
5891        assert!(
5892            !md.keys().any(|k| k.starts_with("wav:slnt")),
5893            "no slnt chunk → no wav:slnt.* metadata"
5894        );
5895        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5896    }
5897
5898    /// A `slnt` body shorter than the 4-byte `dwSamples` field is
5899    /// treated as opaque: the chunk is still counted (so the reservation
5900    /// is observable) but contributes nothing to `total_samples` and its
5901    /// per-chunk `samples` key is omitted. Mirrors how the other
5902    /// fixed-struct parsers treat an under-length body.
5903    #[test]
5904    fn slnt_short_body_is_opaque() {
5905        // 3-byte body (one short of the 4-byte DWORD) — odd length also
5906        // exercises the word-align pad on the way to `data`.
5907        let bytes = wav_with_slnt(&[&[0x01, 0x02, 0x03]]);
5908        let dmx = open_demux_from_bytes(bytes);
5909        let md: std::collections::HashMap<String, String> =
5910            dmx.metadata().iter().cloned().collect();
5911        assert_eq!(md.get("wav:slnt.count"), Some(&"1".to_string()));
5912        assert_eq!(md.get("wav:slnt.total_samples"), Some(&"0".to_string()));
5913        assert!(
5914            !md.contains_key("wav:slnt.0.samples"),
5915            "under-length slnt body must not surface a samples value"
5916        );
5917        // data located correctly past the implicit pad byte.
5918        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5919    }
5920
5921    /// A `slnt` body longer than the canonical 4 bytes still decodes its
5922    /// leading `dwSamples` DWORD and tolerates the trailing region for
5923    /// forward compatibility (matching the §3 forward-extension rule the
5924    /// `fact` parser follows). An odd over-length body also exercises
5925    /// the word-align pad ahead of `data`.
5926    #[test]
5927    fn slnt_long_body_decodes_leading_dword() {
5928        // 5-byte body: leading DWORD = 7, one trailing extension byte.
5929        let mut body = 7u32.to_le_bytes().to_vec();
5930        body.push(0xFF);
5931        let bytes = wav_with_slnt(&[&body]);
5932        let dmx = open_demux_from_bytes(bytes);
5933        let md: std::collections::HashMap<String, String> =
5934            dmx.metadata().iter().cloned().collect();
5935        assert_eq!(md.get("wav:slnt.count"), Some(&"1".to_string()));
5936        assert_eq!(md.get("wav:slnt.0.samples"), Some(&"7".to_string()));
5937        assert_eq!(md.get("wav:slnt.total_samples"), Some(&"7".to_string()));
5938        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5939    }
5940
5941    /// `slnt` coexists with `JUNK` and `LIST INFO` without disrupting
5942    /// the rest of the metadata surface, and the two independent
5943    /// accounting namespaces (`wav:slnt.*` vs `wav:junk.*`) don't
5944    /// collide. Regression guard for the chunk-walk ordering
5945    /// slnt → JUNK → LIST(INFO) → slnt → data.
5946    #[test]
5947    fn slnt_coexists_with_other_chunks() {
5948        let mut buf = Vec::new();
5949        buf.extend_from_slice(b"RIFF");
5950        buf.extend_from_slice(&0u32.to_le_bytes());
5951        buf.extend_from_slice(b"WAVE");
5952        buf.extend_from_slice(b"fmt ");
5953        buf.extend_from_slice(&16u32.to_le_bytes());
5954        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
5955        buf.extend_from_slice(&1u16.to_le_bytes());
5956        buf.extend_from_slice(&8_000u32.to_le_bytes());
5957        buf.extend_from_slice(&16_000u32.to_le_bytes());
5958        buf.extend_from_slice(&2u16.to_le_bytes());
5959        buf.extend_from_slice(&16u16.to_le_bytes());
5960        // First slnt: 800 silent samples.
5961        buf.extend_from_slice(b"slnt");
5962        buf.extend_from_slice(&4u32.to_le_bytes());
5963        buf.extend_from_slice(&800u32.to_le_bytes());
5964        // JUNK: 6 bytes of filler.
5965        buf.extend_from_slice(b"JUNK");
5966        buf.extend_from_slice(&6u32.to_le_bytes());
5967        buf.extend(std::iter::repeat_n(0xAAu8, 6));
5968        // LIST INFO with INAM = "T".
5969        let mut list_body = Vec::new();
5970        list_body.extend_from_slice(b"INFO");
5971        list_body.extend_from_slice(b"INAM");
5972        list_body.extend_from_slice(&1u32.to_le_bytes());
5973        list_body.extend_from_slice(b"T");
5974        list_body.push(0);
5975        buf.extend_from_slice(b"LIST");
5976        buf.extend_from_slice(&(list_body.len() as u32).to_le_bytes());
5977        buf.extend_from_slice(&list_body);
5978        // Second slnt: 200 silent samples.
5979        buf.extend_from_slice(b"slnt");
5980        buf.extend_from_slice(&4u32.to_le_bytes());
5981        buf.extend_from_slice(&200u32.to_le_bytes());
5982        // empty data chunk.
5983        buf.extend_from_slice(b"data");
5984        buf.extend_from_slice(&0u32.to_le_bytes());
5985
5986        let dmx = open_demux_from_bytes(buf);
5987        let md: std::collections::HashMap<String, String> =
5988            dmx.metadata().iter().cloned().collect();
5989        assert_eq!(md.get("wav:slnt.count"), Some(&"2".to_string()));
5990        assert_eq!(md.get("wav:slnt.total_samples"), Some(&"1000".to_string()));
5991        assert_eq!(md.get("wav:slnt.0.samples"), Some(&"800".to_string()));
5992        assert_eq!(md.get("wav:slnt.1.samples"), Some(&"200".to_string()));
5993        // JUNK + INFO survived the interleaved slnt chunks intact.
5994        assert_eq!(md.get("wav:junk.count"), Some(&"1".to_string()));
5995        assert_eq!(md.get("title"), Some(&"T".to_string()));
5996        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
5997    }
5998
5999    /// A `wavl`-form sub-chunk descriptor for the test builder: a 4-byte
6000    /// FOURCC (`data` / `slnt`) plus a verbatim body. `data` bodies carry
6001    /// PCM; `slnt` bodies carry the 4-byte `dwSamples` count.
6002    enum WavlSeg<'a> {
6003        Data(&'a [u8]),
6004        Slnt(u32),
6005    }
6006
6007    /// Build a minimal valid WAV whose waveform is stored as a
6008    /// `LIST('wavl' ...)` wave-list (Microsoft RIFF MCI §3 "Storage of
6009    /// WAVE Data") instead of a top-level `data` chunk. `fmt ` is
6010    /// PCM-S16 mono; a `fact` chunk carries the authoritative total
6011    /// sample count (the spec requires `fact` whenever the data lives in
6012    /// a `wavl` LIST). No top-level `data` chunk is emitted.
6013    fn wav_with_wavl(segs: &[WavlSeg], fact_samples: u32) -> Vec<u8> {
6014        // Build the wavl LIST body first so we can size the LIST chunk.
6015        let mut wavl = Vec::new();
6016        wavl.extend_from_slice(b"wavl");
6017        for seg in segs {
6018            match seg {
6019                WavlSeg::Data(body) => {
6020                    wavl.extend_from_slice(b"data");
6021                    wavl.extend_from_slice(&(body.len() as u32).to_le_bytes());
6022                    wavl.extend_from_slice(body);
6023                    if body.len() % 2 == 1 {
6024                        wavl.push(0);
6025                    }
6026                }
6027                WavlSeg::Slnt(samples) => {
6028                    wavl.extend_from_slice(b"slnt");
6029                    wavl.extend_from_slice(&4u32.to_le_bytes());
6030                    wavl.extend_from_slice(&samples.to_le_bytes());
6031                }
6032            }
6033        }
6034
6035        let mut buf = Vec::new();
6036        buf.extend_from_slice(b"RIFF");
6037        buf.extend_from_slice(&0u32.to_le_bytes());
6038        buf.extend_from_slice(b"WAVE");
6039        buf.extend_from_slice(b"fmt ");
6040        buf.extend_from_slice(&16u32.to_le_bytes());
6041        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
6042        buf.extend_from_slice(&1u16.to_le_bytes());
6043        buf.extend_from_slice(&8_000u32.to_le_bytes());
6044        buf.extend_from_slice(&16_000u32.to_le_bytes());
6045        buf.extend_from_slice(&2u16.to_le_bytes());
6046        buf.extend_from_slice(&16u16.to_le_bytes());
6047        // fact chunk — required for wavl-form data.
6048        buf.extend_from_slice(b"fact");
6049        buf.extend_from_slice(&4u32.to_le_bytes());
6050        buf.extend_from_slice(&fact_samples.to_le_bytes());
6051        // The wavl LIST itself.
6052        buf.extend_from_slice(b"LIST");
6053        buf.extend_from_slice(&(wavl.len() as u32).to_le_bytes());
6054        buf.extend_from_slice(&wavl);
6055        buf
6056    }
6057
6058    /// A single-`data`-segment `wavl` LIST is decodable: the demuxer
6059    /// anchors the cursor at the embedded `data` payload and yields it
6060    /// byte-for-byte, exactly as a top-level `data` chunk would.
6061    #[test]
6062    fn wavl_single_data_segment_decodes() {
6063        let pcm: Vec<u8> = (0..40u8).collect();
6064        let bytes = wav_with_wavl(&[WavlSeg::Data(&pcm)], 20);
6065        let mut dmx = open_demux_from_bytes(bytes);
6066        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
6067        let mut out = Vec::new();
6068        loop {
6069            match dmx.next_packet() {
6070                Ok(p) => out.extend_from_slice(&p.data),
6071                Err(Error::Eof) => break,
6072                Err(e) => panic!("demux error: {e}"),
6073            }
6074        }
6075        assert_eq!(out, pcm);
6076        let md: std::collections::HashMap<String, String> =
6077            dmx.metadata().iter().cloned().collect();
6078        assert_eq!(md.get("wav:wavl.segment_count"), Some(&"1".to_string()));
6079        assert_eq!(md.get("wav:wavl.data_count"), Some(&"1".to_string()));
6080        assert_eq!(md.get("wav:wavl.data_bytes"), Some(&"40".to_string()));
6081        assert_eq!(md.get("wav:wavl.0.kind"), Some(&"data".to_string()));
6082        assert_eq!(md.get("wav:wavl.0.length"), Some(&"40".to_string()));
6083    }
6084
6085    /// A `data`/`slnt`/`data` `wavl` LIST surfaces every segment, anchors
6086    /// the decode cursor at the FIRST `data` segment, and routes the
6087    /// embedded `slnt` through the shared `wav:slnt.*` accounting so the
6088    /// silent-sample total matches a top-level-`slnt` file. The `fact`
6089    /// chunk is the authoritative duration (data_size/block_align is
6090    /// meaningless for a segmented waveform).
6091    #[test]
6092    fn wavl_interleaved_data_silence_surfaces_segments() {
6093        let a: Vec<u8> = (0..8u8).collect();
6094        let b: Vec<u8> = (100..108u8).collect();
6095        let bytes = wav_with_wavl(
6096            &[WavlSeg::Data(&a), WavlSeg::Slnt(500), WavlSeg::Data(&b)],
6097            1000,
6098        );
6099        let mut dmx = open_demux_from_bytes(bytes);
6100        // First data segment is the decode anchor.
6101        let mut out = Vec::new();
6102        loop {
6103            match dmx.next_packet() {
6104                Ok(p) => out.extend_from_slice(&p.data),
6105                Err(Error::Eof) => break,
6106                Err(e) => panic!("demux error: {e}"),
6107            }
6108        }
6109        assert_eq!(out, a);
6110        let md: std::collections::HashMap<String, String> =
6111            dmx.metadata().iter().cloned().collect();
6112        assert_eq!(md.get("wav:wavl.segment_count"), Some(&"3".to_string()));
6113        assert_eq!(md.get("wav:wavl.data_count"), Some(&"2".to_string()));
6114        assert_eq!(md.get("wav:wavl.data_bytes"), Some(&"16".to_string()));
6115        assert_eq!(md.get("wav:wavl.0.kind"), Some(&"data".to_string()));
6116        assert_eq!(md.get("wav:wavl.1.kind"), Some(&"slnt".to_string()));
6117        assert_eq!(md.get("wav:wavl.2.kind"), Some(&"data".to_string()));
6118        // Embedded slnt feeds the shared silence accounting.
6119        assert_eq!(md.get("wav:slnt.count"), Some(&"1".to_string()));
6120        assert_eq!(md.get("wav:slnt.total_samples"), Some(&"500".to_string()));
6121        assert_eq!(md.get("wav:slnt.0.samples"), Some(&"500".to_string()));
6122        // fact-derived duration: 1000 samples at 8000 Hz.
6123        let s = &dmx.streams()[0];
6124        assert_eq!(s.duration, Some(1000));
6125    }
6126
6127    /// A silence-only `wavl` LIST (no `data` segment) carries no
6128    /// decodable audio. The §3 grammar permits it; the demuxer must
6129    /// reject the file as having no waveform rather than panicking, while
6130    /// still having surfaced the segment metadata it walked.
6131    #[test]
6132    fn wavl_silence_only_has_no_waveform() {
6133        let bytes = wav_with_wavl(&[WavlSeg::Slnt(1000), WavlSeg::Slnt(2000)], 3000);
6134        use std::io::Cursor;
6135        let rs: Box<dyn ReadSeek> = Box::new(Cursor::new(bytes));
6136        match open_demuxer(rs, &oxideav_core::NullCodecResolver) {
6137            Ok(_) => panic!("silence-only wavl must be rejected as having no waveform"),
6138            Err(Error::InvalidData(_)) => {}
6139            Err(e) => panic!("expected InvalidData, got {e:?}"),
6140        }
6141    }
6142
6143    /// An odd-length `data` segment inside a `wavl` LIST forces a 1-byte
6144    /// word-align pad; a following segment must still be located. RIFF
6145    /// MCI §2 word-alignment applies to sub-chunks inside a LIST too.
6146    #[test]
6147    fn wavl_odd_data_segment_padding() {
6148        let a: Vec<u8> = (0..7u8).collect(); // odd length → 1 pad byte
6149        let b: Vec<u8> = (50..54u8).collect();
6150        let bytes = wav_with_wavl(&[WavlSeg::Data(&a), WavlSeg::Data(&b)], 100);
6151        let dmx = open_demux_from_bytes(bytes);
6152        let md: std::collections::HashMap<String, String> =
6153            dmx.metadata().iter().cloned().collect();
6154        assert_eq!(md.get("wav:wavl.segment_count"), Some(&"2".to_string()));
6155        assert_eq!(md.get("wav:wavl.0.length"), Some(&"7".to_string()));
6156        assert_eq!(md.get("wav:wavl.1.kind"), Some(&"data".to_string()));
6157        assert_eq!(md.get("wav:wavl.1.length"), Some(&"4".to_string()));
6158    }
6159
6160    /// Locate the first chunk with the given 4-byte FOURCC in a
6161    /// freshly-muxed WAV file. Helper for the muxer-side `fact`
6162    /// presence/absence assertions above — uses a naive linear scan
6163    /// rather than walking the RIFF tree because the tests only need
6164    /// "is this FOURCC anywhere" not "is it at the right depth".
6165    /// Conservative on overlap (the FOURCC could appear inside an
6166    /// `INFO` text payload) — caller chooses test inputs that avoid
6167    /// false positives.
6168    fn find_chunk(buf: &[u8], fourcc: &[u8; 4]) -> Option<usize> {
6169        // Skip the 12-byte RIFF/WAVE header.
6170        let mut i = 12usize;
6171        while i + 8 <= buf.len() {
6172            if &buf[i..i + 4] == fourcc {
6173                return Some(i);
6174            }
6175            let sz = u32::from_le_bytes([buf[i + 4], buf[i + 5], buf[i + 6], buf[i + 7]]) as usize;
6176            i += 8 + sz + (sz % 2);
6177        }
6178        None
6179    }
6180
6181    /// `fmt_guid` produces the canonical text form: first three groups
6182    /// little-endian, trailing two big-endian. This is the format
6183    /// `mmreg.h` definitions use (e.g. `KSDATAFORMAT_SUBTYPE_PCM` =
6184    /// `00000001-0000-0010-8000-00AA00389B71`).
6185    #[test]
6186    fn guid_canonical_text() {
6187        assert_eq!(fmt_guid(&GUID_PCM), "00000001-0000-0010-8000-00AA00389B71");
6188        assert_eq!(
6189            fmt_guid(&GUID_IEEE_FLOAT),
6190            "00000003-0000-0010-8000-00AA00389B71"
6191        );
6192        assert_eq!(fmt_guid(&GUID_ALAW), "00000006-0000-0010-8000-00AA00389B71");
6193        assert_eq!(
6194            fmt_guid(&GUID_MULAW),
6195            "00000007-0000-0010-8000-00AA00389B71"
6196        );
6197    }
6198
6199    /// Build a one-entry `LIST INFO` sub-chunk header + payload for
6200    /// `id` carrying the ASCII text `text` plus a single NUL terminator.
6201    /// Used by the §3 baseline-coverage tests below to feed one INFO
6202    /// sub-ID at a time through the demuxer.
6203    fn info_subchunk(id: &[u8; 4], text: &str) -> Vec<u8> {
6204        let mut out = Vec::new();
6205        out.extend_from_slice(id);
6206        // Payload = text bytes + 1 NUL terminator (the §3 ZSTR form).
6207        let payload_len = text.len() + 1;
6208        out.extend_from_slice(&(payload_len as u32).to_le_bytes());
6209        out.extend_from_slice(text.as_bytes());
6210        out.push(0);
6211        // Word-align the sub-chunk to the next even boundary, per the
6212        // RIFF padding rule (§2 "Chunks": "Padding, if present, is not
6213        // included in `ckSize`.").
6214        if payload_len % 2 == 1 {
6215            out.push(0);
6216        }
6217        out
6218    }
6219
6220    /// Build a minimal valid WAV file whose `LIST INFO` chunk carries
6221    /// the supplied per-sub-ID payloads. Empty `data` keeps the file
6222    /// short; `fmt ` is PCM-S16 mono so the demuxer accepts it.
6223    fn wav_with_info_entries(entries: &[(&[u8; 4], &str)]) -> Vec<u8> {
6224        let mut list_body = Vec::new();
6225        list_body.extend_from_slice(b"INFO");
6226        for (id, text) in entries {
6227            list_body.extend_from_slice(&info_subchunk(id, text));
6228        }
6229        let mut buf = Vec::new();
6230        buf.extend_from_slice(b"RIFF");
6231        buf.extend_from_slice(&0u32.to_le_bytes());
6232        buf.extend_from_slice(b"WAVE");
6233        buf.extend_from_slice(b"fmt ");
6234        buf.extend_from_slice(&16u32.to_le_bytes());
6235        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
6236        buf.extend_from_slice(&1u16.to_le_bytes());
6237        buf.extend_from_slice(&8_000u32.to_le_bytes());
6238        buf.extend_from_slice(&16_000u32.to_le_bytes());
6239        buf.extend_from_slice(&2u16.to_le_bytes());
6240        buf.extend_from_slice(&16u16.to_le_bytes());
6241        buf.extend_from_slice(b"LIST");
6242        buf.extend_from_slice(&(list_body.len() as u32).to_le_bytes());
6243        buf.extend_from_slice(&list_body);
6244        buf.extend_from_slice(b"data");
6245        buf.extend_from_slice(&0u32.to_le_bytes());
6246        buf
6247    }
6248
6249    /// Every Microsoft RIFF MCI §3 INFO List Chunk baseline sub-ID (the
6250    /// 23 entries on pp. 2-14 to 2-16) round-trips through
6251    /// `parse_info_list` to its conventional key name. Spec text per
6252    /// `docs/container/riff/metadata/microsoft-riffmci.pdf`.
6253    #[test]
6254    fn info_list_full_baseline_round_trip() {
6255        // One representative payload per sub-ID, mirroring the §3
6256        // example phrasing where the spec gives one.
6257        let entries: &[(&[u8; 4], &str)] = &[
6258            (b"IARL", "Library of Congress"),
6259            (b"IART", "Michaelangelo"),
6260            (b"ICMS", "Pope Julian II"),
6261            (b"ICMT", "General comment."),
6262            (b"ICOP", "Copyright Encyclopedia International 1991"),
6263            (b"ICRD", "1553-05-03"),
6264            (b"ICRP", "lower right corner"),
6265            (b"IDIM", "8.5 in h, 11 in w"),
6266            (b"IDPI", "300"),
6267            (b"IENG", "Smith, John; Adams, Joe"),
6268            (b"IGNR", "landscape"),
6269            (b"IKEY", "Seattle; aerial view; scenery"),
6270            (b"ILGT", "+10"),
6271            (b"IMED", "lithograph"),
6272            (b"INAM", "Seattle From Above"),
6273            (b"IPLT", "256"),
6274            (b"IPRD", "Encyclopedia of Pacific Northwest Geography"),
6275            (b"ISBJ", "Aerial view of Seattle"),
6276            (b"ISFT", "Microsoft WaveEdit"),
6277            (b"ISHP", "+5"),
6278            (b"ISRC", "Trey Research"),
6279            (b"ISRF", "slide"),
6280            (b"ITCH", "Smith, John"),
6281        ];
6282
6283        let bytes = wav_with_info_entries(entries);
6284        let dmx = open_demux_from_bytes(bytes);
6285        let md: std::collections::HashMap<String, String> =
6286            dmx.metadata().iter().cloned().collect();
6287
6288        // The expected key for each baseline sub-ID. Order matches
6289        // `entries` so a failure points at the offending FOURCC.
6290        let expected: &[(&str, &str)] = &[
6291            ("archival_location", "Library of Congress"),
6292            ("artist", "Michaelangelo"),
6293            ("commissioned", "Pope Julian II"),
6294            ("comment", "General comment."),
6295            ("copyright", "Copyright Encyclopedia International 1991"),
6296            ("date", "1553-05-03"),
6297            ("cropped", "lower right corner"),
6298            ("dimensions", "8.5 in h, 11 in w"),
6299            ("dpi", "300"),
6300            ("engineer", "Smith, John; Adams, Joe"),
6301            ("genre", "landscape"),
6302            ("keywords", "Seattle; aerial view; scenery"),
6303            ("lightness", "+10"),
6304            ("medium", "lithograph"),
6305            ("title", "Seattle From Above"),
6306            ("palette_setting", "256"),
6307            ("album", "Encyclopedia of Pacific Northwest Geography"),
6308            ("subject", "Aerial view of Seattle"),
6309            ("encoder", "Microsoft WaveEdit"),
6310            ("sharpness", "+5"),
6311            ("source", "Trey Research"),
6312            ("source_form", "slide"),
6313            ("technician", "Smith, John"),
6314        ];
6315        for (key, value) in expected {
6316            assert_eq!(
6317                md.get(*key),
6318                Some(&(*value).to_string()),
6319                "INFO key {key:?} missing or wrong; got {:?}",
6320                md.get(*key)
6321            );
6322        }
6323    }
6324
6325    /// Each pre-r221 INFO sub-ID still maps to the same conventional
6326    /// key — the §3 baseline expansion does not perturb the four
6327    /// widely-quoted audio-tag aliases (`title`, `artist`, `album`,
6328    /// `comment`, `genre`, `copyright`, `engineer`, `technician`,
6329    /// `encoder`, `subject`, `date`) or the `track` extension.
6330    #[test]
6331    fn info_id_to_key_legacy_aliases_preserved() {
6332        assert_eq!(info_id_to_key(b"INAM"), Some("title"));
6333        assert_eq!(info_id_to_key(b"IART"), Some("artist"));
6334        assert_eq!(info_id_to_key(b"IPRD"), Some("album"));
6335        assert_eq!(info_id_to_key(b"ICMT"), Some("comment"));
6336        assert_eq!(info_id_to_key(b"ICRD"), Some("date"));
6337        assert_eq!(info_id_to_key(b"IGNR"), Some("genre"));
6338        assert_eq!(info_id_to_key(b"ICOP"), Some("copyright"));
6339        assert_eq!(info_id_to_key(b"IENG"), Some("engineer"));
6340        assert_eq!(info_id_to_key(b"ITCH"), Some("technician"));
6341        assert_eq!(info_id_to_key(b"ISFT"), Some("encoder"));
6342        assert_eq!(info_id_to_key(b"ISBJ"), Some("subject"));
6343        assert_eq!(info_id_to_key(b"ITRK"), Some("track"));
6344    }
6345
6346    /// Every §3 baseline sub-ID resolves to `Some` and every unknown
6347    /// FOURCC resolves to `None`. The `Some` branch is sufficient for
6348    /// the FOURCC-typo regression guard the round-trip test does not
6349    /// catch (a misspelt `b"IRAL"` for `IARL` would silently lose
6350    /// data otherwise).
6351    #[test]
6352    fn info_id_to_key_baseline_completeness() {
6353        const BASELINE: &[&[u8; 4]] = &[
6354            b"IARL", b"IART", b"ICMS", b"ICMT", b"ICOP", b"ICRD", b"ICRP", b"IDIM", b"IDPI",
6355            b"IENG", b"IGNR", b"IKEY", b"ILGT", b"IMED", b"INAM", b"IPLT", b"IPRD", b"ISBJ",
6356            b"ISFT", b"ISHP", b"ISRC", b"ISRF", b"ITCH",
6357        ];
6358        for id in BASELINE {
6359            assert!(
6360                info_id_to_key(id).is_some(),
6361                "baseline INFO sub-ID {:?} missing from info_id_to_key",
6362                std::str::from_utf8(*id).unwrap()
6363            );
6364        }
6365        // Negative: a definitely-unregistered FOURCC.
6366        assert_eq!(info_id_to_key(b"ZZZZ"), None);
6367        // Negative: easy typo of IARL.
6368        assert_eq!(info_id_to_key(b"IRAL"), None);
6369    }
6370
6371    /// A `LIST INFO` chunk that contains an unknown sub-ID alongside
6372    /// known ones — the unknown sub-ID is skipped without disturbing
6373    /// the known ones' surfacing. Regression guard for the
6374    /// "skip-unknown" branch in `parse_info_list`.
6375    #[test]
6376    fn info_list_unknown_subchunk_is_skipped() {
6377        let entries: &[(&[u8; 4], &str)] = &[
6378            (b"INAM", "Title"),
6379            // `IZZZ` is not in the §3 baseline; the parser drops its
6380            // bytes silently.
6381            (b"IZZZ", "ignored"),
6382            (b"IART", "Artist"),
6383        ];
6384        let bytes = wav_with_info_entries(entries);
6385        let dmx = open_demux_from_bytes(bytes);
6386        let md: std::collections::HashMap<String, String> =
6387            dmx.metadata().iter().cloned().collect();
6388        assert_eq!(md.get("title"), Some(&"Title".to_string()));
6389        assert_eq!(md.get("artist"), Some(&"Artist".to_string()));
6390        // No synthetic key created for the unknown FOURCC.
6391        assert!(md
6392            .keys()
6393            .all(|k| !k.contains("IZZZ") && !k.contains("izzz")));
6394    }
6395
6396    // -------- RF64 / BW64 (EBU Tech 3306 / ITU-R BS.2088) ---------------
6397
6398    /// Build a synthetic RF64-or-BW64 WAV from in-memory parts:
6399    /// `magic` is `b"RF64"` or `b"BW64"`, the legacy 32-bit RIFF /
6400    /// data / fact size fields are forced to the `0xFFFFFFFF`
6401    /// sentinel per EBU Tech 3306 §3, and `ds64` carries the
6402    /// authoritative 64-bit sizes ahead of `fmt `/`fact`/`data`.
6403    /// `extra_table` is the optional table of per-chunk-ID 64-bit
6404    /// overrides surfaced after the three mandatory ds64 fields.
6405    #[allow(clippy::too_many_arguments)]
6406    fn synth_rf64(
6407        magic: &[u8; 4],
6408        ds64_data_size: u64,
6409        ds64_sample_count: u64,
6410        extra_table: &[([u8; 4], u64)],
6411        data_payload: &[u8],
6412        channels: u16,
6413        sample_rate: u32,
6414        bits_per_sample: u16,
6415    ) -> Vec<u8> {
6416        // Build `ds64` body first so we can compute its size.
6417        let mut ds64_body: Vec<u8> = Vec::new();
6418        // riffSize will be back-patched once we know the full file
6419        // size. We push a placeholder here.
6420        ds64_body.extend_from_slice(&0u64.to_le_bytes());
6421        ds64_body.extend_from_slice(&ds64_data_size.to_le_bytes());
6422        ds64_body.extend_from_slice(&ds64_sample_count.to_le_bytes());
6423        ds64_body.extend_from_slice(&(extra_table.len() as u32).to_le_bytes());
6424        for (id, sz) in extra_table {
6425            ds64_body.extend_from_slice(id);
6426            ds64_body.extend_from_slice(&sz.to_le_bytes());
6427        }
6428
6429        // `fmt ` chunk: legacy 16-byte WAVEFORMAT for PCM (the spec's
6430        // standard ds64 example targets PCM streams).
6431        let block_align = (bits_per_sample / 8) * channels;
6432        let byte_rate = sample_rate * block_align as u32;
6433        let mut fmt_body: Vec<u8> = Vec::new();
6434        fmt_body.extend_from_slice(&WAVE_FORMAT_PCM.to_le_bytes());
6435        fmt_body.extend_from_slice(&channels.to_le_bytes());
6436        fmt_body.extend_from_slice(&sample_rate.to_le_bytes());
6437        fmt_body.extend_from_slice(&byte_rate.to_le_bytes());
6438        fmt_body.extend_from_slice(&block_align.to_le_bytes());
6439        fmt_body.extend_from_slice(&bits_per_sample.to_le_bytes());
6440
6441        // `fact` chunk: legacy 4-byte dwFileSize forced to the
6442        // sentinel so the demuxer must consult ds64.
6443        let fact_body = SIZE64_SENTINEL.to_le_bytes();
6444
6445        // Assemble file: magic + sentinel + "WAVE" + ds64 + fmt +
6446        // fact + data + payload.
6447        let mut out: Vec<u8> = Vec::new();
6448        out.extend_from_slice(magic);
6449        out.extend_from_slice(&SIZE64_SENTINEL.to_le_bytes());
6450        out.extend_from_slice(b"WAVE");
6451
6452        out.extend_from_slice(b"ds64");
6453        out.extend_from_slice(&(ds64_body.len() as u32).to_le_bytes());
6454        let ds64_riff_field_off = out.len();
6455        out.extend_from_slice(&ds64_body);
6456
6457        out.extend_from_slice(b"fmt ");
6458        out.extend_from_slice(&(fmt_body.len() as u32).to_le_bytes());
6459        out.extend_from_slice(&fmt_body);
6460
6461        out.extend_from_slice(b"fact");
6462        out.extend_from_slice(&(fact_body.len() as u32).to_le_bytes());
6463        out.extend_from_slice(&fact_body);
6464
6465        out.extend_from_slice(b"data");
6466        out.extend_from_slice(&SIZE64_SENTINEL.to_le_bytes());
6467        out.extend_from_slice(data_payload);
6468
6469        // Back-patch the placeholder ds64.riffSize field with the
6470        // real total minus 8 (matching the legacy 32-bit field's
6471        // semantics — file size minus the 8-byte form header).
6472        let riff_size_64 = (out.len() as u64) - 8;
6473        out[ds64_riff_field_off..ds64_riff_field_off + 8]
6474            .copy_from_slice(&riff_size_64.to_le_bytes());
6475        out
6476    }
6477
6478    /// RF64 PCM S16 mono file with `ds64` carrying the 64-bit
6479    /// data-size and sample-count overrides. Demuxer must read the
6480    /// payload through the ds64 path, surface
6481    /// `wav:rf64.magic = "RF64"` plus the three 64-bit fields, and
6482    /// report the correct per-channel sample count.
6483    #[test]
6484    fn rf64_demux_pcm_s16_with_ds64_overrides() {
6485        // 100 samples × 2 bytes = 200 bytes of payload. Small but
6486        // exercises every ds64-promotion branch.
6487        let payload: Vec<u8> = (0..200u8).collect();
6488        let bytes = synth_rf64(b"RF64", 200, 100, &[], &payload, 1, 48_000, 16);
6489
6490        let dmx = open_demux_from_bytes(bytes);
6491        let md: std::collections::HashMap<String, String> =
6492            dmx.metadata().iter().cloned().collect();
6493
6494        assert_eq!(md.get("wav:rf64.magic"), Some(&"RF64".to_string()));
6495        assert_eq!(md.get("wav:rf64.data_size"), Some(&"200".to_string()));
6496        assert_eq!(md.get("wav:rf64.sample_count"), Some(&"100".to_string()));
6497        assert_eq!(md.get("wav:rf64.table.count"), Some(&"0".to_string()));
6498
6499        // The duration was driven from the ds64-promoted fact field,
6500        // not the 4-byte legacy value (which was the sentinel).
6501        let stream = &dmx.streams()[0];
6502        assert_eq!(stream.duration, Some(100));
6503        assert_eq!(stream.params.codec_id, CodecId::new("pcm_s16le"));
6504    }
6505
6506    /// BW64 magic (instead of RF64) is the ADM-carrying form per
6507    /// ITU-R BS.2088 — same ds64 layout, different top-level FOURCC.
6508    /// The demuxer must accept it identically.
6509    #[test]
6510    fn bw64_demux_pcm_s16_treated_as_rf64() {
6511        let payload: Vec<u8> = (0..40u8).collect();
6512        let bytes = synth_rf64(b"BW64", 40, 20, &[], &payload, 1, 48_000, 16);
6513
6514        let dmx = open_demux_from_bytes(bytes);
6515        let md: std::collections::HashMap<String, String> =
6516            dmx.metadata().iter().cloned().collect();
6517        assert_eq!(md.get("wav:rf64.magic"), Some(&"BW64".to_string()));
6518        assert_eq!(md.get("wav:rf64.data_size"), Some(&"40".to_string()));
6519        assert_eq!(md.get("wav:rf64.sample_count"), Some(&"20".to_string()));
6520        assert_eq!(dmx.streams()[0].duration, Some(20));
6521    }
6522
6523    /// A non-`data` chunk that carries the 32-bit sentinel must be
6524    /// resolved via the `ds64.table` lookup. Build a file with a
6525    /// `LIST INFO` chunk whose on-wire size is the sentinel and
6526    /// whose real size is recorded in the table.
6527    #[test]
6528    fn rf64_table_promotes_non_data_chunk_size() {
6529        // Construct a `LIST INFO INAM "Hello"` chunk body manually.
6530        let mut list_body: Vec<u8> = Vec::new();
6531        list_body.extend_from_slice(b"INFO");
6532        list_body.extend_from_slice(b"INAM");
6533        list_body.extend_from_slice(&6u32.to_le_bytes()); // ZSTR "Hello\0"
6534        list_body.extend_from_slice(b"Hello\0");
6535        let list_body_len = list_body.len() as u64;
6536
6537        // Build a file with: magic + sentinel + WAVE + ds64(table =
6538        // [(LIST, list_body_len)]) + LIST (with sentinel size) +
6539        // fmt + data.
6540        let mut ds64_body: Vec<u8> = Vec::new();
6541        ds64_body.extend_from_slice(&0u64.to_le_bytes()); // riffSize placeholder
6542        ds64_body.extend_from_slice(&8u64.to_le_bytes()); // dataSize = 8
6543        ds64_body.extend_from_slice(&4u64.to_le_bytes()); // sampleCount = 4
6544        ds64_body.extend_from_slice(&1u32.to_le_bytes()); // tableLength
6545        ds64_body.extend_from_slice(b"LIST");
6546        ds64_body.extend_from_slice(&list_body_len.to_le_bytes());
6547
6548        let payload: Vec<u8> = (0..8u8).collect();
6549        let fmt_body = {
6550            let mut f = Vec::new();
6551            f.extend_from_slice(&WAVE_FORMAT_PCM.to_le_bytes());
6552            f.extend_from_slice(&1u16.to_le_bytes()); // mono
6553            f.extend_from_slice(&48_000u32.to_le_bytes());
6554            f.extend_from_slice(&(48_000u32 * 2).to_le_bytes());
6555            f.extend_from_slice(&2u16.to_le_bytes()); // block_align
6556            f.extend_from_slice(&16u16.to_le_bytes());
6557            f
6558        };
6559
6560        let mut out: Vec<u8> = Vec::new();
6561        out.extend_from_slice(b"RF64");
6562        out.extend_from_slice(&SIZE64_SENTINEL.to_le_bytes());
6563        out.extend_from_slice(b"WAVE");
6564        out.extend_from_slice(b"ds64");
6565        out.extend_from_slice(&(ds64_body.len() as u32).to_le_bytes());
6566        let ds64_riff_off = out.len();
6567        out.extend_from_slice(&ds64_body);
6568        out.extend_from_slice(b"fmt ");
6569        out.extend_from_slice(&(fmt_body.len() as u32).to_le_bytes());
6570        out.extend_from_slice(&fmt_body);
6571        // LIST with the sentinel — real size comes from ds64.table.
6572        out.extend_from_slice(b"LIST");
6573        out.extend_from_slice(&SIZE64_SENTINEL.to_le_bytes());
6574        out.extend_from_slice(&list_body);
6575        out.extend_from_slice(b"data");
6576        out.extend_from_slice(&SIZE64_SENTINEL.to_le_bytes());
6577        out.extend_from_slice(&payload);
6578        let riff_size_64 = (out.len() as u64) - 8;
6579        out[ds64_riff_off..ds64_riff_off + 8].copy_from_slice(&riff_size_64.to_le_bytes());
6580
6581        let dmx = open_demux_from_bytes(out);
6582        let md: std::collections::HashMap<String, String> =
6583            dmx.metadata().iter().cloned().collect();
6584
6585        // The ds64 table fields surfaced.
6586        assert_eq!(md.get("wav:rf64.table.count"), Some(&"1".to_string()));
6587        assert_eq!(md.get("wav:rf64.table.0.id"), Some(&"LIST".to_string()));
6588        assert_eq!(
6589            md.get("wav:rf64.table.0.size"),
6590            Some(&list_body_len.to_string())
6591        );
6592        // And the LIST INFO sub-chunk inside it was still parsed,
6593        // proving the sentinel resolution worked and the chunk-walk
6594        // skipped the right number of bytes to find `data`.
6595        assert_eq!(md.get("title"), Some(&"Hello".to_string()));
6596    }
6597
6598    /// A plain `RIFF` file with a `data` chunk that carries the
6599    /// `0xFFFFFFFF` sentinel but no `ds64` chunk is malformed under
6600    /// EBU Tech 3306 §3 — the sentinel demands a ds64 override. The
6601    /// demuxer must reject this rather than silently misreading the
6602    /// data size as 4 GiB.
6603    #[test]
6604    fn rf64_sentinel_without_ds64_is_rejected() {
6605        // Minimal `RIFF`/WAVE file with the sentinel in the data
6606        // size and no ds64 chunk.
6607        let fmt_body = {
6608            let mut f = Vec::new();
6609            f.extend_from_slice(&WAVE_FORMAT_PCM.to_le_bytes());
6610            f.extend_from_slice(&1u16.to_le_bytes());
6611            f.extend_from_slice(&48_000u32.to_le_bytes());
6612            f.extend_from_slice(&(48_000u32 * 2).to_le_bytes());
6613            f.extend_from_slice(&2u16.to_le_bytes());
6614            f.extend_from_slice(&16u16.to_le_bytes());
6615            f
6616        };
6617        let mut out: Vec<u8> = Vec::new();
6618        out.extend_from_slice(b"RIFF");
6619        out.extend_from_slice(&0u32.to_le_bytes());
6620        out.extend_from_slice(b"WAVE");
6621        out.extend_from_slice(b"fmt ");
6622        out.extend_from_slice(&(fmt_body.len() as u32).to_le_bytes());
6623        out.extend_from_slice(&fmt_body);
6624        out.extend_from_slice(b"data");
6625        out.extend_from_slice(&SIZE64_SENTINEL.to_le_bytes());
6626
6627        use std::io::Cursor;
6628        let rs: Box<dyn ReadSeek> = Box::new(Cursor::new(out));
6629        let res = open_demuxer(rs, &oxideav_core::NullCodecResolver);
6630        assert!(res.is_err(), "sentinel without ds64 must be rejected");
6631    }
6632
6633    /// A ds64 chunk body shorter than the 28-byte mandatory prefix
6634    /// is malformed under EBU Tech 3306 Annex A.2 — the three
6635    /// `int64` fields plus the `tableLength` `int32` add up to
6636    /// exactly 28 bytes regardless of whether the table is empty.
6637    #[test]
6638    fn rf64_ds64_short_body_is_rejected() {
6639        let mut out: Vec<u8> = Vec::new();
6640        out.extend_from_slice(b"RF64");
6641        out.extend_from_slice(&SIZE64_SENTINEL.to_le_bytes());
6642        out.extend_from_slice(b"WAVE");
6643        out.extend_from_slice(b"ds64");
6644        out.extend_from_slice(&8u32.to_le_bytes()); // way too short
6645        out.extend_from_slice(&[0u8; 8]);
6646
6647        use std::io::Cursor;
6648        let rs: Box<dyn ReadSeek> = Box::new(Cursor::new(out));
6649        let res = open_demuxer(rs, &oxideav_core::NullCodecResolver);
6650        assert!(res.is_err(), "ds64 < 28 bytes must be rejected");
6651    }
6652}