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.subformat` (matching the round-75 `oxideav-avi` shape, but
34//! single-stream so no per-stream index).
35
36use oxideav_core::{
37    CodecId, CodecParameters, CodecResolver, Error, MediaType, Packet, Result, SampleFormat,
38    StreamInfo, TimeBase,
39};
40use oxideav_core::{ContainerRegistry, Demuxer, Muxer, ReadSeek, WriteSeek};
41use std::io::{Read, Seek, SeekFrom, Write};
42
43pub fn register(reg: &mut ContainerRegistry) {
44    reg.register_demuxer("wav", open_demuxer);
45    reg.register_muxer("wav", open_muxer);
46    reg.register_extension("wav", "wav");
47    reg.register_extension("wave", "wav");
48    reg.register_probe("wav", probe);
49}
50
51/// `RIFF....WAVE` — unambiguous when present.
52fn probe(p: &oxideav_core::ProbeData) -> u8 {
53    if p.buf.len() < 12 {
54        return 0;
55    }
56    if &p.buf[0..4] == b"RIFF" && &p.buf[8..12] == b"WAVE" {
57        100
58    } else {
59        0
60    }
61}
62
63// On-the-wire `wFormatTag` constants from RFC 2361 / `mmreg.h`. Public so
64// muxer callers can build `WAVE_FORMAT_EXTENSIBLE` streams against the
65// same dispatch table the demuxer uses.
66/// `WAVE_FORMAT_PCM` — integer linear PCM (`mmreg.h`).
67pub const WAVE_FORMAT_PCM: u16 = 0x0001;
68/// `WAVE_FORMAT_IEEE_FLOAT` — 32-bit / 64-bit IEEE 754 float PCM.
69pub const WAVE_FORMAT_IEEE_FLOAT: u16 = 0x0003;
70/// `WAVE_FORMAT_ALAW` — ITU-T G.711 A-law (RFC 2361 A.7).
71pub const WAVE_FORMAT_ALAW: u16 = 0x0006;
72/// `WAVE_FORMAT_MULAW` — ITU-T G.711 μ-law (RFC 2361 A.8).
73pub const WAVE_FORMAT_MULAW: u16 = 0x0007;
74/// `WAVE_FORMAT_EXTENSIBLE` — escape hatch with 22-byte extension
75/// carrying `wValidBitsPerSample` / `dwChannelMask` / `SubFormat` GUID
76/// (per docs/container/riff/waveformatextensible/README.md).
77pub const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
78
79// Internal aliases kept for readability of the local match arms below.
80const FMT_PCM: u16 = WAVE_FORMAT_PCM;
81const FMT_IEEE_FLOAT: u16 = WAVE_FORMAT_IEEE_FLOAT;
82const FMT_ALAW: u16 = WAVE_FORMAT_ALAW;
83const FMT_MULAW: u16 = WAVE_FORMAT_MULAW;
84const FMT_EXTENSIBLE: u16 = WAVE_FORMAT_EXTENSIBLE;
85
86// `KSDATAFORMAT_SUBTYPE_*` GUIDs (`KSMedia.h`). All follow the same
87// `<tag>-0000-0010-8000-00AA00389B71` "DataFormat" base where the
88// leading 16-bit `<tag>` is the legacy `wFormatTag` — per
89// docs/container/riff/waveformatextensible/README.md.
90const GUID_PCM: [u8; 16] = [
91    0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
92];
93const GUID_IEEE_FLOAT: [u8; 16] = [
94    0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
95];
96const GUID_ALAW: [u8; 16] = [
97    0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
98];
99const GUID_MULAW: [u8; 16] = [
100    0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
101];
102
103/// Format the 16-byte SubFormat GUID for diagnostic strings as
104/// `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` (canonical text representation
105/// used by `mmreg.h` GUID definitions). The first three groups are
106/// little-endian on the wire, the trailing two groups are big-endian.
107fn fmt_guid(g: &[u8; 16]) -> String {
108    format!(
109        "{:08X}-{:04X}-{:04X}-{:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}",
110        u32::from_le_bytes([g[0], g[1], g[2], g[3]]),
111        u16::from_le_bytes([g[4], g[5]]),
112        u16::from_le_bytes([g[6], g[7]]),
113        g[8],
114        g[9],
115        g[10],
116        g[11],
117        g[12],
118        g[13],
119        g[14],
120        g[15],
121    )
122}
123
124// --- Demuxer ---------------------------------------------------------------
125
126fn open_demuxer(
127    mut input: Box<dyn ReadSeek>,
128    _codecs: &dyn CodecResolver,
129) -> Result<Box<dyn Demuxer>> {
130    let mut hdr = [0u8; 12];
131    input.read_exact(&mut hdr)?;
132    if &hdr[0..4] != b"RIFF" || &hdr[8..12] != b"WAVE" {
133        return Err(Error::invalid("not a RIFF/WAVE file"));
134    }
135
136    // Walk chunks until we hit "data"; parse "fmt " and "LIST" along the way.
137    let mut fmt: Option<WaveFmt> = None;
138    let mut metadata: Vec<(String, String)> = Vec::new();
139    let data_offset: u64;
140    let data_size: u64;
141    loop {
142        let mut chdr = [0u8; 8];
143        input.read_exact(&mut chdr)?;
144        let id = &chdr[0..4];
145        let size = u32::from_le_bytes([chdr[4], chdr[5], chdr[6], chdr[7]]) as u64;
146        match id {
147            b"fmt " => {
148                let mut buf = vec![0u8; size as usize];
149                input.read_exact(&mut buf)?;
150                fmt = Some(parse_fmt(&buf)?);
151                if size % 2 == 1 {
152                    input.seek(SeekFrom::Current(1))?;
153                }
154            }
155            b"LIST" => {
156                let mut buf = vec![0u8; size as usize];
157                input.read_exact(&mut buf)?;
158                parse_list_chunk(&buf, &mut metadata);
159                if size % 2 == 1 {
160                    input.seek(SeekFrom::Current(1))?;
161                }
162            }
163            b"bext" => {
164                let mut buf = vec![0u8; size as usize];
165                input.read_exact(&mut buf)?;
166                parse_bext_chunk(&buf, &mut metadata);
167                if size % 2 == 1 {
168                    input.seek(SeekFrom::Current(1))?;
169                }
170            }
171            b"cue " => {
172                let mut buf = vec![0u8; size as usize];
173                input.read_exact(&mut buf)?;
174                parse_cue_chunk(&buf, &mut metadata);
175                if size % 2 == 1 {
176                    input.seek(SeekFrom::Current(1))?;
177                }
178            }
179            b"plst" => {
180                let mut buf = vec![0u8; size as usize];
181                input.read_exact(&mut buf)?;
182                parse_plst_chunk(&buf, &mut metadata);
183                if size % 2 == 1 {
184                    input.seek(SeekFrom::Current(1))?;
185                }
186            }
187            b"smpl" => {
188                let mut buf = vec![0u8; size as usize];
189                input.read_exact(&mut buf)?;
190                parse_smpl_chunk(&buf, &mut metadata);
191                if size % 2 == 1 {
192                    input.seek(SeekFrom::Current(1))?;
193                }
194            }
195            b"inst" => {
196                let mut buf = vec![0u8; size as usize];
197                input.read_exact(&mut buf)?;
198                parse_inst_chunk(&buf, &mut metadata);
199                if size % 2 == 1 {
200                    input.seek(SeekFrom::Current(1))?;
201                }
202            }
203            b"data" => {
204                data_offset = input.stream_position()?;
205                data_size = size;
206                break;
207            }
208            _ => {
209                let pad = size + (size % 2);
210                input.seek(SeekFrom::Current(pad as i64))?;
211            }
212        }
213    }
214    let fmt = fmt.ok_or_else(|| Error::invalid("WAV missing fmt chunk"))?;
215
216    let codec_id = resolve_codec(&fmt)?;
217    // Sample format hint for the decoded shape (NOT the on-wire layout
218    // — A-law/μ-law expand to S16 once decoded, mirroring the
219    // round-75 `oxideav-avi` audio_codec_sample_format helper). For
220    // synthesised `wav:guid_<...>` ids the decoded shape is unknown
221    // so we leave sample_format as None — callers can still pull
222    // channels / sample_rate / channel_mask / SubFormat to surface a
223    // useful diagnostic without us pretending to know the codec.
224    let sample_fmt = decoded_sample_format(&codec_id);
225
226    let time_base = TimeBase::new(1, fmt.sample_rate as i64);
227    let block_align = fmt.block_align.max(1) as u64;
228    let total_samples = data_size / block_align;
229    let duration_micros: i64 = if fmt.sample_rate > 0 {
230        (total_samples as i128 * 1_000_000 / fmt.sample_rate as i128) as i64
231    } else {
232        0
233    };
234
235    let mut params = CodecParameters::audio(codec_id);
236    params.tag = Some(oxideav_core::CodecTag::wave_format(fmt.format_tag));
237    params.channels = Some(fmt.channels);
238    params.sample_rate = Some(fmt.sample_rate);
239    params.sample_format = sample_fmt;
240    // bit_rate uses the on-wire bytes_per_second (== block_align *
241    // sample_rate) — for A-law/μ-law that's 8 * channels * rate, NOT
242    // the post-decode S16 rate.
243    params.bit_rate = Some(8 * block_align * (fmt.sample_rate as u64));
244
245    // Round-77 metadata: surface WAVEFORMATEXTENSIBLE side-info under
246    // the same key shape `oxideav-avi` uses (without the per-stream
247    // index — WAV is single-stream by construction). Only emitted when
248    // the on-wire wFormatTag is EXTENSIBLE and the extension parsed.
249    if fmt.format_tag == FMT_EXTENSIBLE {
250        if let Some(valid) = fmt.valid_bits_per_sample {
251            metadata.push((
252                "wav:fmt.valid_bits_per_sample".to_string(),
253                valid.to_string(),
254            ));
255        }
256        if let Some(mask) = fmt.channel_mask {
257            metadata.push(("wav:fmt.channel_mask".to_string(), format!("0x{mask:08X}")));
258        }
259        if let Some(sub) = &fmt.subformat {
260            metadata.push(("wav:fmt.subformat".to_string(), fmt_guid(sub)));
261        }
262    }
263
264    let stream = StreamInfo {
265        index: 0,
266        time_base,
267        duration: Some(total_samples as i64),
268        start_time: Some(0),
269        params,
270    };
271
272    Ok(Box::new(WavDemuxer {
273        input,
274        streams: vec![stream],
275        data_offset,
276        data_end: data_offset + data_size,
277        cursor: data_offset,
278        block_align,
279        chunk_frames: 1024,
280        samples_emitted: 0,
281        metadata,
282        duration_micros,
283        format_tag: fmt.format_tag,
284        valid_bits_per_sample: fmt.valid_bits_per_sample,
285        channel_mask: fmt.channel_mask,
286        subformat: fmt.subformat,
287    }))
288}
289
290/// Parse a RIFF LIST chunk body. Dispatches by the 4-byte list type:
291/// `INFO` maps `IART`/`INAM`/... sub-chunks to standard key names
292/// (`artist`, `title`, ...); `adtl` (Associated Data List, per
293/// `docs/container/riff/metadata/microsoft-riffmci.pdf` §3 "Associated
294/// Data Chunk") maps `labl`/`note`/`ltxt` sub-chunks to
295/// `wav:adtl.<id>.<dwName>` keys carrying the cue-point text.
296fn parse_list_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
297    if buf.len() < 4 {
298        return;
299    }
300    match &buf[0..4] {
301        b"INFO" => parse_info_list(&buf[4..], out),
302        b"adtl" => parse_adtl_list(&buf[4..], out),
303        _ => {}
304    }
305}
306
307fn parse_info_list(buf: &[u8], out: &mut Vec<(String, String)>) {
308    let mut i = 0usize;
309    while i + 8 <= buf.len() {
310        let id: [u8; 4] = [buf[i], buf[i + 1], buf[i + 2], buf[i + 3]];
311        let size = u32::from_le_bytes([buf[i + 4], buf[i + 5], buf[i + 6], buf[i + 7]]) as usize;
312        i += 8;
313        if i + size > buf.len() {
314            break;
315        }
316        let raw = &buf[i..i + size];
317        let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
318        let value = String::from_utf8_lossy(&raw[..end]).trim().to_string();
319        let key = info_id_to_key(&id);
320        if !value.is_empty() {
321            if let Some(k) = key {
322                out.push((k.to_string(), value));
323            }
324        }
325        i += size;
326        if size % 2 == 1 {
327            i += 1;
328        }
329    }
330}
331
332/// Parse a `LIST adtl` (Associated Data List) body and emit
333/// `wav:adtl.<sub-id>.<dwName>` keys carrying the text payload of each
334/// sub-chunk. Per `docs/container/riff/metadata/microsoft-riffmci.pdf`
335/// §3 "Associated Data Chunk":
336///
337/// * `labl(<dwName:DWORD> <data:ZSTR>)` — title text for cue `dwName`.
338/// * `note(<dwName:DWORD> <data:ZSTR>)` — comment text for cue `dwName`.
339/// * `ltxt(<dwName> <dwSampleLength> <dwPurpose> <wCountry> <wLanguage>
340///   <wDialect> <wCodePage> <data:BYTE>...)` — text covering a
341///   `dwSampleLength`-sample segment starting at cue `dwName`. The
342///   parser surfaces the segment length under `.ltxt.<dwName>.length`,
343///   the FOURCC purpose under `.ltxt.<dwName>.purpose`, and the text
344///   payload (trimmed at the first NUL) under `.ltxt.<dwName>.text`.
345/// * `file(...)` — embedded media file; skipped (binary, no useful
346///   metadata key without a reader for the inner format type).
347///
348/// Sub-chunks shorter than the minimum required header are skipped.
349fn parse_adtl_list(buf: &[u8], out: &mut Vec<(String, String)>) {
350    let mut i = 0usize;
351    while i + 8 <= buf.len() {
352        let id: [u8; 4] = [buf[i], buf[i + 1], buf[i + 2], buf[i + 3]];
353        let size = u32::from_le_bytes([buf[i + 4], buf[i + 5], buf[i + 6], buf[i + 7]]) as usize;
354        i += 8;
355        if i + size > buf.len() {
356            break;
357        }
358        let body = &buf[i..i + size];
359        match &id {
360            b"labl" | b"note" if body.len() >= 4 => {
361                let dw_name = u32::from_le_bytes([body[0], body[1], body[2], body[3]]);
362                let raw = &body[4..];
363                let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
364                let text = String::from_utf8_lossy(&raw[..end]).trim().to_string();
365                if !text.is_empty() {
366                    let sub = if &id == b"labl" { "labl" } else { "note" };
367                    out.push((format!("wav:adtl.{sub}.{dw_name}"), text));
368                }
369            }
370            // 4 dwName + 4 dwSampleLength + 4 dwPurpose + 2 wCountry
371            // + 2 wLanguage + 2 wDialect + 2 wCodePage = 20 bytes
372            // fixed header.
373            b"ltxt" if body.len() >= 20 => {
374                let dw_name = u32::from_le_bytes([body[0], body[1], body[2], body[3]]);
375                let dw_length = u32::from_le_bytes([body[4], body[5], body[6], body[7]]);
376                let purpose = &body[8..12];
377                out.push((
378                    format!("wav:adtl.ltxt.{dw_name}.length"),
379                    dw_length.to_string(),
380                ));
381                // Render purpose as a FOURCC when printable, hex otherwise.
382                let purpose_str = if purpose.iter().all(|&b| (0x20..=0x7E).contains(&b)) {
383                    String::from_utf8_lossy(purpose).to_string()
384                } else {
385                    format!(
386                        "0x{:02X}{:02X}{:02X}{:02X}",
387                        purpose[0], purpose[1], purpose[2], purpose[3]
388                    )
389                };
390                out.push((format!("wav:adtl.ltxt.{dw_name}.purpose"), purpose_str));
391                let raw = &body[20..];
392                // The text payload may or may not be NUL-terminated
393                // per the spec ("<data:BYTE>..."); trim at the first
394                // NUL if present and strip surrounding whitespace.
395                let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
396                let text = String::from_utf8_lossy(&raw[..end]).trim().to_string();
397                if !text.is_empty() {
398                    out.push((format!("wav:adtl.ltxt.{dw_name}.text"), text));
399                }
400            }
401            // `file` sub-chunks carry an embedded media file; we don't
402            // surface their bytes through the string-typed metadata API.
403            // Truncated `labl`/`note`/`ltxt` (under the fixed-header
404            // minimum) are likewise skipped as opaque.
405            _ => {}
406        }
407        i += size;
408        if size % 2 == 1 {
409            i += 1;
410        }
411    }
412}
413
414/// Parse a `cue ` chunk body and emit `wav:cue.count` + per-point
415/// `wav:cue.<dwName>.position` / `.fcc_chunk` / `.chunk_start` /
416/// `.block_start` / `.sample_offset` keys. Layout per
417/// `docs/container/riff/metadata/microsoft-riffmci.pdf` §3 "Cue-Points
418/// Chunk":
419///
420/// ```text
421/// <cue-ck> -> cue( <dwCuePoints:DWORD> <cue-point>... )
422/// <cue-point> -> struct {
423///     DWORD dwName;        // unique id, referenced by plst/adtl
424///     DWORD dwPosition;    // sample position in the play order
425///     FOURCC fccChunk;     // 'data' or 'slnt' (for wavl LIST forms)
426///     DWORD dwChunkStart;  // byte offset of fccChunk within wavl LIST
427///     DWORD dwBlockStart;  // byte offset of enclosing block
428///     DWORD dwSampleOffset;// sample offset within block
429/// }
430/// ```
431///
432/// A truncated chunk (count > body) is treated as opaque and skipped.
433/// Each cue-point record is 24 bytes; the function consumes as many
434/// records as the body actually carries even if `dwCuePoints` claims
435/// more (defensive vs. writers that lie about the count).
436fn parse_cue_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
437    if buf.len() < 4 {
438        return;
439    }
440    let count_claimed = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
441    let body = &buf[4..];
442    const REC_LEN: usize = 24;
443    let count_actual = (body.len() / REC_LEN) as u32;
444    let count = count_claimed.min(count_actual);
445    out.push(("wav:cue.count".to_string(), count.to_string()));
446    for i in 0..count as usize {
447        let off = i * REC_LEN;
448        let dw_name = u32::from_le_bytes([body[off], body[off + 1], body[off + 2], body[off + 3]]);
449        let dw_position =
450            u32::from_le_bytes([body[off + 4], body[off + 5], body[off + 6], body[off + 7]]);
451        let fcc_chunk = &body[off + 8..off + 12];
452        let dw_chunk_start = u32::from_le_bytes([
453            body[off + 12],
454            body[off + 13],
455            body[off + 14],
456            body[off + 15],
457        ]);
458        let dw_block_start = u32::from_le_bytes([
459            body[off + 16],
460            body[off + 17],
461            body[off + 18],
462            body[off + 19],
463        ]);
464        let dw_sample_offset = u32::from_le_bytes([
465            body[off + 20],
466            body[off + 21],
467            body[off + 22],
468            body[off + 23],
469        ]);
470        let fcc_str = if fcc_chunk.iter().all(|&b| (0x20..=0x7E).contains(&b)) {
471            String::from_utf8_lossy(fcc_chunk).to_string()
472        } else {
473            format!(
474                "0x{:02X}{:02X}{:02X}{:02X}",
475                fcc_chunk[0], fcc_chunk[1], fcc_chunk[2], fcc_chunk[3]
476            )
477        };
478        out.push((
479            format!("wav:cue.{dw_name}.position"),
480            dw_position.to_string(),
481        ));
482        out.push((format!("wav:cue.{dw_name}.fcc_chunk"), fcc_str));
483        out.push((
484            format!("wav:cue.{dw_name}.chunk_start"),
485            dw_chunk_start.to_string(),
486        ));
487        out.push((
488            format!("wav:cue.{dw_name}.block_start"),
489            dw_block_start.to_string(),
490        ));
491        out.push((
492            format!("wav:cue.{dw_name}.sample_offset"),
493            dw_sample_offset.to_string(),
494        ));
495    }
496}
497
498/// Parse a `plst` (Playlist) chunk body and emit `wav:plst.count` plus
499/// per-segment `wav:plst.<n>.cue_id` / `.length` / `.loops` keys. Layout
500/// per `docs/container/riff/metadata/microsoft-riffmci.pdf` §3 "Playlist
501/// Chunk":
502///
503/// ```text
504/// <plst-ck> -> plst( <dwSegments:DWORD> <play-segment>... )
505/// <play-segment> -> struct {
506///     DWORD dwName;    // cue-point id (must match a <cue-ck> entry)
507///     DWORD dwLength;  // section length in samples
508///     DWORD dwLoops;   // play count
509/// }
510/// ```
511///
512/// The segment index `<n>` is the zero-based position in the playlist,
513/// NOT `dwName` — unlike `cue ` / `smpl`-loops, multiple playlist
514/// entries can reference the same cue point (a cue replayed twice =
515/// two segments with identical `dwName`), so keying on the cue id
516/// would collide. A `dwSegments` count exceeding what the body
517/// actually carries is clamped to the records that fit (defensive
518/// against writers that lie about the count); a body shorter than the
519/// 4-byte segment-count header is treated as opaque and skipped.
520fn parse_plst_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
521    if buf.len() < 4 {
522        return;
523    }
524    let count_claimed = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
525    let body = &buf[4..];
526    const REC_LEN: usize = 12;
527    let count_actual = (body.len() / REC_LEN) as u32;
528    let count = count_claimed.min(count_actual);
529    out.push(("wav:plst.count".to_string(), count.to_string()));
530    for i in 0..count as usize {
531        let off = i * REC_LEN;
532        let dw_name = u32::from_le_bytes([body[off], body[off + 1], body[off + 2], body[off + 3]]);
533        let dw_length =
534            u32::from_le_bytes([body[off + 4], body[off + 5], body[off + 6], body[off + 7]]);
535        let dw_loops =
536            u32::from_le_bytes([body[off + 8], body[off + 9], body[off + 10], body[off + 11]]);
537        out.push((format!("wav:plst.{i}.cue_id"), dw_name.to_string()));
538        out.push((format!("wav:plst.{i}.length"), dw_length.to_string()));
539        out.push((format!("wav:plst.{i}.loops"), dw_loops.to_string()));
540    }
541}
542
543/// Parse a `smpl` (Sampler) chunk body and emit `wav:smpl.*` metadata
544/// keys. Layout per the RIFF MCI / `mmreg.h`-era Sampler structure as
545/// catalogued in
546/// `docs/container/riff/metadata/exiftool-riff-tags.html` § "RIFF
547/// Sampler Tags" and summarised in
548/// `docs/container/riff/metadata/README.md` § "Sampler / Instrument
549/// chunks":
550///
551/// ```text
552/// <smpl-ck> -> smpl( <fixed:36 bytes> <loops:N × 24 bytes> <sampler-data> )
553/// <fixed> -> struct {
554///     DWORD dwManufacturer;
555///     DWORD dwProduct;
556///     DWORD dwSamplePeriod;       // nanoseconds per sample
557///     DWORD dwMIDIUnityNote;      // 0..=127
558///     DWORD dwMIDIPitchFraction;  // fractional offset, /0xFFFFFFFF
559///     DWORD dwSMPTEFormat;        // 0/24/25/29/30 fps
560///     DWORD dwSMPTEOffset;        // packed HH MM SS FF (MSB → LSB)
561///     DWORD cSampleLoops;
562///     DWORD cbSamplerData;        // bytes of trailing sampler-specific data
563/// }
564/// <loop> -> struct {
565///     DWORD dwCuePointID;
566///     DWORD dwType;               // 0=forward, 1=ping-pong, 2=reverse
567///     DWORD dwStart;              // start sample offset
568///     DWORD dwEnd;                // end sample offset
569///     DWORD dwFraction;           // /0xFFFFFFFF fractional sample
570///     DWORD dwPlayCount;          // 0 == infinite
571/// }
572/// ```
573///
574/// A `cSampleLoops` count exceeding what the chunk body actually
575/// carries is clamped to the records that fit (defensive against
576/// writers that lie about the count); a body shorter than the 36-byte
577/// fixed header is treated as opaque and skipped.
578fn parse_smpl_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
579    const FIXED_LEN: usize = 36;
580    const LOOP_LEN: usize = 24;
581    if buf.len() < FIXED_LEN {
582        return;
583    }
584    let r = |off: usize| -> u32 {
585        u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
586    };
587    let manufacturer = r(0);
588    let product = r(4);
589    let sample_period = r(8);
590    let midi_unity_note = r(12);
591    let midi_pitch_fraction = r(16);
592    let smpte_format = r(20);
593    let smpte_offset = r(24);
594    let num_loops_claimed = r(28);
595    let sampler_data_len = r(32);
596
597    out.push((
598        "wav:smpl.manufacturer".to_string(),
599        manufacturer.to_string(),
600    ));
601    out.push(("wav:smpl.product".to_string(), product.to_string()));
602    out.push((
603        "wav:smpl.sample_period".to_string(),
604        sample_period.to_string(),
605    ));
606    out.push((
607        "wav:smpl.midi_unity_note".to_string(),
608        midi_unity_note.to_string(),
609    ));
610    out.push((
611        "wav:smpl.midi_pitch_fraction".to_string(),
612        midi_pitch_fraction.to_string(),
613    ));
614    out.push((
615        "wav:smpl.smpte_format".to_string(),
616        smpte_format.to_string(),
617    ));
618    // SMPTE offset packs HH MM SS FF in the high-to-low bytes of the
619    // DWORD. Render the canonical HH:MM:SS:FF form alongside the raw
620    // value for callers that prefer the on-wire integer.
621    let smpte_hh = (smpte_offset >> 24) & 0xFF;
622    let smpte_mm = (smpte_offset >> 16) & 0xFF;
623    let smpte_ss = (smpte_offset >> 8) & 0xFF;
624    let smpte_ff = smpte_offset & 0xFF;
625    out.push((
626        "wav:smpl.smpte_offset".to_string(),
627        format!("{smpte_hh:02}:{smpte_mm:02}:{smpte_ss:02}:{smpte_ff:02}"),
628    ));
629    out.push((
630        "wav:smpl.sampler_data_len".to_string(),
631        sampler_data_len.to_string(),
632    ));
633
634    let body = &buf[FIXED_LEN..];
635    let num_loops_fits = (body.len() / LOOP_LEN) as u32;
636    let num_loops = num_loops_claimed.min(num_loops_fits);
637    out.push((
638        "wav:smpl.num_sample_loops".to_string(),
639        num_loops.to_string(),
640    ));
641    for i in 0..num_loops as usize {
642        let off = i * LOOP_LEN;
643        let loop_field = |word: usize| -> u32 {
644            let p = off + word * 4;
645            u32::from_le_bytes([body[p], body[p + 1], body[p + 2], body[p + 3]])
646        };
647        let cue_point_id = loop_field(0);
648        let loop_type = loop_field(1);
649        let start = loop_field(2);
650        let end = loop_field(3);
651        let fraction = loop_field(4);
652        let play_count = loop_field(5);
653        out.push((
654            format!("wav:smpl.loop.{i}.cue_point_id"),
655            cue_point_id.to_string(),
656        ));
657        out.push((format!("wav:smpl.loop.{i}.type"), loop_type.to_string()));
658        out.push((format!("wav:smpl.loop.{i}.start"), start.to_string()));
659        out.push((format!("wav:smpl.loop.{i}.end"), end.to_string()));
660        out.push((format!("wav:smpl.loop.{i}.fraction"), fraction.to_string()));
661        out.push((
662            format!("wav:smpl.loop.{i}.play_count"),
663            play_count.to_string(),
664        ));
665    }
666}
667
668/// Parse an `inst` (Instrument) chunk body and emit `wav:inst.*`
669/// metadata keys. Layout per
670/// `docs/container/riff/metadata/exiftool-riff-tags.html` § "RIFF
671/// Instrument Tags":
672///
673/// ```text
674/// <inst-ck> -> inst( <UnshiftedNote:i8> <FineTune:i8> <Gain:i8>
675///                    <LowNote:u8> <HighNote:u8>
676///                    <LowVelocity:u8> <HighVelocity:u8> )
677/// ```
678///
679/// `UnshiftedNote`, `LowNote`, `HighNote` are MIDI note numbers
680/// (0..=127); `FineTune` is cents and `Gain` is dB, both signed
681/// 8-bit. Velocity range is 1..=127 (unsigned).
682///
683/// A body shorter than the 7-byte fixed struct is treated as opaque
684/// and skipped.
685fn parse_inst_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
686    const FIXED_LEN: usize = 7;
687    if buf.len() < FIXED_LEN {
688        return;
689    }
690    let unshifted_note = buf[0];
691    let fine_tune = buf[1] as i8;
692    let gain = buf[2] as i8;
693    let low_note = buf[3];
694    let high_note = buf[4];
695    let low_velocity = buf[5];
696    let high_velocity = buf[6];
697    out.push((
698        "wav:inst.unshifted_note".to_string(),
699        unshifted_note.to_string(),
700    ));
701    out.push(("wav:inst.fine_tune".to_string(), fine_tune.to_string()));
702    out.push(("wav:inst.gain".to_string(), gain.to_string()));
703    out.push(("wav:inst.low_note".to_string(), low_note.to_string()));
704    out.push(("wav:inst.high_note".to_string(), high_note.to_string()));
705    out.push((
706        "wav:inst.low_velocity".to_string(),
707        low_velocity.to_string(),
708    ));
709    out.push((
710        "wav:inst.high_velocity".to_string(),
711        high_velocity.to_string(),
712    ));
713}
714
715fn info_id_to_key(id: &[u8; 4]) -> Option<&'static str> {
716    match id {
717        b"INAM" => Some("title"),
718        b"IART" => Some("artist"),
719        b"IPRD" => Some("album"),
720        b"ICMT" => Some("comment"),
721        b"ICRD" => Some("date"),
722        b"IGNR" => Some("genre"),
723        b"ICOP" => Some("copyright"),
724        b"IENG" => Some("engineer"),
725        b"ITCH" => Some("technician"),
726        b"ISFT" => Some("encoder"),
727        b"ISBJ" => Some("subject"),
728        b"ITRK" => Some("track"),
729        _ => None,
730    }
731}
732
733/// Fixed (pre-`CodingHistory`) size of the BWF `bext` struct, in bytes.
734///
735/// Sum of the field widths from EBU Tech 3285 v2 §2.3 `BROADCAST_EXT`:
736/// `Description[256] + Originator[32] + OriginatorReference[32] +
737/// OriginationDate[10] + OriginationTime[8] + TimeReferenceLow(4) +
738/// TimeReferenceHigh(4) + Version(2) + UMID[64] + LoudnessValue(2) +
739/// LoudnessRange(2) + MaxTruePeakLevel(2) + MaxMomentaryLoudness(2) +
740/// MaxShortTermLoudness(2) + Reserved[180]` = 602.
741const BEXT_FIXED_LEN: usize = 602;
742
743/// Trim a fixed-width ASCII field to its value: cut at the first NUL
744/// (EBU Tech 3285 v2 §2.3 mandates a NUL terminator for under-length
745/// strings) and strip surrounding whitespace.
746fn bext_field(raw: &[u8]) -> String {
747    let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
748    String::from_utf8_lossy(&raw[..end]).trim().to_string()
749}
750
751/// Parse a BWF `bext` (Broadcast Audio Extension) chunk body and append
752/// its fields to `out` under `wav:bext.*` keys.
753///
754/// Layout per `docs/container/riff/metadata/ebu-tech3285-bwf.pdf`
755/// (EBU Tech 3285 v2 §2.3, `BROADCAST_EXT` struct). All multi-byte
756/// integers are little-endian (RIFF convention). The loudness fields
757/// (`LoudnessValue` … `MaxShortTermLoudness`) are signed 16-bit values
758/// equal to `round(100 × value)` per §2.4, so they are surfaced divided
759/// by 100 with two decimal places.
760///
761/// The `Version` field selects which fields are meaningful: v0 has none
762/// of the UMID/loudness fields populated, v1 adds the SMPTE-330M UMID,
763/// v2 adds the five loudness values (§1.1). The fixed struct is always
764/// 602 bytes regardless of version, so this parser reads every field
765/// unconditionally and lets the version key tell the caller which ones
766/// to trust. `TimeReference` is reassembled as a 64-bit sample count.
767fn parse_bext_chunk(buf: &[u8], out: &mut Vec<(String, String)>) {
768    if buf.len() < BEXT_FIXED_LEN {
769        return;
770    }
771    let description = bext_field(&buf[0..256]);
772    let originator = bext_field(&buf[256..288]);
773    let originator_ref = bext_field(&buf[288..320]);
774    let origination_date = bext_field(&buf[320..330]);
775    let origination_time = bext_field(&buf[330..338]);
776    let time_ref_low = u32::from_le_bytes([buf[338], buf[339], buf[340], buf[341]]);
777    let time_ref_high = u32::from_le_bytes([buf[342], buf[343], buf[344], buf[345]]);
778    let time_reference = ((time_ref_high as u64) << 32) | (time_ref_low as u64);
779    let version = u16::from_le_bytes([buf[346], buf[347]]);
780
781    // UMID[64] at [348..412]; only meaningful for v1+. Surface it as a
782    // hex string only when non-zero so v0 files don't carry a bogus
783    // all-zero UMID key.
784    let umid = &buf[348..412];
785
786    // Loudness WORDs at [412..422]; signed, ×100. Only meaningful for v2.
787    let loudness_value = i16::from_le_bytes([buf[412], buf[413]]);
788    let loudness_range = i16::from_le_bytes([buf[414], buf[415]]);
789    let max_true_peak = i16::from_le_bytes([buf[416], buf[417]]);
790    let max_momentary = i16::from_le_bytes([buf[418], buf[419]]);
791    let max_short_term = i16::from_le_bytes([buf[420], buf[421]]);
792
793    // CodingHistory: everything past the 602-byte fixed struct.
794    let coding_history = if buf.len() > BEXT_FIXED_LEN {
795        bext_field(&buf[BEXT_FIXED_LEN..])
796    } else {
797        String::new()
798    };
799
800    let push = |out: &mut Vec<(String, String)>, key: &str, value: String| {
801        if !value.is_empty() {
802            out.push((key.to_string(), value));
803        }
804    };
805
806    push(out, "wav:bext.description", description);
807    push(out, "wav:bext.originator", originator);
808    push(out, "wav:bext.originator_reference", originator_ref);
809    push(out, "wav:bext.origination_date", origination_date);
810    push(out, "wav:bext.origination_time", origination_time);
811    out.push((
812        "wav:bext.time_reference".to_string(),
813        time_reference.to_string(),
814    ));
815    out.push(("wav:bext.version".to_string(), version.to_string()));
816
817    // v1+ : the SMPTE-330M UMID (64 bytes; a 32-byte "basic UMID"
818    // zero-pads the trailing half per §2.3). Emit only when present.
819    if umid.iter().any(|&b| b != 0) {
820        let mut hex = String::with_capacity(umid.len() * 2);
821        for b in umid {
822            hex.push_str(&format!("{b:02x}"));
823        }
824        out.push(("wav:bext.umid".to_string(), hex));
825    }
826
827    // v2 : loudness metadata (×100 fixed-point → two decimals). Emitted
828    // only for v2 files since v0/v1 leave these WORDs zero by spec.
829    if version >= 2 {
830        out.push((
831            "wav:bext.loudness_value".to_string(),
832            fmt_loudness(loudness_value),
833        ));
834        out.push((
835            "wav:bext.loudness_range".to_string(),
836            fmt_loudness(loudness_range),
837        ));
838        out.push((
839            "wav:bext.max_true_peak_level".to_string(),
840            fmt_loudness(max_true_peak),
841        ));
842        out.push((
843            "wav:bext.max_momentary_loudness".to_string(),
844            fmt_loudness(max_momentary),
845        ));
846        out.push((
847            "wav:bext.max_short_term_loudness".to_string(),
848            fmt_loudness(max_short_term),
849        ));
850    }
851
852    push(out, "wav:bext.coding_history", coding_history);
853}
854
855/// Render a BWF loudness WORD (signed 16-bit, `round(100 × value)` per
856/// EBU Tech 3285 v2 §2.4) back to its two-decimal value, e.g. `-2264`
857/// → `"-22.64"`. The sign is carried on the integer part so values in
858/// `(-1, 0)` keep their leading minus (`-50` → `"-0.50"`).
859fn fmt_loudness(v: i16) -> String {
860    let neg = v < 0;
861    let abs = (v as i32).unsigned_abs();
862    let whole = abs / 100;
863    let frac = abs % 100;
864    if neg {
865        format!("-{whole}.{frac:02}")
866    } else {
867        format!("{whole}.{frac:02}")
868    }
869}
870
871#[derive(Clone, Debug)]
872struct WaveFmt {
873    format_tag: u16,
874    channels: u16,
875    sample_rate: u32,
876    #[allow(dead_code)]
877    byte_rate: u32,
878    block_align: u16,
879    bits_per_sample: u16,
880    /// `wValidBitsPerSample` from the 22-byte EXTENSIBLE extension.
881    /// `None` for plain `WAVEFORMATEX`; `Some(0)` is a writer that left
882    /// the union zero — the demuxer falls back to `bits_per_sample`.
883    valid_bits_per_sample: Option<u16>,
884    /// `dwChannelMask` — SPEAKER_* bitmap. `None` outside EXTENSIBLE.
885    channel_mask: Option<u32>,
886    /// 16-byte SubFormat GUID. `None` outside EXTENSIBLE.
887    subformat: Option<[u8; 16]>,
888}
889
890fn parse_fmt(buf: &[u8]) -> Result<WaveFmt> {
891    if buf.len() < 16 {
892        return Err(Error::invalid("fmt chunk too small"));
893    }
894    let format_tag = u16::from_le_bytes([buf[0], buf[1]]);
895    let channels = u16::from_le_bytes([buf[2], buf[3]]);
896    let sample_rate = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
897    let byte_rate = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
898    let block_align = u16::from_le_bytes([buf[12], buf[13]]);
899    let bits_per_sample = u16::from_le_bytes([buf[14], buf[15]]);
900    let mut valid_bits_per_sample = None;
901    let mut channel_mask = None;
902    let mut subformat = None;
903    if format_tag == FMT_EXTENSIBLE {
904        // WAVEFORMATEXTENSIBLE layout per
905        // docs/container/riff/waveformatextensible/README.md
906        // §"Structure layout":
907        //   [16..18]  cbSize             (must be >= 22)
908        //   [18..20]  wValidBitsPerSample (active union member)
909        //   [20..24]  dwChannelMask
910        //   [24..40]  SubFormat GUID
911        if buf.len() < 40 {
912            return Err(Error::invalid(
913                "WAVE_FORMAT_EXTENSIBLE fmt chunk shorter than the 40 bytes \
914                 mandated by mmreg.h",
915            ));
916        }
917        let cb_size = u16::from_le_bytes([buf[16], buf[17]]);
918        if (cb_size as usize) < 22 {
919            return Err(Error::invalid(format!(
920                "WAVE_FORMAT_EXTENSIBLE cbSize must be >= 22, got {cb_size}"
921            )));
922        }
923        valid_bits_per_sample = Some(u16::from_le_bytes([buf[18], buf[19]]));
924        channel_mask = Some(u32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]));
925        let mut g = [0u8; 16];
926        g.copy_from_slice(&buf[24..40]);
927        subformat = Some(g);
928    }
929    Ok(WaveFmt {
930        format_tag,
931        channels,
932        sample_rate,
933        byte_rate,
934        block_align,
935        bits_per_sample,
936        valid_bits_per_sample,
937        channel_mask,
938        subformat,
939    })
940}
941
942fn resolve_codec(fmt: &WaveFmt) -> Result<CodecId> {
943    match fmt.format_tag {
944        FMT_PCM => Ok(CodecId::new(pcm_int_codec(fmt.bits_per_sample)?)),
945        FMT_IEEE_FLOAT => Ok(CodecId::new(pcm_float_codec(fmt.bits_per_sample)?)),
946        FMT_ALAW => Ok(CodecId::new("pcm_alaw")),
947        FMT_MULAW => Ok(CodecId::new("pcm_mulaw")),
948        FMT_EXTENSIBLE => {
949            let sub = fmt
950                .subformat
951                .ok_or_else(|| Error::invalid("extensible WAV missing subformat"))?;
952            // Per docs/container/riff/waveformatextensible/README.md
953            // §"On using these specs": the actual codec precision is the
954            // SubFormat union's wValidBitsPerSample, NOT the WAVEFORMATEX
955            // wBitsPerSample (the container size). Fall back to the
956            // container size only when the union is zero — some writers
957            // leave it unset.
958            let depth = fmt
959                .valid_bits_per_sample
960                .filter(|&v| v > 0)
961                .unwrap_or(fmt.bits_per_sample);
962            match sub {
963                GUID_PCM => Ok(CodecId::new(pcm_int_codec(depth)?)),
964                GUID_IEEE_FLOAT => Ok(CodecId::new(pcm_float_codec(depth)?)),
965                GUID_ALAW => Ok(CodecId::new("pcm_alaw")),
966                GUID_MULAW => Ok(CodecId::new("pcm_mulaw")),
967                // Unknown SubFormat — synthesise a `wav:guid_<text>` id
968                // so downstream make_decoder fails naming the actual
969                // GUID rather than the opaque 0xFFFE tag. Mirrors the
970                // `avi:guid_<...>` pattern in oxideav-avi.
971                other => Ok(CodecId::new(format!("wav:guid_{}", fmt_guid(&other)))),
972            }
973        }
974        other => Err(Error::unsupported(format!(
975            "unsupported WAV format tag 0x{:04x}",
976            other
977        ))),
978    }
979}
980
981fn pcm_int_codec(bits: u16) -> Result<&'static str> {
982    Ok(match bits {
983        8 => "pcm_u8",
984        16 => "pcm_s16le",
985        24 => "pcm_s24le",
986        32 => "pcm_s32le",
987        _ => {
988            return Err(Error::unsupported(format!(
989                "unsupported WAV integer-PCM bit depth: {bits}"
990            )));
991        }
992    })
993}
994
995fn pcm_float_codec(bits: u16) -> Result<&'static str> {
996    Ok(match bits {
997        32 => "pcm_f32le",
998        64 => "pcm_f64le",
999        _ => {
1000            return Err(Error::unsupported(format!(
1001                "unsupported WAV IEEE-float bit depth: {bits}"
1002            )));
1003        }
1004    })
1005}
1006
1007/// Decoded sample-format hint for a WAV codec id. The host runtime
1008/// applies the actual decode (A-law/μ-law through `oxideav-g711`); this
1009/// crate only resolves what the *output* of that decode looks like in
1010/// the standard `SampleFormat` enum so callers building pipelines know
1011/// the shape ahead of time.
1012///
1013/// For unknown `wav:guid_<...>` ids the function returns `None` — the
1014/// EXTENSIBLE GUID didn't match any of the well-known SubFormats and
1015/// we don't pretend to know how the codec is decoded.
1016fn decoded_sample_format(id: &CodecId) -> Option<SampleFormat> {
1017    // Plain PCM codec ids forward to the existing helper.
1018    if let Some(fmt) = super::pcm::sample_format_for(id) {
1019        return Some(fmt);
1020    }
1021    match id.as_str() {
1022        // G.711 expands to S16 once decoded (oxideav-g711 alaw/mulaw
1023        // output is S16, matching the round-75 audio_codec_sample_format
1024        // mapping in oxideav-avi).
1025        "pcm_alaw" | "pcm_mulaw" => Some(SampleFormat::S16),
1026        _ => None,
1027    }
1028}
1029
1030/// WAV demuxer.
1031///
1032/// Beyond the `Demuxer` trait, this type exposes round-77 accessors
1033/// for the `WAVEFORMATEXTENSIBLE` side-info — `format_tag`,
1034/// `valid_bits_per_sample`, `channel_mask`, `subformat`. Callers that
1035/// only have a `Box<dyn Demuxer>` should rely on the `wav:fmt.*`
1036/// metadata keys instead.
1037pub struct WavDemuxer {
1038    input: Box<dyn ReadSeek>,
1039    streams: Vec<StreamInfo>,
1040    data_offset: u64,
1041    data_end: u64,
1042    cursor: u64,
1043    block_align: u64,
1044    chunk_frames: u64,
1045    samples_emitted: i64,
1046    metadata: Vec<(String, String)>,
1047    duration_micros: i64,
1048    format_tag: u16,
1049    valid_bits_per_sample: Option<u16>,
1050    channel_mask: Option<u32>,
1051    subformat: Option<[u8; 16]>,
1052}
1053
1054impl WavDemuxer {
1055    /// On-wire `wFormatTag` from the `fmt ` chunk (one of `WAVE_FORMAT_*`).
1056    /// Preserved verbatim for round-trip purposes — the codec id
1057    /// already encodes the decoder dispatch.
1058    pub fn format_tag(&self) -> u16 {
1059        self.format_tag
1060    }
1061
1062    /// `WAVEFORMATEXTENSIBLE.Samples.wValidBitsPerSample` — actual bit
1063    /// precision per sample. `None` for legacy `WAVEFORMATEX` streams
1064    /// (non-EXTENSIBLE `wFormatTag`).
1065    pub fn valid_bits_per_sample(&self) -> Option<u16> {
1066        self.valid_bits_per_sample
1067    }
1068
1069    /// `WAVEFORMATEXTENSIBLE.dwChannelMask` — `SPEAKER_*` bitmap
1070    /// describing the channel ordering of the interleaved PCM byte
1071    /// stream. `None` for non-EXTENSIBLE streams.
1072    ///
1073    /// See `docs/container/riff/waveformatextensible/README.md`
1074    /// §"dwChannelMask bits" for the standard layouts.
1075    pub fn channel_mask(&self) -> Option<u32> {
1076        self.channel_mask
1077    }
1078
1079    /// `WAVEFORMATEXTENSIBLE.SubFormat` — 16-byte GUID (the actual
1080    /// codec identifier when `format_tag == WAVE_FORMAT_EXTENSIBLE`).
1081    /// Returned in on-wire byte order (first three groups
1082    /// little-endian, trailing two groups big-endian); use
1083    /// [`Self::subformat_text`] for the canonical text representation.
1084    pub fn subformat(&self) -> Option<&[u8; 16]> {
1085        self.subformat.as_ref()
1086    }
1087
1088    /// `WAVEFORMATEXTENSIBLE.SubFormat` formatted as a canonical
1089    /// `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` GUID string.
1090    pub fn subformat_text(&self) -> Option<String> {
1091        self.subformat.as_ref().map(fmt_guid)
1092    }
1093}
1094
1095impl Demuxer for WavDemuxer {
1096    fn format_name(&self) -> &str {
1097        "wav"
1098    }
1099
1100    fn streams(&self) -> &[StreamInfo] {
1101        &self.streams
1102    }
1103
1104    fn next_packet(&mut self) -> Result<Packet> {
1105        if self.cursor >= self.data_end {
1106            return Err(Error::Eof);
1107        }
1108        let remaining = self.data_end - self.cursor;
1109        let want_bytes = (self.chunk_frames * self.block_align).min(remaining);
1110        let want_bytes = (want_bytes / self.block_align) * self.block_align;
1111        if want_bytes == 0 {
1112            return Err(Error::Eof);
1113        }
1114
1115        // Ensure we're positioned correctly (if an upstream operation seeked us).
1116        self.input.seek(SeekFrom::Start(self.cursor))?;
1117        let mut buf = vec![0u8; want_bytes as usize];
1118        self.input.read_exact(&mut buf)?;
1119        self.cursor += want_bytes;
1120
1121        let stream = &self.streams[0];
1122        let frames = want_bytes / self.block_align;
1123        let pts = self.samples_emitted;
1124        self.samples_emitted += frames as i64;
1125
1126        let mut pkt = Packet::new(0, stream.time_base, buf);
1127        pkt.pts = Some(pts);
1128        pkt.dts = Some(pts);
1129        pkt.duration = Some(frames as i64);
1130        pkt.flags.keyframe = true;
1131        Ok(pkt)
1132    }
1133
1134    fn seek_to(&mut self, stream_index: u32, pts: i64) -> Result<i64> {
1135        if stream_index != 0 {
1136            return Err(Error::invalid(format!(
1137                "WAV: stream index {stream_index} out of range"
1138            )));
1139        }
1140        // PCM is keyframe-only and frame-aligned: the target pts is a
1141        // sample-index offset into the data chunk. Clamp to the valid
1142        // range and translate to a byte offset.
1143        let total_samples = (self.data_end - self.data_offset) / self.block_align;
1144        let target = (pts.max(0) as u64).min(total_samples);
1145        let new_cursor = self.data_offset + target * self.block_align;
1146
1147        self.input.seek(SeekFrom::Start(new_cursor))?;
1148        self.cursor = new_cursor;
1149        self.samples_emitted = target as i64;
1150        Ok(target as i64)
1151    }
1152
1153    fn metadata(&self) -> &[(String, String)] {
1154        &self.metadata
1155    }
1156
1157    fn duration_micros(&self) -> Option<i64> {
1158        if self.duration_micros > 0 {
1159            Some(self.duration_micros)
1160        } else {
1161            None
1162        }
1163    }
1164}
1165
1166// --- Muxer -----------------------------------------------------------------
1167
1168fn open_muxer(output: Box<dyn WriteSeek>, streams: &[StreamInfo]) -> Result<Box<dyn Muxer>> {
1169    if streams.len() != 1 {
1170        return Err(Error::unsupported("WAV supports exactly one audio stream"));
1171    }
1172    let s = &streams[0];
1173    if s.params.media_type != MediaType::Audio {
1174        return Err(Error::invalid("WAV stream must be audio"));
1175    }
1176    let channels = s
1177        .params
1178        .channels
1179        .ok_or_else(|| Error::invalid("WAV muxer: missing channels"))?;
1180    let sample_rate = s
1181        .params
1182        .sample_rate
1183        .ok_or_else(|| Error::invalid("WAV muxer: missing sample rate"))?;
1184    // Codec-id directs which `wFormatTag` flavour and on-wire shape the
1185    // muxer writes. A-law / μ-law take the dedicated tag-6/7 paths;
1186    // every other id falls back to the PCM/IEEE-FLOAT sample-format
1187    // lookup. Extensible muxing is opt-in via [`WavMuxOptions`] (see
1188    // [`open_muxer_with`] below) — the default path writes the
1189    // legacy 16-byte `WAVEFORMAT` for maximum compatibility.
1190    let shape = wire_shape_for_params(&s.params)?;
1191    Ok(Box::new(WavMuxer {
1192        output,
1193        channels,
1194        sample_rate,
1195        shape,
1196        extensible: None,
1197        riff_size_offset: 0,
1198        data_size_offset: 0,
1199        data_bytes: 0,
1200        header_written: false,
1201        trailer_written: false,
1202    }))
1203}
1204
1205/// Optional muxer configuration: write a `WAVE_FORMAT_EXTENSIBLE`
1206/// (`wFormatTag = 0xFFFE`) header with the supplied `dwChannelMask`,
1207/// `wValidBitsPerSample`, and SubFormat GUID. See
1208/// `docs/container/riff/waveformatextensible/README.md` §"Channel-mask"
1209/// for the standard layouts.
1210///
1211/// When `valid_bits_per_sample` is `None` the muxer reuses the
1212/// container `wBitsPerSample` (computed from the codec's
1213/// `SampleFormat`). When `subformat` is `None` the muxer picks the
1214/// well-known `KSDATAFORMAT_SUBTYPE_*` GUID for the codec id (PCM /
1215/// IEEE_FLOAT / ALAW / MULAW).
1216#[derive(Clone, Debug, Default)]
1217pub struct WavMuxOptions {
1218    extensible: Option<ExtensibleOpts>,
1219}
1220
1221#[derive(Clone, Debug)]
1222struct ExtensibleOpts {
1223    channel_mask: u32,
1224    valid_bits_per_sample: Option<u16>,
1225    subformat: Option<[u8; 16]>,
1226}
1227
1228impl WavMuxOptions {
1229    /// Opt into `WAVE_FORMAT_EXTENSIBLE` muxing with the supplied
1230    /// `dwChannelMask`. The muxer derives `wValidBitsPerSample` and
1231    /// SubFormat from the codec-id automatically; use the
1232    /// finer-grained setters to override.
1233    pub fn with_extensible(mut self, channel_mask: u32) -> Self {
1234        self.extensible = Some(ExtensibleOpts {
1235            channel_mask,
1236            valid_bits_per_sample: None,
1237            subformat: None,
1238        });
1239        self
1240    }
1241
1242    /// Override `wValidBitsPerSample` for an extensible stream. Has no
1243    /// effect unless [`Self::with_extensible`] was also called.
1244    pub fn with_valid_bits_per_sample(mut self, valid_bps: u16) -> Self {
1245        if let Some(opts) = self.extensible.as_mut() {
1246            opts.valid_bits_per_sample = Some(valid_bps);
1247        }
1248        self
1249    }
1250
1251    /// Override the 16-byte SubFormat GUID. Has no effect unless
1252    /// [`Self::with_extensible`] was also called.
1253    pub fn with_subformat(mut self, guid: [u8; 16]) -> Self {
1254        if let Some(opts) = self.extensible.as_mut() {
1255            opts.subformat = Some(guid);
1256        }
1257        self
1258    }
1259}
1260
1261/// Open the WAV muxer with caller-controlled `WAVEFORMATEXTENSIBLE`
1262/// options. Identical to `open_muxer` when `opts ==
1263/// WavMuxOptions::default()`.
1264pub fn open_muxer_with(
1265    output: Box<dyn WriteSeek>,
1266    streams: &[StreamInfo],
1267    opts: WavMuxOptions,
1268) -> Result<Box<dyn Muxer>> {
1269    if streams.len() != 1 {
1270        return Err(Error::unsupported("WAV supports exactly one audio stream"));
1271    }
1272    let s = &streams[0];
1273    if s.params.media_type != MediaType::Audio {
1274        return Err(Error::invalid("WAV stream must be audio"));
1275    }
1276    let channels = s
1277        .params
1278        .channels
1279        .ok_or_else(|| Error::invalid("WAV muxer: missing channels"))?;
1280    let sample_rate = s
1281        .params
1282        .sample_rate
1283        .ok_or_else(|| Error::invalid("WAV muxer: missing sample rate"))?;
1284    let shape = wire_shape_for_params(&s.params)?;
1285    Ok(Box::new(WavMuxer {
1286        output,
1287        channels,
1288        sample_rate,
1289        shape,
1290        extensible: opts.extensible,
1291        riff_size_offset: 0,
1292        data_size_offset: 0,
1293        data_bytes: 0,
1294        header_written: false,
1295        trailer_written: false,
1296    }))
1297}
1298
1299/// On-wire shape the muxer needs to know per codec — distinguishes
1300/// PCM/IEEE-float (bit-depth driven) from A-law/μ-law (fixed
1301/// 8 bits / sample).
1302#[derive(Clone, Copy, Debug)]
1303enum WireShape {
1304    /// Integer PCM (`wFormatTag = 0x0001`). bits = `wBitsPerSample`.
1305    IntPcm { bits: u16 },
1306    /// IEEE float PCM (`wFormatTag = 0x0003`). bits = 32 or 64.
1307    FloatPcm { bits: u16 },
1308    /// G.711 A-law (`wFormatTag = 0x0006`), 8 bits per sample.
1309    Alaw,
1310    /// G.711 μ-law (`wFormatTag = 0x0007`), 8 bits per sample.
1311    Mulaw,
1312}
1313
1314impl WireShape {
1315    fn bits_per_sample(self) -> u16 {
1316        match self {
1317            WireShape::IntPcm { bits } | WireShape::FloatPcm { bits } => bits,
1318            WireShape::Alaw | WireShape::Mulaw => 8,
1319        }
1320    }
1321
1322    fn format_tag(self) -> u16 {
1323        match self {
1324            WireShape::IntPcm { .. } => FMT_PCM,
1325            WireShape::FloatPcm { .. } => FMT_IEEE_FLOAT,
1326            WireShape::Alaw => FMT_ALAW,
1327            WireShape::Mulaw => FMT_MULAW,
1328        }
1329    }
1330
1331    fn well_known_guid(self) -> [u8; 16] {
1332        match self {
1333            WireShape::IntPcm { .. } => GUID_PCM,
1334            WireShape::FloatPcm { .. } => GUID_IEEE_FLOAT,
1335            WireShape::Alaw => GUID_ALAW,
1336            WireShape::Mulaw => GUID_MULAW,
1337        }
1338    }
1339}
1340
1341fn wire_shape_for_params(p: &CodecParameters) -> Result<WireShape> {
1342    match p.codec_id.as_str() {
1343        "pcm_alaw" => return Ok(WireShape::Alaw),
1344        "pcm_mulaw" => return Ok(WireShape::Mulaw),
1345        _ => {}
1346    }
1347    let fmt = p
1348        .sample_format
1349        .or_else(|| super::pcm::sample_format_for(&p.codec_id))
1350        .ok_or_else(|| Error::unsupported(format!("WAV: unknown PCM codec {}", p.codec_id)))?;
1351    Ok(match fmt {
1352        SampleFormat::U8 => WireShape::IntPcm { bits: 8 },
1353        SampleFormat::S16 => WireShape::IntPcm { bits: 16 },
1354        SampleFormat::S24 => WireShape::IntPcm { bits: 24 },
1355        SampleFormat::S32 => WireShape::IntPcm { bits: 32 },
1356        SampleFormat::F32 => WireShape::FloatPcm { bits: 32 },
1357        SampleFormat::F64 => WireShape::FloatPcm { bits: 64 },
1358        other => {
1359            return Err(Error::unsupported(format!(
1360                "WAV muxer cannot write sample format {:?}",
1361                other
1362            )));
1363        }
1364    })
1365}
1366
1367struct WavMuxer {
1368    output: Box<dyn WriteSeek>,
1369    channels: u16,
1370    sample_rate: u32,
1371    shape: WireShape,
1372    extensible: Option<ExtensibleOpts>,
1373    riff_size_offset: u64,
1374    data_size_offset: u64,
1375    data_bytes: u64,
1376    header_written: bool,
1377    trailer_written: bool,
1378}
1379
1380impl Muxer for WavMuxer {
1381    fn format_name(&self) -> &str {
1382        "wav"
1383    }
1384
1385    fn write_header(&mut self) -> Result<()> {
1386        if self.header_written {
1387            return Err(Error::other("WAV header already written"));
1388        }
1389        let bits_per_sample = self.shape.bits_per_sample();
1390        let block_align = (bits_per_sample / 8) * self.channels;
1391        let byte_rate = self.sample_rate * block_align as u32;
1392
1393        // On-wire wFormatTag: caller's extensible opt-in overrides the
1394        // per-codec default (the underlying shape still drives
1395        // wBitsPerSample / block_align / byte_rate).
1396        let format_tag = if self.extensible.is_some() {
1397            FMT_EXTENSIBLE
1398        } else {
1399            self.shape.format_tag()
1400        };
1401
1402        self.output.write_all(b"RIFF")?;
1403        self.riff_size_offset = self.output.stream_position()?;
1404        self.output.write_all(&0u32.to_le_bytes())?; // placeholder
1405        self.output.write_all(b"WAVE")?;
1406
1407        // fmt chunk: 16 bytes for plain WAVEFORMAT, 40 bytes for
1408        // WAVEFORMATEXTENSIBLE (16 + 2 cbSize + 22 ext).
1409        let fmt_size: u32 = if self.extensible.is_some() { 40 } else { 16 };
1410        self.output.write_all(b"fmt ")?;
1411        self.output.write_all(&fmt_size.to_le_bytes())?;
1412        self.output.write_all(&format_tag.to_le_bytes())?;
1413        self.output.write_all(&self.channels.to_le_bytes())?;
1414        self.output.write_all(&self.sample_rate.to_le_bytes())?;
1415        self.output.write_all(&byte_rate.to_le_bytes())?;
1416        self.output.write_all(&block_align.to_le_bytes())?;
1417        self.output.write_all(&bits_per_sample.to_le_bytes())?;
1418
1419        if let Some(opts) = &self.extensible {
1420            // cbSize (22) + 22-byte extension. Layout per
1421            // docs/container/riff/waveformatextensible/README.md
1422            // §"Structure layout".
1423            self.output.write_all(&22u16.to_le_bytes())?;
1424            let valid = opts.valid_bits_per_sample.unwrap_or(bits_per_sample);
1425            self.output.write_all(&valid.to_le_bytes())?;
1426            self.output.write_all(&opts.channel_mask.to_le_bytes())?;
1427            let guid = opts
1428                .subformat
1429                .unwrap_or_else(|| self.shape.well_known_guid());
1430            self.output.write_all(&guid)?;
1431        }
1432
1433        self.output.write_all(b"data")?;
1434        self.data_size_offset = self.output.stream_position()?;
1435        self.output.write_all(&0u32.to_le_bytes())?; // placeholder
1436
1437        self.header_written = true;
1438        Ok(())
1439    }
1440
1441    fn write_packet(&mut self, packet: &Packet) -> Result<()> {
1442        if !self.header_written {
1443            return Err(Error::other("WAV muxer: write_header not called"));
1444        }
1445        self.output.write_all(&packet.data)?;
1446        self.data_bytes += packet.data.len() as u64;
1447        Ok(())
1448    }
1449
1450    fn write_trailer(&mut self) -> Result<()> {
1451        if self.trailer_written {
1452            return Ok(());
1453        }
1454        // Pad data chunk to even length.
1455        if self.data_bytes % 2 == 1 {
1456            self.output.write_all(&[0u8])?;
1457        }
1458        let end = self.output.stream_position()?;
1459
1460        // Patch "data" chunk size.
1461        let data_size_u32: u32 = self
1462            .data_bytes
1463            .try_into()
1464            .map_err(|_| Error::other("WAV data chunk exceeds 4 GiB"))?;
1465        self.output.seek(SeekFrom::Start(self.data_size_offset))?;
1466        self.output.write_all(&data_size_u32.to_le_bytes())?;
1467
1468        // Patch "RIFF" size: total file size minus 8 (RIFF + size fields).
1469        let riff_size_u32: u32 = (end - 8)
1470            .try_into()
1471            .map_err(|_| Error::other("WAV RIFF size exceeds 4 GiB"))?;
1472        self.output.seek(SeekFrom::Start(self.riff_size_offset))?;
1473        self.output.write_all(&riff_size_u32.to_le_bytes())?;
1474
1475        self.output.seek(SeekFrom::Start(end))?;
1476        self.output.flush()?;
1477        self.trailer_written = true;
1478        Ok(())
1479    }
1480}
1481
1482#[cfg(test)]
1483mod tests {
1484    use super::*;
1485    use oxideav_core::{CodecParameters, MediaType};
1486
1487    fn make_stream(fmt: SampleFormat, ch: u16, sr: u32) -> StreamInfo {
1488        let mut params = CodecParameters::audio(super::super::pcm::codec_id_for(fmt).unwrap());
1489        params.media_type = MediaType::Audio;
1490        params.channels = Some(ch);
1491        params.sample_rate = Some(sr);
1492        params.sample_format = Some(fmt);
1493        StreamInfo {
1494            index: 0,
1495            time_base: TimeBase::new(1, sr as i64),
1496            duration: None,
1497            start_time: Some(0),
1498            params,
1499        }
1500    }
1501
1502    fn wave_format_tag(p: &CodecParameters) -> Option<u16> {
1503        match p.tag.as_ref()? {
1504            oxideav_core::CodecTag::WaveFormat(t) => Some(*t),
1505            _ => None,
1506        }
1507    }
1508
1509    fn make_g711_stream(codec: &str, ch: u16, sr: u32) -> StreamInfo {
1510        let mut params = CodecParameters::audio(CodecId::new(codec));
1511        params.media_type = MediaType::Audio;
1512        params.channels = Some(ch);
1513        params.sample_rate = Some(sr);
1514        // G.711 expands to S16 once decoded — sample_format describes
1515        // the post-decode shape, matching the round-75 oxideav-avi
1516        // convention. The on-wire packets are 8-bit codewords.
1517        params.sample_format = Some(SampleFormat::S16);
1518        StreamInfo {
1519            index: 0,
1520            time_base: TimeBase::new(1, sr as i64),
1521            duration: None,
1522            start_time: Some(0),
1523            params,
1524        }
1525    }
1526
1527    /// Mux a single-packet stream through `open_muxer_with` to a
1528    /// uniquely-named tmpfile, then return both the encoded bytes and
1529    /// an open demuxer over them. The tmpfile is removed after the
1530    /// read. Avoids `Cursor<&mut Vec<u8>>` lifetime traps —
1531    /// `Box<dyn WriteSeek>` requires `'static`.
1532    fn mux_to_bytes(
1533        stream: &StreamInfo,
1534        payload: &[u8],
1535        opts: WavMuxOptions,
1536        tag: &str,
1537    ) -> Vec<u8> {
1538        let tmp = std::env::temp_dir().join(format!("oxideav-basic-wav-r77-{tag}.wav"));
1539        let _ = std::fs::remove_file(&tmp);
1540        {
1541            let f = std::fs::File::create(&tmp).unwrap();
1542            let ws: Box<dyn WriteSeek> = Box::new(f);
1543            let mut mux = open_muxer_with(ws, std::slice::from_ref(stream), opts).unwrap();
1544            mux.write_header().unwrap();
1545            let pkt = Packet::new(0, stream.time_base, payload.to_vec());
1546            mux.write_packet(&pkt).unwrap();
1547            mux.write_trailer().unwrap();
1548        }
1549        let bytes = std::fs::read(&tmp).unwrap();
1550        let _ = std::fs::remove_file(&tmp);
1551        bytes
1552    }
1553
1554    fn open_demux_from_bytes(bytes: Vec<u8>) -> Box<dyn Demuxer> {
1555        use std::io::Cursor;
1556        let rs: Box<dyn ReadSeek> = Box::new(Cursor::new(bytes));
1557        open_demuxer(rs, &oxideav_core::NullCodecResolver).unwrap()
1558    }
1559
1560    #[test]
1561    fn round_trip_s16_mono() {
1562        // Write then read back a small S16 mono WAV via the public demuxer/muxer paths.
1563        let samples: Vec<i16> = (0..1000).map(|i| ((i * 32) - 16000) as i16).collect();
1564        let mut payload = Vec::with_capacity(samples.len() * 2);
1565        for s in &samples {
1566            payload.extend_from_slice(&s.to_le_bytes());
1567        }
1568
1569        // Mux to a temp file, then demux and compare.
1570        let stream = make_stream(SampleFormat::S16, 1, 48_000);
1571        let tmp = std::env::temp_dir().join("oxideav-basic-wav-test.wav");
1572        {
1573            let f = std::fs::File::create(&tmp).unwrap();
1574            let ws: Box<dyn WriteSeek> = Box::new(f);
1575            let mut mux = open_muxer(ws, std::slice::from_ref(&stream)).unwrap();
1576            mux.write_header().unwrap();
1577            let pkt = Packet::new(0, stream.time_base, payload.clone());
1578            mux.write_packet(&pkt).unwrap();
1579            mux.write_trailer().unwrap();
1580        }
1581        let rs: Box<dyn ReadSeek> = Box::new(std::fs::File::open(&tmp).unwrap());
1582        let mut dmx = open_demuxer(rs, &oxideav_core::NullCodecResolver).unwrap();
1583        assert_eq!(dmx.format_name(), "wav");
1584        assert_eq!(dmx.streams().len(), 1);
1585        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
1586        let mut out_bytes = Vec::new();
1587        loop {
1588            match dmx.next_packet() {
1589                Ok(p) => out_bytes.extend_from_slice(&p.data),
1590                Err(Error::Eof) => break,
1591                Err(e) => panic!("demux error: {e}"),
1592            }
1593        }
1594        assert_eq!(out_bytes, payload);
1595    }
1596
1597    /// `WAVE_FORMAT_ALAW (0x0006)` — codec_id is `pcm_alaw`, on-wire
1598    /// stays 8-bit codewords, sample_format hint is S16 (decoded shape).
1599    #[test]
1600    fn round_trip_alaw_mono() {
1601        // Synthetic A-law codewords — A-law is a byte stream, so any
1602        // distinct-byte sequence exercises the byte-for-byte plumbing.
1603        let payload: Vec<u8> = (0..=255u8).collect();
1604        let stream = make_g711_stream("pcm_alaw", 1, 8_000);
1605        let bytes = mux_to_bytes(&stream, &payload, WavMuxOptions::default(), "alaw-mono");
1606        let mut dmx = open_demux_from_bytes(bytes);
1607        assert_eq!(dmx.streams().len(), 1);
1608        let s = &dmx.streams()[0];
1609        assert_eq!(s.params.codec_id, CodecId::new("pcm_alaw"));
1610        assert_eq!(wave_format_tag(&s.params), Some(FMT_ALAW));
1611        assert_eq!(s.params.sample_format, Some(SampleFormat::S16));
1612        // bit_rate is the on-wire rate (8 bits/sample * channels * rate),
1613        // NOT the post-decode S16 rate.
1614        assert_eq!(s.params.bit_rate, Some(8 * 8_000));
1615        let mut out = Vec::new();
1616        loop {
1617            match dmx.next_packet() {
1618                Ok(p) => out.extend_from_slice(&p.data),
1619                Err(Error::Eof) => break,
1620                Err(e) => panic!("demux error: {e}"),
1621            }
1622        }
1623        assert_eq!(out, payload);
1624    }
1625
1626    /// `WAVE_FORMAT_MULAW (0x0007)` — codec_id is `pcm_mulaw`.
1627    #[test]
1628    fn round_trip_mulaw_stereo() {
1629        let payload: Vec<u8> = (0..512u32).map(|i| (i & 0xFF) as u8).collect();
1630        let stream = make_g711_stream("pcm_mulaw", 2, 8_000);
1631        let bytes = mux_to_bytes(&stream, &payload, WavMuxOptions::default(), "mulaw-stereo");
1632        let mut dmx = open_demux_from_bytes(bytes);
1633        let s = &dmx.streams()[0];
1634        assert_eq!(s.params.codec_id, CodecId::new("pcm_mulaw"));
1635        assert_eq!(wave_format_tag(&s.params), Some(FMT_MULAW));
1636        // stereo G.711 — block_align = 2 bytes (1 byte/channel),
1637        // byte_rate = 16000, bit_rate = 128 kbps.
1638        assert_eq!(s.params.bit_rate, Some(16 * 8_000));
1639        let mut out = Vec::new();
1640        loop {
1641            match dmx.next_packet() {
1642                Ok(p) => out.extend_from_slice(&p.data),
1643                Err(Error::Eof) => break,
1644                Err(e) => panic!("demux error: {e}"),
1645            }
1646        }
1647        assert_eq!(out, payload);
1648    }
1649
1650    /// `WAVE_FORMAT_EXTENSIBLE (0xFFFE)` end-to-end — muxer emits the
1651    /// 40-byte fmt chunk with cbSize = 22, demuxer parses the
1652    /// extension and exposes channel_mask / valid_bits / SubFormat via
1653    /// both metadata keys AND the typed accessors.
1654    #[test]
1655    fn round_trip_extensible_5_1_pcm() {
1656        // 6-channel 5.1-Microsoft layout (FL FR FC LFE BL BR) per
1657        // docs/container/riff/waveformatextensible/README.md
1658        // §"Channel-mask channel ordering". `payload` is one frame of
1659        // 6 distinct s16 samples so we can verify frame boundary
1660        // alignment after the round trip.
1661        const MASK_5_1: u32 = 0x0003F;
1662        let frame: [i16; 6] = [-100, 200, -300, 400, -500, 600];
1663        let mut payload = Vec::new();
1664        for _ in 0..32 {
1665            for s in &frame {
1666                payload.extend_from_slice(&s.to_le_bytes());
1667            }
1668        }
1669
1670        let mut stream = make_stream(SampleFormat::S16, 6, 48_000);
1671        // Build via open_muxer_with so the muxer emits the EXTENSIBLE
1672        // fmt chunk. open_muxer (the registry default) writes the
1673        // legacy 16-byte fmt chunk for maximum compatibility.
1674        stream.params.codec_id = CodecId::new("pcm_s16le");
1675        let opts = WavMuxOptions::default().with_extensible(MASK_5_1);
1676        let buf = mux_to_bytes(&stream, &payload, opts, "ext-5-1-pcm");
1677
1678        // Sanity-check the on-wire fmt chunk size + cbSize.
1679        // RIFF(4)+size(4)+WAVE(4)+"fmt "(4)+size(4) == 20 bytes
1680        // before the fmt body; fmt size at offset 16 must be 40.
1681        assert_eq!(&buf[12..16], b"fmt ");
1682        let fmt_size = u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
1683        assert_eq!(fmt_size, 40, "EXTENSIBLE fmt chunk must be 40 bytes");
1684        // wFormatTag at fmt body[0..2] == 0xFFFE
1685        assert_eq!(u16::from_le_bytes([buf[20], buf[21]]), FMT_EXTENSIBLE);
1686        // cbSize at fmt body[16..18] == 22
1687        assert_eq!(u16::from_le_bytes([buf[36], buf[37]]), 22);
1688
1689        // Demux it back.
1690        let dmx = open_demux_from_bytes(buf);
1691        // SubFormat == KSDATAFORMAT_SUBTYPE_PCM resolves the codec id
1692        // to the legacy pcm_s16le (the round-75 oxideav-avi convention
1693        // — the GUID identifies the codec, not the bit depth).
1694        let s = &dmx.streams()[0];
1695        assert_eq!(s.params.codec_id, CodecId::new("pcm_s16le"));
1696        assert_eq!(wave_format_tag(&s.params), Some(FMT_EXTENSIBLE));
1697
1698        // Metadata round-trip.
1699        let md: std::collections::HashMap<String, String> =
1700            dmx.metadata().iter().cloned().collect();
1701        assert_eq!(
1702            md.get("wav:fmt.channel_mask"),
1703            Some(&format!("0x{MASK_5_1:08X}"))
1704        );
1705        assert_eq!(
1706            md.get("wav:fmt.valid_bits_per_sample"),
1707            Some(&"16".to_string())
1708        );
1709        assert_eq!(
1710            md.get("wav:fmt.subformat"),
1711            Some(&"00000001-0000-0010-8000-00AA00389B71".to_string())
1712        );
1713    }
1714
1715    /// Typed accessors on the concrete `WavDemuxer` carry the same
1716    /// EXTENSIBLE side-info the metadata keys do.
1717    #[test]
1718    fn extensible_accessors_match_metadata() {
1719        const MASK_STEREO: u32 = 0x00003;
1720        let payload = vec![0u8; 4 * 100]; // 100 stereo s16 frames
1721
1722        let mut stream = make_stream(SampleFormat::S16, 2, 44_100);
1723        stream.params.codec_id = CodecId::new("pcm_s16le");
1724
1725        let opts = WavMuxOptions::default().with_extensible(MASK_STEREO);
1726        let bytes = mux_to_bytes(&stream, &payload, opts, "ext-stereo-md");
1727        let dmx = open_demux_from_bytes(bytes);
1728        let md: std::collections::HashMap<String, String> =
1729            dmx.metadata().iter().cloned().collect();
1730        assert_eq!(
1731            md.get("wav:fmt.channel_mask"),
1732            Some(&format!("0x{MASK_STEREO:08X}"))
1733        );
1734        assert_eq!(
1735            md.get("wav:fmt.valid_bits_per_sample"),
1736            Some(&"16".to_string())
1737        );
1738    }
1739
1740    /// EXTENSIBLE muxing of A-law: muxer writes `wFormatTag = 0xFFFE`
1741    /// with the KSDATAFORMAT_SUBTYPE_ALAW GUID; demuxer resolves
1742    /// codec_id to `pcm_alaw` via the GUID path.
1743    #[test]
1744    fn extensible_alaw_through_guid() {
1745        let payload: Vec<u8> = (0..=255u8).collect();
1746        let stream = make_g711_stream("pcm_alaw", 1, 8_000);
1747
1748        let opts = WavMuxOptions::default().with_extensible(0x00004); // FRONT_CENTER
1749        let bytes = mux_to_bytes(&stream, &payload, opts, "ext-alaw");
1750        let mut dmx = open_demux_from_bytes(bytes);
1751        let s = &dmx.streams()[0];
1752        // The legacy wFormatTag is preserved (0xFFFE) but the codec_id
1753        // resolves through the SubFormat GUID — the demuxer must
1754        // dispatch G.711 even though wFormatTag is the EXTENSIBLE
1755        // escape hatch.
1756        assert_eq!(s.params.codec_id, CodecId::new("pcm_alaw"));
1757        assert_eq!(wave_format_tag(&s.params), Some(FMT_EXTENSIBLE));
1758        let mut out = Vec::new();
1759        loop {
1760            match dmx.next_packet() {
1761                Ok(p) => out.extend_from_slice(&p.data),
1762                Err(Error::Eof) => break,
1763                Err(e) => panic!("demux error: {e}"),
1764            }
1765        }
1766        assert_eq!(out, payload);
1767    }
1768
1769    /// EXTENSIBLE with an unknown SubFormat GUID — the demuxer must
1770    /// synthesise a `wav:guid_<canonical>` codec id so downstream
1771    /// `make_decoder` lookups fail naming the actual GUID rather than
1772    /// the opaque `0xFFFE` tag.
1773    #[test]
1774    fn extensible_unknown_guid_synthesised_id() {
1775        // Hand-build a minimal EXTENSIBLE WAV with a bogus GUID, then
1776        // feed it to the demuxer. We bypass the muxer so we can pick
1777        // an unknown SubFormat directly.
1778        let bogus_guid: [u8; 16] = [
1779            0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC,
1780            0xDE, 0xF0,
1781        ];
1782        let mut buf = Vec::new();
1783        buf.extend_from_slice(b"RIFF");
1784        buf.extend_from_slice(&0u32.to_le_bytes()); // riff size placeholder
1785        buf.extend_from_slice(b"WAVE");
1786        buf.extend_from_slice(b"fmt ");
1787        buf.extend_from_slice(&40u32.to_le_bytes()); // fmt size
1788        buf.extend_from_slice(&FMT_EXTENSIBLE.to_le_bytes()); // wFormatTag
1789        buf.extend_from_slice(&1u16.to_le_bytes()); // channels
1790        buf.extend_from_slice(&44_100u32.to_le_bytes()); // sample_rate
1791        buf.extend_from_slice(&88_200u32.to_le_bytes()); // byte_rate
1792        buf.extend_from_slice(&2u16.to_le_bytes()); // block_align
1793        buf.extend_from_slice(&16u16.to_le_bytes()); // bits_per_sample
1794        buf.extend_from_slice(&22u16.to_le_bytes()); // cbSize
1795        buf.extend_from_slice(&16u16.to_le_bytes()); // wValidBitsPerSample
1796        buf.extend_from_slice(&0x00004u32.to_le_bytes()); // dwChannelMask (FC)
1797        buf.extend_from_slice(&bogus_guid); // SubFormat
1798        buf.extend_from_slice(b"data");
1799        buf.extend_from_slice(&0u32.to_le_bytes()); // empty data chunk
1800
1801        use std::io::Cursor;
1802        let rs: Box<dyn ReadSeek> = Box::new(Cursor::new(buf));
1803        let dmx_res = open_demuxer(rs, &oxideav_core::NullCodecResolver);
1804        let dmx = dmx_res.expect("unknown-GUID extensible stream still parses");
1805        let id = dmx.streams()[0].params.codec_id.as_str().to_string();
1806        assert!(
1807            id.starts_with("wav:guid_"),
1808            "unknown GUID must synthesise wav:guid_<text>, got {id:?}"
1809        );
1810        // Canonical text form: lowercase hex-dump in the on-wire
1811        // little-endian first-three-groups layout.
1812        assert!(
1813            id.contains("EFBEADDE-FECA-BEBA"),
1814            "synthesised id must carry the canonical GUID text, got {id:?}"
1815        );
1816    }
1817
1818    /// `WAVE_FORMAT_EXTENSIBLE` with cbSize < 22 must reject — the spec
1819    /// mandates a 22-byte extension. Per
1820    /// docs/container/riff/waveformatextensible/README.md §"Structure
1821    /// layout": "RIFF `fmt` chunks carrying it are 40 bytes (38 bytes
1822    /// of struct payload + 2-byte `cbSize = 22`)."
1823    #[test]
1824    fn extensible_short_cbsize_rejected() {
1825        let mut buf = Vec::new();
1826        buf.extend_from_slice(b"RIFF");
1827        buf.extend_from_slice(&0u32.to_le_bytes());
1828        buf.extend_from_slice(b"WAVE");
1829        buf.extend_from_slice(b"fmt ");
1830        // 40-byte fmt body but cbSize = 10 (insufficient).
1831        buf.extend_from_slice(&40u32.to_le_bytes());
1832        buf.extend_from_slice(&FMT_EXTENSIBLE.to_le_bytes());
1833        buf.extend_from_slice(&1u16.to_le_bytes());
1834        buf.extend_from_slice(&44_100u32.to_le_bytes());
1835        buf.extend_from_slice(&88_200u32.to_le_bytes());
1836        buf.extend_from_slice(&2u16.to_le_bytes());
1837        buf.extend_from_slice(&16u16.to_le_bytes());
1838        buf.extend_from_slice(&10u16.to_le_bytes()); // cbSize too small
1839        buf.extend_from_slice(&[0u8; 20]); // padding to reach 40 fmt body bytes
1840        buf.extend_from_slice(b"data");
1841        buf.extend_from_slice(&0u32.to_le_bytes());
1842
1843        use std::io::Cursor;
1844        let rs: Box<dyn ReadSeek> = Box::new(Cursor::new(buf));
1845        let err = open_demuxer(rs, &oxideav_core::NullCodecResolver).err();
1846        assert!(
1847            matches!(err, Some(Error::InvalidData(_))),
1848            "short cbSize must yield Error::InvalidData, got {err:?}"
1849        );
1850    }
1851
1852    /// Build a minimal valid PCM WAV with a caller-supplied raw `bext`
1853    /// chunk body inserted between `fmt ` and `data`. Returns the file
1854    /// bytes ready for `open_demux_from_bytes`. RIFF size is left at 0
1855    /// (the demuxer doesn't validate it).
1856    fn wav_with_bext(bext_body: &[u8]) -> Vec<u8> {
1857        let mut buf = Vec::new();
1858        buf.extend_from_slice(b"RIFF");
1859        buf.extend_from_slice(&0u32.to_le_bytes());
1860        buf.extend_from_slice(b"WAVE");
1861        // fmt : 16-byte PCM s16 mono 8000 Hz.
1862        buf.extend_from_slice(b"fmt ");
1863        buf.extend_from_slice(&16u32.to_le_bytes());
1864        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
1865        buf.extend_from_slice(&1u16.to_le_bytes()); // channels
1866        buf.extend_from_slice(&8_000u32.to_le_bytes()); // sample_rate
1867        buf.extend_from_slice(&16_000u32.to_le_bytes()); // byte_rate
1868        buf.extend_from_slice(&2u16.to_le_bytes()); // block_align
1869        buf.extend_from_slice(&16u16.to_le_bytes()); // bits_per_sample
1870                                                     // bext chunk.
1871        buf.extend_from_slice(b"bext");
1872        buf.extend_from_slice(&(bext_body.len() as u32).to_le_bytes());
1873        buf.extend_from_slice(bext_body);
1874        if bext_body.len() % 2 == 1 {
1875            buf.push(0); // RIFF word-alignment pad
1876        }
1877        // empty data chunk.
1878        buf.extend_from_slice(b"data");
1879        buf.extend_from_slice(&0u32.to_le_bytes());
1880        buf
1881    }
1882
1883    /// Assemble a 602-byte BWF v2 `bext` fixed struct plus a coding
1884    /// history string. Field offsets follow EBU Tech 3285 v2 §2.3.
1885    #[allow(clippy::too_many_arguments)]
1886    fn make_bext_v2(
1887        description: &str,
1888        originator: &str,
1889        originator_ref: &str,
1890        date: &str,
1891        time: &str,
1892        time_reference: u64,
1893        umid: &[u8; 64],
1894        loudness: [i16; 5],
1895        coding_history: &str,
1896    ) -> Vec<u8> {
1897        fn put_ascii(buf: &mut Vec<u8>, s: &str, width: usize) {
1898            let bytes = s.as_bytes();
1899            let n = bytes.len().min(width);
1900            buf.extend_from_slice(&bytes[..n]);
1901            buf.resize(buf.len() + (width - n), 0);
1902        }
1903        let mut b = Vec::new();
1904        put_ascii(&mut b, description, 256);
1905        put_ascii(&mut b, originator, 32);
1906        put_ascii(&mut b, originator_ref, 32);
1907        put_ascii(&mut b, date, 10);
1908        put_ascii(&mut b, time, 8);
1909        b.extend_from_slice(&(time_reference as u32).to_le_bytes()); // low
1910        b.extend_from_slice(&((time_reference >> 32) as u32).to_le_bytes()); // high
1911        b.extend_from_slice(&2u16.to_le_bytes()); // Version = 2
1912        b.extend_from_slice(umid);
1913        for v in &loudness {
1914            b.extend_from_slice(&v.to_le_bytes());
1915        }
1916        b.resize(BEXT_FIXED_LEN, 0); // Reserved[180] zero-fill to 602
1917        assert_eq!(b.len(), BEXT_FIXED_LEN);
1918        b.extend_from_slice(coding_history.as_bytes());
1919        b
1920    }
1921
1922    /// `fmt_loudness` mirrors the EBU Tech 3285 v2 §2.4 round-to-nearest
1923    /// fixed-point: the stored WORD is `round(100 × value)`, so dividing
1924    /// by 100 with two decimals recovers the displayed value. The §2.4
1925    /// negative-number examples (`-22.64` / `-22.65` / `-22.66` stored
1926    /// as `-2264` / `-2265` / `-2265`) are exercised in reverse here.
1927    #[test]
1928    fn bext_loudness_formatting() {
1929        assert_eq!(fmt_loudness(-2264), "-22.64");
1930        assert_eq!(fmt_loudness(-2265), "-22.65");
1931        assert_eq!(fmt_loudness(0), "0.00");
1932        assert_eq!(fmt_loudness(2300), "23.00");
1933        assert_eq!(fmt_loudness(-50), "-0.50"); // sub-unity negative keeps the sign
1934        assert_eq!(fmt_loudness(7), "0.07");
1935        assert_eq!(fmt_loudness(-1), "-0.01");
1936    }
1937
1938    /// Full BWF v2 `bext` round-trip: every field surfaces under its
1939    /// `wav:bext.*` metadata key, loudness fields decode to two
1940    /// decimals, the 64-bit TimeReference reassembles, and the UMID is
1941    /// hex-encoded.
1942    #[test]
1943    fn bext_v2_full_metadata() {
1944        let mut umid = [0u8; 64];
1945        umid[0] = 0x06;
1946        umid[1] = 0x0a;
1947        umid[63] = 0xff;
1948        let body = make_bext_v2(
1949            "Scene 1 take 3",
1950            "OxideAV Recorder",
1951            "USABC2400001",
1952            "2026-05-23",
1953            "14:30:00",
1954            // 48000 samples/s × 90061 s ≈ a value that spans 32 bits.
1955            0x0000_0001_2345_6789,
1956            &umid,
1957            [-2305, 700, -120, -1850, -2010],
1958            "A=PCM,F=48000,W=24,M=stereo,T=OxideAV\r\n",
1959        );
1960        let bytes = wav_with_bext(&body);
1961        let dmx = open_demux_from_bytes(bytes);
1962        let md: std::collections::HashMap<String, String> =
1963            dmx.metadata().iter().cloned().collect();
1964
1965        assert_eq!(
1966            md.get("wav:bext.description"),
1967            Some(&"Scene 1 take 3".to_string())
1968        );
1969        assert_eq!(
1970            md.get("wav:bext.originator"),
1971            Some(&"OxideAV Recorder".to_string())
1972        );
1973        assert_eq!(
1974            md.get("wav:bext.originator_reference"),
1975            Some(&"USABC2400001".to_string())
1976        );
1977        assert_eq!(
1978            md.get("wav:bext.origination_date"),
1979            Some(&"2026-05-23".to_string())
1980        );
1981        assert_eq!(
1982            md.get("wav:bext.origination_time"),
1983            Some(&"14:30:00".to_string())
1984        );
1985        assert_eq!(
1986            md.get("wav:bext.time_reference"),
1987            Some(&0x0000_0001_2345_6789u64.to_string())
1988        );
1989        assert_eq!(md.get("wav:bext.version"), Some(&"2".to_string()));
1990        assert_eq!(
1991            md.get("wav:bext.loudness_value"),
1992            Some(&"-23.05".to_string())
1993        );
1994        assert_eq!(md.get("wav:bext.loudness_range"), Some(&"7.00".to_string()));
1995        assert_eq!(
1996            md.get("wav:bext.max_true_peak_level"),
1997            Some(&"-1.20".to_string())
1998        );
1999        assert_eq!(
2000            md.get("wav:bext.max_momentary_loudness"),
2001            Some(&"-18.50".to_string())
2002        );
2003        assert_eq!(
2004            md.get("wav:bext.max_short_term_loudness"),
2005            Some(&"-20.10".to_string())
2006        );
2007        // UMID hex begins with the bytes we set and ends with 0xff.
2008        let umid_hex = md.get("wav:bext.umid").expect("umid present");
2009        assert!(umid_hex.starts_with("060a"), "umid hex {umid_hex:?}");
2010        assert!(umid_hex.ends_with("ff"), "umid hex {umid_hex:?}");
2011        assert_eq!(umid_hex.len(), 128); // 64 bytes × 2 hex chars
2012        assert_eq!(
2013            md.get("wav:bext.coding_history"),
2014            Some(&"A=PCM,F=48000,W=24,M=stereo,T=OxideAV".to_string())
2015        );
2016    }
2017
2018    /// BWF v0 `bext`: no UMID, no loudness fields. Version = 0 so the
2019    /// loudness keys must be absent and the (all-zero) UMID suppressed,
2020    /// while the text + TimeReference fields still surface.
2021    #[test]
2022    fn bext_v0_omits_umid_and_loudness() {
2023        // 602-byte fixed struct, version 0, all UMID/loudness zero.
2024        let mut body = vec![0u8; BEXT_FIXED_LEN];
2025        // Description "Field recording".
2026        let desc = b"Field recording";
2027        body[..desc.len()].copy_from_slice(desc);
2028        // TimeReferenceLow = 12345 (offset 338).
2029        body[338..342].copy_from_slice(&12_345u32.to_le_bytes());
2030        // Version = 0 (offset 346) — already zero.
2031        let bytes = wav_with_bext(&body);
2032        let dmx = open_demux_from_bytes(bytes);
2033        let md: std::collections::HashMap<String, String> =
2034            dmx.metadata().iter().cloned().collect();
2035
2036        assert_eq!(
2037            md.get("wav:bext.description"),
2038            Some(&"Field recording".to_string())
2039        );
2040        assert_eq!(md.get("wav:bext.version"), Some(&"0".to_string()));
2041        assert_eq!(
2042            md.get("wav:bext.time_reference"),
2043            Some(&"12345".to_string())
2044        );
2045        // v0 must not emit loudness or UMID.
2046        assert!(!md.contains_key("wav:bext.umid"));
2047        assert!(!md.contains_key("wav:bext.loudness_value"));
2048        assert!(!md.contains_key("wav:bext.max_true_peak_level"));
2049    }
2050
2051    /// A `bext` chunk shorter than the 602-byte fixed struct is
2052    /// malformed; the parser must skip it without panicking and the
2053    /// stream must still open (the chunk is treated as opaque).
2054    #[test]
2055    fn bext_truncated_is_skipped() {
2056        let body = vec![0u8; 100]; // < 602
2057        let bytes = wav_with_bext(&body);
2058        let dmx = open_demux_from_bytes(bytes);
2059        let md: std::collections::HashMap<String, String> =
2060            dmx.metadata().iter().cloned().collect();
2061        assert!(md.keys().all(|k| !k.starts_with("wav:bext.")));
2062        // Stream still resolves to PCM s16.
2063        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
2064    }
2065
2066    /// Build a minimal valid PCM WAV with a caller-supplied raw `cue `
2067    /// chunk and optional `LIST adtl` body inserted between `fmt ` and
2068    /// `data`. Returns the file bytes ready for `open_demux_from_bytes`.
2069    fn wav_with_cue_and_adtl(cue_body: &[u8], adtl_body: Option<&[u8]>) -> Vec<u8> {
2070        let mut buf = Vec::new();
2071        buf.extend_from_slice(b"RIFF");
2072        buf.extend_from_slice(&0u32.to_le_bytes());
2073        buf.extend_from_slice(b"WAVE");
2074        // fmt : 16-byte PCM s16 mono 8000 Hz.
2075        buf.extend_from_slice(b"fmt ");
2076        buf.extend_from_slice(&16u32.to_le_bytes());
2077        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
2078        buf.extend_from_slice(&1u16.to_le_bytes());
2079        buf.extend_from_slice(&8_000u32.to_le_bytes());
2080        buf.extend_from_slice(&16_000u32.to_le_bytes());
2081        buf.extend_from_slice(&2u16.to_le_bytes());
2082        buf.extend_from_slice(&16u16.to_le_bytes());
2083        // cue chunk
2084        buf.extend_from_slice(b"cue ");
2085        buf.extend_from_slice(&(cue_body.len() as u32).to_le_bytes());
2086        buf.extend_from_slice(cue_body);
2087        if cue_body.len() % 2 == 1 {
2088            buf.push(0);
2089        }
2090        // optional LIST adtl chunk
2091        if let Some(adtl) = adtl_body {
2092            // LIST chunk wraps a 4-byte form-type 'adtl' + sub-chunks.
2093            buf.extend_from_slice(b"LIST");
2094            buf.extend_from_slice(&((adtl.len() + 4) as u32).to_le_bytes());
2095            buf.extend_from_slice(b"adtl");
2096            buf.extend_from_slice(adtl);
2097            if (adtl.len() + 4) % 2 == 1 {
2098                buf.push(0);
2099            }
2100        }
2101        // empty data chunk
2102        buf.extend_from_slice(b"data");
2103        buf.extend_from_slice(&0u32.to_le_bytes());
2104        buf
2105    }
2106
2107    /// Build a single 24-byte `<cue-point>` record per
2108    /// `docs/container/riff/metadata/microsoft-riffmci.pdf` §3.
2109    fn cue_point(
2110        dw_name: u32,
2111        dw_position: u32,
2112        fcc_chunk: &[u8; 4],
2113        dw_chunk_start: u32,
2114        dw_block_start: u32,
2115        dw_sample_offset: u32,
2116    ) -> Vec<u8> {
2117        let mut b = Vec::with_capacity(24);
2118        b.extend_from_slice(&dw_name.to_le_bytes());
2119        b.extend_from_slice(&dw_position.to_le_bytes());
2120        b.extend_from_slice(fcc_chunk);
2121        b.extend_from_slice(&dw_chunk_start.to_le_bytes());
2122        b.extend_from_slice(&dw_block_start.to_le_bytes());
2123        b.extend_from_slice(&dw_sample_offset.to_le_bytes());
2124        b
2125    }
2126
2127    /// Build a `labl` or `note` sub-chunk body (without the chunk
2128    /// header) per RIFF MCI §3 "Label and Note Information".
2129    fn adtl_text_subchunk(id: &[u8; 4], dw_name: u32, text: &str) -> Vec<u8> {
2130        let mut b = Vec::new();
2131        b.extend_from_slice(id);
2132        // Body = 4-byte dwName + ZSTR (text + NUL terminator).
2133        let body_len = 4 + text.len() + 1;
2134        b.extend_from_slice(&(body_len as u32).to_le_bytes());
2135        b.extend_from_slice(&dw_name.to_le_bytes());
2136        b.extend_from_slice(text.as_bytes());
2137        b.push(0);
2138        if body_len % 2 == 1 {
2139            b.push(0);
2140        }
2141        b
2142    }
2143
2144    /// Full `cue ` + `LIST adtl` round-trip: two cue points, each with
2145    /// a `labl` and a `note`, surface under the documented metadata
2146    /// keys.
2147    #[test]
2148    fn cue_and_adtl_full_metadata() {
2149        // Two cue points (id=1 at sample 0, id=2 at sample 12345).
2150        let mut cue_body = Vec::new();
2151        cue_body.extend_from_slice(&2u32.to_le_bytes()); // dwCuePoints
2152        cue_body.extend(cue_point(1, 0, b"data", 0, 0, 0));
2153        cue_body.extend(cue_point(2, 12_345, b"data", 0, 0, 12_345));
2154
2155        // LIST adtl with labl + note for each cue point.
2156        let mut adtl = Vec::new();
2157        adtl.extend(adtl_text_subchunk(b"labl", 1, "Intro"));
2158        adtl.extend(adtl_text_subchunk(b"note", 1, "Fade-in"));
2159        adtl.extend(adtl_text_subchunk(b"labl", 2, "Verse"));
2160        adtl.extend(adtl_text_subchunk(b"note", 2, "Vocal entry"));
2161
2162        let bytes = wav_with_cue_and_adtl(&cue_body, Some(&adtl));
2163        let dmx = open_demux_from_bytes(bytes);
2164        let md: std::collections::HashMap<String, String> =
2165            dmx.metadata().iter().cloned().collect();
2166
2167        assert_eq!(md.get("wav:cue.count"), Some(&"2".to_string()));
2168        assert_eq!(md.get("wav:cue.1.position"), Some(&"0".to_string()));
2169        assert_eq!(md.get("wav:cue.1.fcc_chunk"), Some(&"data".to_string()));
2170        assert_eq!(md.get("wav:cue.1.chunk_start"), Some(&"0".to_string()));
2171        assert_eq!(md.get("wav:cue.1.block_start"), Some(&"0".to_string()));
2172        assert_eq!(md.get("wav:cue.1.sample_offset"), Some(&"0".to_string()));
2173        assert_eq!(md.get("wav:cue.2.position"), Some(&"12345".to_string()));
2174        assert_eq!(
2175            md.get("wav:cue.2.sample_offset"),
2176            Some(&"12345".to_string())
2177        );
2178
2179        assert_eq!(md.get("wav:adtl.labl.1"), Some(&"Intro".to_string()));
2180        assert_eq!(md.get("wav:adtl.note.1"), Some(&"Fade-in".to_string()));
2181        assert_eq!(md.get("wav:adtl.labl.2"), Some(&"Verse".to_string()));
2182        assert_eq!(md.get("wav:adtl.note.2"), Some(&"Vocal entry".to_string()));
2183    }
2184
2185    /// `ltxt` sub-chunk surfaces dwSampleLength, FOURCC purpose, and
2186    /// text under `wav:adtl.ltxt.<dwName>.*`.
2187    #[test]
2188    fn adtl_ltxt_segment_metadata() {
2189        // Single cue point so the ltxt reference is meaningful.
2190        let mut cue_body = Vec::new();
2191        cue_body.extend_from_slice(&1u32.to_le_bytes());
2192        cue_body.extend(cue_point(7, 1000, b"data", 0, 0, 1000));
2193
2194        // ltxt chunk for cue 7: 4410-sample segment, 'scrp' (script) text.
2195        let mut ltxt_body = Vec::new();
2196        ltxt_body.extend_from_slice(&7u32.to_le_bytes()); // dwName
2197        ltxt_body.extend_from_slice(&4410u32.to_le_bytes()); // dwSampleLength
2198        ltxt_body.extend_from_slice(b"scrp"); // dwPurpose
2199        ltxt_body.extend_from_slice(&0u16.to_le_bytes()); // wCountry
2200        ltxt_body.extend_from_slice(&0u16.to_le_bytes()); // wLanguage
2201        ltxt_body.extend_from_slice(&0u16.to_le_bytes()); // wDialect
2202        ltxt_body.extend_from_slice(&0u16.to_le_bytes()); // wCodePage
2203        ltxt_body.extend_from_slice(b"Hello world");
2204        ltxt_body.push(0);
2205
2206        let mut adtl = Vec::new();
2207        adtl.extend_from_slice(b"ltxt");
2208        adtl.extend_from_slice(&(ltxt_body.len() as u32).to_le_bytes());
2209        adtl.extend_from_slice(&ltxt_body);
2210        if ltxt_body.len() % 2 == 1 {
2211            adtl.push(0);
2212        }
2213
2214        let bytes = wav_with_cue_and_adtl(&cue_body, Some(&adtl));
2215        let dmx = open_demux_from_bytes(bytes);
2216        let md: std::collections::HashMap<String, String> =
2217            dmx.metadata().iter().cloned().collect();
2218
2219        assert_eq!(md.get("wav:adtl.ltxt.7.length"), Some(&"4410".to_string()));
2220        assert_eq!(md.get("wav:adtl.ltxt.7.purpose"), Some(&"scrp".to_string()));
2221        assert_eq!(
2222            md.get("wav:adtl.ltxt.7.text"),
2223            Some(&"Hello world".to_string())
2224        );
2225    }
2226
2227    /// A `cue ` chunk whose `dwCuePoints` count exceeds the body length
2228    /// must not panic — the parser surfaces only the records that
2229    /// actually fit in the body.
2230    #[test]
2231    fn cue_truncated_count_is_clamped() {
2232        // Claim 5 points, ship 1.
2233        let mut cue_body = Vec::new();
2234        cue_body.extend_from_slice(&5u32.to_le_bytes());
2235        cue_body.extend(cue_point(42, 100, b"data", 0, 0, 100));
2236        let bytes = wav_with_cue_and_adtl(&cue_body, None);
2237        let dmx = open_demux_from_bytes(bytes);
2238        let md: std::collections::HashMap<String, String> =
2239            dmx.metadata().iter().cloned().collect();
2240        assert_eq!(md.get("wav:cue.count"), Some(&"1".to_string()));
2241        assert_eq!(md.get("wav:cue.42.position"), Some(&"100".to_string()));
2242        // Stream still opens cleanly.
2243        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
2244    }
2245
2246    /// An `adtl` list without a matching `cue ` chunk still emits its
2247    /// `wav:adtl.*` keys — the spec doesn't require the cue chunk to
2248    /// precede the adtl list, and downstream consumers can cross-
2249    /// reference dwName values themselves.
2250    #[test]
2251    fn adtl_without_cue_still_surfaces() {
2252        let mut adtl = Vec::new();
2253        adtl.extend(adtl_text_subchunk(b"labl", 99, "Orphan label"));
2254        // cue body with zero points exercises the count=0 path.
2255        let cue_body = 0u32.to_le_bytes().to_vec();
2256        let bytes = wav_with_cue_and_adtl(&cue_body, Some(&adtl));
2257        let dmx = open_demux_from_bytes(bytes);
2258        let md: std::collections::HashMap<String, String> =
2259            dmx.metadata().iter().cloned().collect();
2260        assert_eq!(md.get("wav:cue.count"), Some(&"0".to_string()));
2261        assert_eq!(
2262            md.get("wav:adtl.labl.99"),
2263            Some(&"Orphan label".to_string())
2264        );
2265    }
2266
2267    /// Build a minimal valid PCM WAV with caller-supplied raw `smpl`
2268    /// and/or `inst` chunks inserted between `fmt ` and `data`. Mirrors
2269    /// `wav_with_cue_and_adtl` but for the sampler/instrument chunk
2270    /// pair.
2271    fn wav_with_smpl_and_inst(smpl_body: Option<&[u8]>, inst_body: Option<&[u8]>) -> Vec<u8> {
2272        let mut buf = Vec::new();
2273        buf.extend_from_slice(b"RIFF");
2274        buf.extend_from_slice(&0u32.to_le_bytes());
2275        buf.extend_from_slice(b"WAVE");
2276        // fmt : 16-byte PCM s16 mono 8000 Hz.
2277        buf.extend_from_slice(b"fmt ");
2278        buf.extend_from_slice(&16u32.to_le_bytes());
2279        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
2280        buf.extend_from_slice(&1u16.to_le_bytes());
2281        buf.extend_from_slice(&8_000u32.to_le_bytes());
2282        buf.extend_from_slice(&16_000u32.to_le_bytes());
2283        buf.extend_from_slice(&2u16.to_le_bytes());
2284        buf.extend_from_slice(&16u16.to_le_bytes());
2285        if let Some(smpl) = smpl_body {
2286            buf.extend_from_slice(b"smpl");
2287            buf.extend_from_slice(&(smpl.len() as u32).to_le_bytes());
2288            buf.extend_from_slice(smpl);
2289            if smpl.len() % 2 == 1 {
2290                buf.push(0);
2291            }
2292        }
2293        if let Some(inst) = inst_body {
2294            buf.extend_from_slice(b"inst");
2295            buf.extend_from_slice(&(inst.len() as u32).to_le_bytes());
2296            buf.extend_from_slice(inst);
2297            if inst.len() % 2 == 1 {
2298                buf.push(0);
2299            }
2300        }
2301        // empty data chunk
2302        buf.extend_from_slice(b"data");
2303        buf.extend_from_slice(&0u32.to_le_bytes());
2304        buf
2305    }
2306
2307    /// Build a `smpl` fixed header (36 bytes) followed by N sample-loop
2308    /// records (24 bytes each).
2309    #[allow(clippy::too_many_arguments)]
2310    fn smpl_body(
2311        manufacturer: u32,
2312        product: u32,
2313        sample_period: u32,
2314        midi_unity_note: u32,
2315        midi_pitch_fraction: u32,
2316        smpte_format: u32,
2317        smpte_offset: u32,
2318        c_sample_loops_claimed: u32,
2319        cb_sampler_data: u32,
2320        loops: &[(u32, u32, u32, u32, u32, u32)],
2321    ) -> Vec<u8> {
2322        let mut b = Vec::new();
2323        for v in [
2324            manufacturer,
2325            product,
2326            sample_period,
2327            midi_unity_note,
2328            midi_pitch_fraction,
2329            smpte_format,
2330            smpte_offset,
2331            c_sample_loops_claimed,
2332            cb_sampler_data,
2333        ] {
2334            b.extend_from_slice(&v.to_le_bytes());
2335        }
2336        for &(id, ty, start, end, frac, count) in loops {
2337            for v in [id, ty, start, end, frac, count] {
2338                b.extend_from_slice(&v.to_le_bytes());
2339            }
2340        }
2341        b
2342    }
2343
2344    /// Full `smpl` round-trip: one loop, every fixed-header field and
2345    /// the per-loop record surface under the documented metadata keys.
2346    /// SMPTE offset is decoded as `HH:MM:SS:FF`.
2347    #[test]
2348    fn smpl_full_metadata() {
2349        // SMPTE offset 0x01020304 → 01:02:03:04 (HH MM SS FF).
2350        let body = smpl_body(
2351            0x1234,                   // manufacturer
2352            0xDEAD_BEEF,              // product
2353            22_675,                   // sample period (ns; ≈ 44.1 kHz)
2354            60,                       // MIDI middle-C
2355            0x8000_0000,              // MIDI pitch fraction (½ semitone)
2356            30,                       // SMPTE 30 fps
2357            0x01_02_03_04,            // SMPTE offset HH:MM:SS:FF
2358            1,                        // one sample loop
2359            0,                        // no sampler-specific data
2360            &[(7, 0, 0, 1000, 0, 0)], // cue id 7, fwd loop, 0..1000, infinite
2361        );
2362        let bytes = wav_with_smpl_and_inst(Some(&body), None);
2363        let dmx = open_demux_from_bytes(bytes);
2364        let md: std::collections::HashMap<String, String> =
2365            dmx.metadata().iter().cloned().collect();
2366        assert_eq!(md.get("wav:smpl.manufacturer"), Some(&"4660".to_string()));
2367        assert_eq!(md.get("wav:smpl.product"), Some(&"3735928559".to_string()));
2368        assert_eq!(md.get("wav:smpl.sample_period"), Some(&"22675".to_string()));
2369        assert_eq!(md.get("wav:smpl.midi_unity_note"), Some(&"60".to_string()));
2370        assert_eq!(
2371            md.get("wav:smpl.midi_pitch_fraction"),
2372            Some(&"2147483648".to_string())
2373        );
2374        assert_eq!(md.get("wav:smpl.smpte_format"), Some(&"30".to_string()));
2375        assert_eq!(
2376            md.get("wav:smpl.smpte_offset"),
2377            Some(&"01:02:03:04".to_string())
2378        );
2379        assert_eq!(md.get("wav:smpl.sampler_data_len"), Some(&"0".to_string()));
2380        assert_eq!(md.get("wav:smpl.num_sample_loops"), Some(&"1".to_string()));
2381        assert_eq!(
2382            md.get("wav:smpl.loop.0.cue_point_id"),
2383            Some(&"7".to_string())
2384        );
2385        assert_eq!(md.get("wav:smpl.loop.0.type"), Some(&"0".to_string()));
2386        assert_eq!(md.get("wav:smpl.loop.0.start"), Some(&"0".to_string()));
2387        assert_eq!(md.get("wav:smpl.loop.0.end"), Some(&"1000".to_string()));
2388        assert_eq!(md.get("wav:smpl.loop.0.fraction"), Some(&"0".to_string()));
2389        assert_eq!(md.get("wav:smpl.loop.0.play_count"), Some(&"0".to_string()));
2390        // Stream still opens cleanly.
2391        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
2392    }
2393
2394    /// A `smpl` chunk whose `cSampleLoops` count exceeds the records
2395    /// that actually fit in the chunk body is clamped to the records
2396    /// the body carries — defensive vs. writers that lie about the
2397    /// count (mirrors the `cue ` chunk's clamping behaviour).
2398    #[test]
2399    fn smpl_loop_count_clamped_to_body() {
2400        // Claim 5 loops but provide only 1 — parser must surface
2401        // num_sample_loops=1.
2402        let body = smpl_body(
2403            0,
2404            0,
2405            0,
2406            60,
2407            0,
2408            0,
2409            0,
2410            /* claim */ 5,
2411            0,
2412            &[(1, 0, 0, 100, 0, 0)],
2413        );
2414        let bytes = wav_with_smpl_and_inst(Some(&body), None);
2415        let dmx = open_demux_from_bytes(bytes);
2416        let md: std::collections::HashMap<String, String> =
2417            dmx.metadata().iter().cloned().collect();
2418        assert_eq!(md.get("wav:smpl.num_sample_loops"), Some(&"1".to_string()));
2419        // Only loop.0 should be present; loop.1.. must not be emitted.
2420        assert!(md.contains_key("wav:smpl.loop.0.cue_point_id"));
2421        assert!(!md.contains_key("wav:smpl.loop.1.cue_point_id"));
2422        assert!(!md.contains_key("wav:smpl.loop.4.cue_point_id"));
2423    }
2424
2425    /// A `smpl` chunk shorter than the 36-byte fixed struct is
2426    /// malformed; the parser must skip it without panicking and the
2427    /// stream must still open.
2428    #[test]
2429    fn smpl_truncated_is_skipped() {
2430        let body = vec![0u8; 20]; // < 36
2431        let bytes = wav_with_smpl_and_inst(Some(&body), None);
2432        let dmx = open_demux_from_bytes(bytes);
2433        let md: std::collections::HashMap<String, String> =
2434            dmx.metadata().iter().cloned().collect();
2435        assert!(md.keys().all(|k| !k.starts_with("wav:smpl.")));
2436        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
2437    }
2438
2439    /// Full `inst` round-trip: signed `FineTune` / `Gain` are decoded
2440    /// as i8 (so `-1` shows as `-1`, not `255`), MIDI note fields are
2441    /// unsigned.
2442    #[test]
2443    fn inst_full_metadata() {
2444        // FineTune = -3 cents (0xFD), Gain = -6 dB (0xFA).
2445        let body: Vec<u8> = vec![60, 0xFD, 0xFA, 36, 96, 1, 127];
2446        let bytes = wav_with_smpl_and_inst(None, Some(&body));
2447        let dmx = open_demux_from_bytes(bytes);
2448        let md: std::collections::HashMap<String, String> =
2449            dmx.metadata().iter().cloned().collect();
2450        assert_eq!(md.get("wav:inst.unshifted_note"), Some(&"60".to_string()));
2451        assert_eq!(md.get("wav:inst.fine_tune"), Some(&"-3".to_string()));
2452        assert_eq!(md.get("wav:inst.gain"), Some(&"-6".to_string()));
2453        assert_eq!(md.get("wav:inst.low_note"), Some(&"36".to_string()));
2454        assert_eq!(md.get("wav:inst.high_note"), Some(&"96".to_string()));
2455        assert_eq!(md.get("wav:inst.low_velocity"), Some(&"1".to_string()));
2456        assert_eq!(md.get("wav:inst.high_velocity"), Some(&"127".to_string()));
2457        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
2458    }
2459
2460    /// An `inst` chunk shorter than the 7-byte fixed struct is skipped
2461    /// as opaque and the stream still resolves.
2462    #[test]
2463    fn inst_truncated_is_skipped() {
2464        let body = vec![0u8; 5]; // < 7
2465        let bytes = wav_with_smpl_and_inst(None, Some(&body));
2466        let dmx = open_demux_from_bytes(bytes);
2467        let md: std::collections::HashMap<String, String> =
2468            dmx.metadata().iter().cloned().collect();
2469        assert!(md.keys().all(|k| !k.starts_with("wav:inst.")));
2470        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
2471    }
2472
2473    /// Both `smpl` and `inst` chunks present in the same file surface
2474    /// under their respective key namespaces without colliding. The
2475    /// odd-length `inst` chunk forces the 1-byte word-pad path; the
2476    /// `data` chunk that follows must still be located.
2477    #[test]
2478    fn smpl_and_inst_coexist_with_padding() {
2479        let smpl = smpl_body(0, 0, 0, 64, 0, 0, 0, 0, 0, &[]);
2480        let inst: Vec<u8> = vec![64, 0, 0, 0, 127, 1, 127]; // 7 bytes → odd
2481        let bytes = wav_with_smpl_and_inst(Some(&smpl), Some(&inst));
2482        let dmx = open_demux_from_bytes(bytes);
2483        let md: std::collections::HashMap<String, String> =
2484            dmx.metadata().iter().cloned().collect();
2485        assert_eq!(md.get("wav:smpl.midi_unity_note"), Some(&"64".to_string()));
2486        assert_eq!(md.get("wav:inst.unshifted_note"), Some(&"64".to_string()));
2487        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
2488    }
2489
2490    /// Build a minimal valid PCM WAV with a caller-supplied raw `plst`
2491    /// chunk inserted between `fmt ` and `data`. Mirrors
2492    /// `wav_with_cue_and_adtl` but for the playlist chunk alone — the
2493    /// playlist references cue ids but is parsed independently of any
2494    /// preceding `cue ` chunk.
2495    fn wav_with_plst(plst_body: &[u8]) -> Vec<u8> {
2496        let mut buf = Vec::new();
2497        buf.extend_from_slice(b"RIFF");
2498        buf.extend_from_slice(&0u32.to_le_bytes());
2499        buf.extend_from_slice(b"WAVE");
2500        // fmt : 16-byte PCM s16 mono 8000 Hz.
2501        buf.extend_from_slice(b"fmt ");
2502        buf.extend_from_slice(&16u32.to_le_bytes());
2503        buf.extend_from_slice(&FMT_PCM.to_le_bytes());
2504        buf.extend_from_slice(&1u16.to_le_bytes());
2505        buf.extend_from_slice(&8_000u32.to_le_bytes());
2506        buf.extend_from_slice(&16_000u32.to_le_bytes());
2507        buf.extend_from_slice(&2u16.to_le_bytes());
2508        buf.extend_from_slice(&16u16.to_le_bytes());
2509        // plst chunk
2510        buf.extend_from_slice(b"plst");
2511        buf.extend_from_slice(&(plst_body.len() as u32).to_le_bytes());
2512        buf.extend_from_slice(plst_body);
2513        if plst_body.len() % 2 == 1 {
2514            buf.push(0);
2515        }
2516        // empty data chunk
2517        buf.extend_from_slice(b"data");
2518        buf.extend_from_slice(&0u32.to_le_bytes());
2519        buf
2520    }
2521
2522    /// Build a single 12-byte `<play-segment>` record per
2523    /// `docs/container/riff/metadata/microsoft-riffmci.pdf` §3.
2524    fn plst_segment(dw_name: u32, dw_length: u32, dw_loops: u32) -> Vec<u8> {
2525        let mut b = Vec::with_capacity(12);
2526        b.extend_from_slice(&dw_name.to_le_bytes());
2527        b.extend_from_slice(&dw_length.to_le_bytes());
2528        b.extend_from_slice(&dw_loops.to_le_bytes());
2529        b
2530    }
2531
2532    /// Full `plst` round-trip: three play segments referencing cue ids
2533    /// 1, 2, 1 (replaying cue 1) surface under index-keyed metadata.
2534    /// The replay case is the reason segments are indexed by position
2535    /// rather than by `dwName`.
2536    #[test]
2537    fn plst_full_metadata() {
2538        let mut plst_body = Vec::new();
2539        plst_body.extend_from_slice(&3u32.to_le_bytes()); // dwSegments
2540        plst_body.extend(plst_segment(1, 4410, 1)); // 0.1s of cue 1
2541        plst_body.extend(plst_segment(2, 8820, 2)); // 0.2s of cue 2, twice
2542        plst_body.extend(plst_segment(1, 4410, 1)); // replay cue 1
2543
2544        let bytes = wav_with_plst(&plst_body);
2545        let dmx = open_demux_from_bytes(bytes);
2546        let md: std::collections::HashMap<String, String> =
2547            dmx.metadata().iter().cloned().collect();
2548
2549        assert_eq!(md.get("wav:plst.count"), Some(&"3".to_string()));
2550        assert_eq!(md.get("wav:plst.0.cue_id"), Some(&"1".to_string()));
2551        assert_eq!(md.get("wav:plst.0.length"), Some(&"4410".to_string()));
2552        assert_eq!(md.get("wav:plst.0.loops"), Some(&"1".to_string()));
2553        assert_eq!(md.get("wav:plst.1.cue_id"), Some(&"2".to_string()));
2554        assert_eq!(md.get("wav:plst.1.length"), Some(&"8820".to_string()));
2555        assert_eq!(md.get("wav:plst.1.loops"), Some(&"2".to_string()));
2556        assert_eq!(md.get("wav:plst.2.cue_id"), Some(&"1".to_string()));
2557        assert_eq!(md.get("wav:plst.2.length"), Some(&"4410".to_string()));
2558        assert_eq!(md.get("wav:plst.2.loops"), Some(&"1".to_string()));
2559    }
2560
2561    /// A `plst` chunk whose `dwSegments` count exceeds the body length
2562    /// must not panic — the parser surfaces only the records that
2563    /// actually fit in the body (defensive against writers that lie
2564    /// about the count, matching the `cue ` clamp behaviour).
2565    #[test]
2566    fn plst_truncated_count_is_clamped() {
2567        // Claim 10 segments, ship 1.
2568        let mut plst_body = Vec::new();
2569        plst_body.extend_from_slice(&10u32.to_le_bytes());
2570        plst_body.extend(plst_segment(42, 1000, 1));
2571        let bytes = wav_with_plst(&plst_body);
2572        let dmx = open_demux_from_bytes(bytes);
2573        let md: std::collections::HashMap<String, String> =
2574            dmx.metadata().iter().cloned().collect();
2575        assert_eq!(md.get("wav:plst.count"), Some(&"1".to_string()));
2576        assert_eq!(md.get("wav:plst.0.cue_id"), Some(&"42".to_string()));
2577        // Stream still opens cleanly.
2578        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
2579    }
2580
2581    /// A `plst` chunk shorter than the 4-byte `dwSegments` header is
2582    /// treated as opaque and skipped — no metadata keys emitted, stream
2583    /// still opens.
2584    #[test]
2585    fn plst_truncated_header_is_opaque() {
2586        let plst_body = vec![0u8, 0]; // < 4 bytes
2587        let bytes = wav_with_plst(&plst_body);
2588        let dmx = open_demux_from_bytes(bytes);
2589        let md: std::collections::HashMap<String, String> =
2590            dmx.metadata().iter().cloned().collect();
2591        assert!(md.keys().all(|k| !k.starts_with("wav:plst.")));
2592        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
2593    }
2594
2595    /// A zero-segment `plst` chunk surfaces `wav:plst.count = 0` with
2596    /// no per-segment keys.
2597    #[test]
2598    fn plst_zero_segments() {
2599        let plst_body = 0u32.to_le_bytes().to_vec();
2600        let bytes = wav_with_plst(&plst_body);
2601        let dmx = open_demux_from_bytes(bytes);
2602        let md: std::collections::HashMap<String, String> =
2603            dmx.metadata().iter().cloned().collect();
2604        assert_eq!(md.get("wav:plst.count"), Some(&"0".to_string()));
2605        assert!(md
2606            .keys()
2607            .all(|k| !k.starts_with("wav:plst.") || k == "wav:plst.count"));
2608    }
2609
2610    /// An odd-length `plst` body forces a pad byte; the `data` chunk
2611    /// that follows must still be located correctly.
2612    #[test]
2613    fn plst_odd_body_padding() {
2614        // One 12-byte segment + an extra trailing byte → 17 bytes (odd).
2615        let mut plst_body = Vec::new();
2616        plst_body.extend_from_slice(&1u32.to_le_bytes());
2617        plst_body.extend(plst_segment(5, 100, 1));
2618        plst_body.push(0xAA);
2619        let bytes = wav_with_plst(&plst_body);
2620        let dmx = open_demux_from_bytes(bytes);
2621        let md: std::collections::HashMap<String, String> =
2622            dmx.metadata().iter().cloned().collect();
2623        assert_eq!(md.get("wav:plst.count"), Some(&"1".to_string()));
2624        assert_eq!(md.get("wav:plst.0.cue_id"), Some(&"5".to_string()));
2625        assert_eq!(dmx.streams()[0].params.codec_id, CodecId::new("pcm_s16le"));
2626    }
2627
2628    /// `fmt_guid` produces the canonical text form: first three groups
2629    /// little-endian, trailing two big-endian. This is the format
2630    /// `mmreg.h` definitions use (e.g. `KSDATAFORMAT_SUBTYPE_PCM` =
2631    /// `00000001-0000-0010-8000-00AA00389B71`).
2632    #[test]
2633    fn guid_canonical_text() {
2634        assert_eq!(fmt_guid(&GUID_PCM), "00000001-0000-0010-8000-00AA00389B71");
2635        assert_eq!(
2636            fmt_guid(&GUID_IEEE_FLOAT),
2637            "00000003-0000-0010-8000-00AA00389B71"
2638        );
2639        assert_eq!(fmt_guid(&GUID_ALAW), "00000006-0000-0010-8000-00AA00389B71");
2640        assert_eq!(
2641            fmt_guid(&GUID_MULAW),
2642            "00000007-0000-0010-8000-00AA00389B71"
2643        );
2644    }
2645}