Skip to main content

oxideav_ac4/
decoder.rs

1//! Foundation AC-4 decoder.
2//!
3//! Given a packet that carries either a full `ac4_syncframe()` (the
4//! TS/RTP form) or a bare `raw_ac4_frame()` payload (the ISO BMFF MP4
5//! sample form), the decoder:
6//!
7//! 1. Scans for the `0xAC40` / `0xAC41` sync word; if not found, treats
8//!    the full packet as a bare payload.
9//! 2. Runs [`toc::parse_ac4_toc`] to extract the channel count, effective
10//!    sample rate and frame length.
11//! 3. Emits an `AudioFrame` full of zero S16 samples with the correct
12//!    shape.
13//!
14//! This is not a real AC-4 decoder — decoding the ASF / A-SPX / ASF-A2
15//! substream coefficient streams is spec work measured in weeks. What
16//! it *does* give us is a clean path for the rest of the oxideav
17//! pipeline (demuxer → decoder → filter → output) to run end-to-end
18//! against real AC-4 fixtures without panics, plus a parsed
19//! [`toc::Ac4FrameInfo`] surface for downstream tooling.
20
21use oxideav_core::Decoder;
22#[cfg(test)]
23use oxideav_core::TimeBase;
24use oxideav_core::{AudioFrame, CodecId, CodecParameters, Error, Frame, Packet, Result};
25
26use crate::{acpl_synth, asf, aspx, mdct, qmf, ssf, ssf_synth, sync, toc};
27
28pub fn make_decoder(params: &CodecParameters) -> Result<Box<dyn Decoder>> {
29    Ok(Box::new(Ac4Decoder::new(params)))
30}
31
32pub struct Ac4Decoder {
33    codec_id: CodecId,
34    /// Channel count hint supplied by the container (CodecParameters).
35    /// Used as a fallback when the TOC's channel-mode code is one of
36    /// the reserved/escape values.
37    hint_channels: u16,
38    /// Sample-rate hint from the container.
39    hint_sample_rate: u32,
40    pending: Option<Packet>,
41    eof: bool,
42    /// Last parsed frame info — exposed for downstream inspection.
43    pub last_info: Option<toc::Ac4FrameInfo>,
44    /// Last parsed substream tool summary (first substream of the last
45    /// decoded frame). `None` when the TOC didn't expose a usable size
46    /// for the substream (e.g. single-substream frame where
47    /// `b_size_present == 0`).
48    pub last_substream: Option<asf::Ac4SubstreamInfo>,
49    /// Per-channel overlap-add state (length = transform_length samples).
50    /// Keyed by channel index; resized on transform-length change.
51    overlap: Vec<Vec<f32>>,
52    /// Transform length of the previous frame (for overlap sizing).
53    prev_transform_length: u32,
54    /// Per-channel A-SPX persistent state — noise generator
55    /// `noise_idx_prev` (§5.7.6.4.3 Pseudocode 103), tone generator
56    /// `sine_idx_prev` (§5.7.6.4.4 Pseudocode 105), and the
57    /// `sine_idx_sb_prev` / `tsg_ptr_prev` / `num_atsg_sig_prev` bundle
58    /// that Pseudocode 92 consults. Grown on demand as channels decode.
59    aspx_ext_state: Vec<aspx::AspxChannelExtState>,
60    /// Per-substream A-CPL persistent state for the channel-pair
61    /// element (§5.7.7.5 Pseudocode 115). Only one substream is wired
62    /// in the foundation decoder; multichannel `ASPX_ACPL_3` would carry
63    /// a vector keyed by substream index.
64    acpl_state: acpl_synth::AcplSubstreamState,
65    /// Per-substream state for the 5_X `ASPX_ACPL_1` / `ASPX_ACPL_2`
66    /// pair pipeline (§5.7.7.6.1 Pseudocode 117). Carries the pair
67    /// decorrelator + QMF analysis/synthesis banks across frames.
68    acpl_5x_pair_state: acpl_synth::Acpl5xPairPcmState,
69    /// Per-substream state for the 5_X `ASPX_ACPL_3` multichannel
70    /// synthesis pipeline (§5.7.7.6.2 Pseudocode 118). Carries the
71    /// D0/D1/D2 + ducker IIR state + differential-decode rolling sums
72    /// across frames.
73    acpl_5x_mch_state: acpl_synth::Acpl5xMchPcmState,
74    /// Per-channel SSF synthesis state — RNGs, predictor lag history,
75    /// subband-predictor spec/env buffers, and the previous block's
76    /// `f_spec[]` latch. Grown on demand as channels decode SSF.
77    ssf_synth_state: Vec<ssf_synth::SsfSynthState>,
78    /// Per-channel SSF *walker* state — the bitstream-side dither /
79    /// noise RNG, `prev_pred_lag_idx`, `last_num_bands`, and the
80    /// `env_prev[]` snapshot of raw delta symbols. Hoisted onto the
81    /// decoder in round 32 so RNG continuity (Pseudocodes 54-57) is
82    /// preserved across frame boundaries; pre-r32 the walker built a
83    /// fresh per-frame state and dropped it. Grown on demand to match
84    /// the channel count seen on the latest frame.
85    ssf_walker_state: Vec<ssf::SsfChannelState>,
86}
87
88/// Phase-1 result of [`Ac4Decoder::aspx_extend_to_qmf`]:
89/// `(qmf_matrix, sbx, sbz)` — the post-extension QMF matrix and the
90/// (sb0, sb1) range needed by the §5.7.5 companding tool.
91type AspxQmfPhase1 = (aspx::QmfMatrix, u32, u32);
92
93/// One per-channel entry consumed by
94/// [`Ac4Decoder::extend_5x_channels_with_sync_companding`].
95type SyncCompandingChannelEntry<'a> = (
96    usize,                             // output slot (0..=4)
97    &'a [f32],                         // pcm_in
98    &'a aspx::FiveXAspxTrailer,        // trailer
99    &'a aspx::FiveXAspxChannelTrailer, // channel trailer
100    &'a aspx::AspxConfig,              // aspx config
101    Option<u32>,                       // sb0 override (acpl_qmf_band)
102);
103
104/// One per-channel entry consumed by [`Ac4Decoder::extend_5x_entries`]:
105/// `(slot, pcm_f32, trailer_pair)` where `trailer_pair` is
106/// `(trailer, is_secondary)` — `None` when the channel has no trailer
107/// (passthrough case).
108type FiveXChannelEntry<'a> = (usize, Vec<f32>, Option<(&'a aspx::FiveXAspxTrailer, bool)>);
109
110/// Round 45: per-channel input bundle for the stereo-CPE M=2 synced
111/// companding helper [`Ac4Decoder::extend_stereo_cpe_pair_with_sync_companding`].
112/// Mirrors the per-channel arguments of [`Ac4Decoder::aspx_extend_pcm`]
113/// (the un-trailerised stereo-CPE form used by the primary / secondary
114/// dispatch path) so the two-channel cohort can run phase-1 → synced
115/// companding apply → phase-2 in lockstep.
116struct StereoCpeChannelInput<'a> {
117    /// Decoder-local channel index used to pick the right
118    /// `aspx_ext_state[ch_index]` carry-over (0 for primary, 1 for
119    /// secondary on a 2-channel CPE).
120    ch_index: usize,
121    /// IMDCT'd low-band PCM for this channel; the helper runs forward
122    /// QMF + HF generation + envelope adjustment + companding +
123    /// inverse QMF on this buffer.
124    pcm_in: &'a [f32],
125    /// `aspx_framing()` for this channel (per-channel in stereo CPE).
126    framing: Option<&'a aspx::AspxFraming>,
127    /// `aspx_data_sig` Huffman envelopes for this channel.
128    sig: Option<&'a [aspx::AspxHuffEnv]>,
129    /// `aspx_data_noise` Huffman envelopes for this channel.
130    noise: Option<&'a [aspx::AspxHuffEnv]>,
131    /// `aspx_qmode_env` quant-step for this channel's envelopes.
132    qmode: Option<aspx::AspxQuantStep>,
133    /// Per-envelope sign of the dpcm directionality (`f` flag in the
134    /// spec): `true` = freq-direction, `false` = time-direction.
135    delta_dir: Option<&'a aspx::AspxDeltaDir>,
136    /// `aspx_hfgen_iwc.add_harmonic[ch]` for tone injection.
137    add_harmonic: Option<&'a [bool]>,
138    /// `aspx_hfgen_iwc.tna_mode[ch]` for the chirp + α0 + α1 TNS body.
139    tna_mode: Option<&'a [u8]>,
140}
141
142impl Ac4Decoder {
143    pub fn new(params: &CodecParameters) -> Self {
144        Self {
145            codec_id: params.codec_id.clone(),
146            hint_channels: params.channels.unwrap_or(2),
147            hint_sample_rate: params.sample_rate.unwrap_or(48_000),
148            pending: None,
149            eof: false,
150            last_info: None,
151            last_substream: None,
152            overlap: Vec::new(),
153            prev_transform_length: 0,
154            aspx_ext_state: Vec::new(),
155            acpl_state: acpl_synth::AcplSubstreamState::new(),
156            acpl_5x_pair_state: acpl_synth::Acpl5xPairPcmState::new(),
157            acpl_5x_mch_state: acpl_synth::Acpl5xMchPcmState::new(),
158            ssf_synth_state: Vec::new(),
159            ssf_walker_state: Vec::new(),
160        }
161    }
162
163    fn extract_raw_frame<'a>(&self, pkt: &'a Packet) -> (&'a [u8], bool) {
164        if let Some(f) = sync::find_sync_frame(&pkt.data) {
165            (f.payload, true)
166        } else {
167            (pkt.data.as_slice(), false)
168        }
169    }
170
171    /// Run the A-SPX bandwidth-extension pipeline on a block of
172    /// low-band PCM (produced by the core ASF/MDCT path) using the
173    /// derived A-SPX frequency tables: forward QMF, HF tile-copy via
174    /// the patch subband groups (§5.7.6.3.1.4 + §5.7.6.4.1.4
175    /// simplified), per-envelope HF envelope adjustment gains
176    /// (§5.7.6.4.2 Pseudocodes 90 / 91 / 95) when the substream
177    /// carried envelope deltas, noise + tone injection (§5.7.6.4.3 P102,
178    /// §5.7.6.4.4 P104, §5.7.6.4.5 P107/P108) driven by `add_harmonic`
179    /// flags + `scf_sig_sb` / `scf_noise_sb` (Pseudocode 92/94), and
180    /// otherwise a flat 0.5 gain scaffold. Finally runs inverse QMF
181    /// synthesis. Returns the bandwidth-extended PCM (f32) aligned to
182    /// the input PCM after accounting for the combined QMF group delay.
183    ///
184    /// `state` carries the noise/tone/sine-idx index state across calls
185    /// (one per decoder channel). `add_harmonic` is from the parsed
186    /// `aspx_hfgen_iwc_*` (Table 55/56) — empty/None if the substream
187    /// didn't carry it, in which case the tone generator stays silent
188    /// but noise still injects if envelope deltas are available.
189    /// `tna_mode` is `aspx_tna_mode[sbg]` from the same hfgen payload;
190    /// when present + FIXFIX framing, the HF generator runs the full
191    /// §5.7.6.4.1.3 chirp + α0 + α1 TNS body (Pseudocodes 86 → 89)
192    /// instead of the bare tile copy.
193    ///
194    /// If any preconditions fail (length not a multiple of 64, tables
195    /// missing, sbx >= 64) the original PCM is returned unchanged.
196    #[allow(clippy::too_many_arguments)]
197    fn aspx_extend_pcm(
198        pcm_in: &[f32],
199        tables: &aspx::AspxFrequencyTables,
200        cfg: &aspx::AspxConfig,
201        framing: Option<&aspx::AspxFraming>,
202        sig_deltas: Option<&[aspx::AspxHuffEnv]>,
203        noise_deltas: Option<&[aspx::AspxHuffEnv]>,
204        qmode_env: Option<aspx::AspxQuantStep>,
205        delta_dir: Option<&aspx::AspxDeltaDir>,
206        add_harmonic: Option<&[bool]>,
207        // §5.7.6.4.1.3 Pseudocode 88 — `aspx_tna_mode[sbg]` per noise
208        // subband group, drives chirp + α0 + α1 TNS path. `None` falls
209        // back to the bare HF tile copy.
210        tna_mode: Option<&[u8]>,
211        state: &mut aspx::AspxChannelExtState,
212        num_ts_in_ats: u32,
213        // Round 43: §5.7.5 companding tool — applied on the QMF matrix
214        // between envelope adjustment and QMF synthesis. `mode` selects
215        // the Pseudocode 121 sub-branch (`Off` / `PerSlot` / `Averaged`
216        // / `SyncPerSlot` / `SyncAveraged`). `sb0_override == Some(b)`
217        // overrides the lower band edge with `acpl_qmf_band` for the
218        // ASPX_ACPL_1 codec mode (per §5.7.5.2 sb0 selection); `None`
219        // falls back to `tables.sbx` (the A-SPX crossover, default
220        // for ASPX / SIMPLE).
221        compand_mode: aspx::CompandingMode,
222        compand_sb0_override: Option<u32>,
223    ) -> Vec<f32> {
224        let extended = Self::aspx_extend_to_qmf(
225            pcm_in,
226            tables,
227            cfg,
228            framing,
229            sig_deltas,
230            noise_deltas,
231            qmode_env,
232            delta_dir,
233            add_harmonic,
234            tna_mode,
235            state,
236            num_ts_in_ats,
237        );
238        match extended {
239            Some((mut q, sbx_eff, sbz_eff)) => {
240                let compand_sb0 = compand_sb0_override.unwrap_or(sbx_eff);
241                aspx::apply_companding_on_qmf_with_mode(&mut q, compand_sb0, sbz_eff, compand_mode);
242                Self::qmf_synthesise_pcm(&q, pcm_in.len())
243            }
244            None => pcm_in.to_vec(),
245        }
246    }
247
248    /// Round 44: phase-1 of the A-SPX HF-extension pipeline — runs the
249    /// QMF analysis, HF generation (TNS / tile-copy), envelope
250    /// adjustment + noise / tone injection, and updates `state` — but
251    /// stops BEFORE the §5.7.5 companding gain and the inverse-QMF
252    /// synthesis. Returns the post-extension QMF matrix `q[sb][ts]`
253    /// along with the (`sbx`, `sbz`) the companding tool will need.
254    ///
255    /// Returns `None` (and leaves `state` untouched in the same
256    /// preconditions [`aspx_extend_pcm`] historically returned the
257    /// input PCM verbatim) when the input fails the multiple-of-64
258    /// length check, the frequency tables are degenerate, or the
259    /// patches couldn't be derived.
260    ///
261    /// This split exists so that cross-channel synchronised companding
262    /// (`sync_flag == 1`, see [`aspx::apply_synchronised_companding_across_channels`])
263    /// can collect every channel's QMF matrix, compute the
264    /// geometric-mean gain across them, then apply the synced gain
265    /// uniformly before each channel runs its own synthesis via
266    /// [`Self::qmf_synthesise_pcm`].
267    #[allow(clippy::too_many_arguments)]
268    fn aspx_extend_to_qmf(
269        pcm_in: &[f32],
270        tables: &aspx::AspxFrequencyTables,
271        cfg: &aspx::AspxConfig,
272        framing: Option<&aspx::AspxFraming>,
273        sig_deltas: Option<&[aspx::AspxHuffEnv]>,
274        noise_deltas: Option<&[aspx::AspxHuffEnv]>,
275        qmode_env: Option<aspx::AspxQuantStep>,
276        delta_dir: Option<&aspx::AspxDeltaDir>,
277        add_harmonic: Option<&[bool]>,
278        tna_mode: Option<&[u8]>,
279        state: &mut aspx::AspxChannelExtState,
280        num_ts_in_ats: u32,
281    ) -> Option<AspxQmfPhase1> {
282        const NUM_QMF: usize = qmf::NUM_QMF_SUBBANDS;
283        // Need PCM length as a multiple of 64 for whole QMF slots.
284        if pcm_in.is_empty() || pcm_in.len() % NUM_QMF != 0 {
285            return None;
286        }
287        let sbx = tables.sbx as usize;
288        let sbz = tables.sbz as usize;
289        if sbx == 0 || sbx >= NUM_QMF || sbz <= sbx || sbz > NUM_QMF {
290            return None;
291        }
292        let n_slots = pcm_in.len() / NUM_QMF;
293        // Forward QMF analysis on the low-band PCM.
294        let mut ana = qmf::QmfAnalysisBank::new();
295        let slots = ana.process_block(pcm_in);
296        // Re-layout to q[sb][ts].
297        let mut q: Vec<Vec<(f32, f32)>> = (0..NUM_QMF)
298            .map(|_| vec![(0.0f32, 0.0f32); n_slots])
299            .collect();
300        for (ts, slot) in slots.iter().enumerate() {
301            for (sb, s) in slot.iter().enumerate() {
302                q[sb][ts] = *s;
303            }
304        }
305        // Derive patches from the master-freq-scale tables. 48 kHz
306        // family is the only base_samp_freq wired in the current
307        // TOC-driven pipeline; 44.1 kHz would pass `false` instead.
308        let is_highres = matches!(cfg.master_freq_scale, aspx::AspxMasterFreqScale::HighRes);
309        let patches = aspx::derive_patch_tables(
310            &tables.sbg_master,
311            tables.num_sbg_master,
312            tables.sba,
313            tables.sbx,
314            tables.num_sb_aspx,
315            true,
316            is_highres,
317        );
318        if patches.num_sbg_patches == 0 {
319            return None;
320        }
321        // Truncate the high band (ASPX substreams only carry spectral
322        // data up to sbx in the core path; the bandwidth-extension
323        // tool is responsible for filling sbx..sbz).
324        for row in q.iter_mut().skip(sbx) {
325            for sample in row.iter_mut() {
326                *sample = (0.0, 0.0);
327            }
328        }
329        // HF generation: when the substream gave us aspx_tna_mode + a
330        // framing we can derive atsg_sig and run the full §5.7.6.4.1.3
331        // / .4 TNS body (Pseudocodes 86 → 89). Covers FIXFIX (Pseudocode
332        // 76) and variable interval classes (Pseudocode 77).
333        // Otherwise fall back to the bare tile copy in §5.7.6.4.1.4.
334        let mut tns_used = false;
335        if let (Some(tna), Some(frm)) = (tna_mode, framing) {
336            let num_aspx_ts = (n_slots as u32) / num_ts_in_ats.max(1);
337            let atsg_sig_opt = aspx::derive_atsg_borders(num_aspx_ts, frm).map(|(s, _)| s);
338            if let Some(atsg_sig) = atsg_sig_opt {
339                if !tna.is_empty() {
340                    let q_low_ext =
341                        crate::aspx_tns::build_q_low_ext(&q, &state.q_low_prev, tables.sba);
342                    let cov = crate::aspx_tns::compute_covariance(&q_low_ext, tables.sba);
343                    let (alpha0, alpha1) = crate::aspx_tns::compute_alphas(&cov);
344                    let chirp = crate::aspx_tns::chirp_factors(tna, &state.tns);
345                    let gain_vec = if cfg.preflat {
346                        Some(crate::aspx_tns::compute_preflat_gains(
347                            &q,
348                            tables.sbx,
349                            &atsg_sig,
350                            num_ts_in_ats,
351                        ))
352                    } else {
353                        None
354                    };
355                    let q_high = crate::aspx_tns::hf_tile_tns(
356                        &q_low_ext,
357                        &patches,
358                        &tables.sbg_noise,
359                        &chirp.chirp_arr,
360                        &alpha0,
361                        &alpha1,
362                        gain_vec.as_deref(),
363                        tables.sbx,
364                        NUM_QMF as u32,
365                        &atsg_sig,
366                        num_ts_in_ats,
367                    );
368                    for (dst, src) in q.iter_mut().zip(q_high.iter()).take(sbz).skip(sbx) {
369                        let len = dst.len().min(src.len());
370                        dst[..len].copy_from_slice(&src[..len]);
371                    }
372                    crate::aspx_tns::advance_tns_state(&mut state.tns, &chirp);
373                    tns_used = true;
374                }
375            }
376        }
377        if !tns_used {
378            // Bare tile copy (§5.7.6.4.1.4 with chirp/α0/α1 = 0).
379            let q_high = aspx::hf_tile_copy(&q, &patches, tables.sbx, NUM_QMF as u32);
380            for (dst, src) in q.iter_mut().zip(q_high.iter()).take(sbz).skip(sbx) {
381                dst.clone_from(src);
382            }
383        }
384        // Snapshot Q_low for the next interval's Pseudocode 86 prefix.
385        // Only snapshot the actual low-band (sb < sba); the high-band
386        // is what we just synthesised, not part of Q_low.
387        state.q_low_prev = (0..(tables.sba as usize))
388            .map(|sb| {
389                if sb < q.len() {
390                    q[sb].clone()
391                } else {
392                    Vec::new()
393                }
394            })
395            .collect();
396        // Per-envelope HF envelope adjustment (§5.7.6.4.2 Pseudocodes
397        // 90 / 91 / 95) when the bitstream surface carried envelope
398        // deltas, followed by noise + tone injection (§5.7.6.4.3 / .4 /
399        // .5 Pseudocodes 102 / 104 / 107 / 108) when add_harmonic flags
400        // are available. Otherwise fall back to the flat-gain scaffold
401        // so output PCM still has audible HF content.
402        let mut used_envelope = false;
403        if let (Some(frm), Some(sig), Some(noise), Some(qm), Some(dd)) =
404            (framing, sig_deltas, noise_deltas, qmode_env, delta_dir)
405        {
406            let num_aspx_ts = (n_slots as u32) / num_ts_in_ats.max(1);
407            // §5.7.6.3.3.1 Pseudocode 76 (FIXFIX) or §5.7.6.3.3.2
408            // Pseudocode 77 (FIXVAR / VARFIX / VARVAR) border derivation.
409            if let Some((atsg_sig, atsg_noise)) = aspx::derive_atsg_borders(num_aspx_ts, frm) {
410                if sig.len() as u32 == frm.num_env {
411                    let adjuster = aspx::AspxEnvelopeAdjuster::from_deltas(
412                        &q,
413                        tables,
414                        sig,
415                        noise,
416                        qm,
417                        &dd.sig_delta_dir,
418                        &atsg_sig,
419                        &atsg_noise,
420                        num_ts_in_ats,
421                        cfg.interpolation,
422                    );
423                    // Noise + tone injection on top of the
424                    // envelope-adjusted HF. `add_harmonic` is sized
425                    // to `num_sbg_sig_highres`; if the caller didn't
426                    // provide one (no aspx_hfgen_iwc in the
427                    // substream), default to an all-false slice so
428                    // only the noise floor contributes.
429                    let num_sbg_sig_highres = tables.sbg_sig_highres.len().saturating_sub(1);
430                    let default_ah = vec![false; num_sbg_sig_highres];
431                    let ah: &[bool] = match add_harmonic {
432                        Some(s) if s.len() == num_sbg_sig_highres => s,
433                        _ => &default_ah,
434                    };
435                    // tsg_ptr: 0 for FIXFIX (§4.3.10.4.7), from
436                    // framing.tsg_ptr for variable interval classes.
437                    let aspx_tsg_ptr: u32 = frm.tsg_ptr.map(|p| p as u32).unwrap_or(0);
438                    if matches!(frm.int_class, aspx::AspxIntClass::FixFix) && cfg.limiter {
439                        // §5.7.6.4.2.2 limiter pipeline (Pseudocodes
440                        // 96 → 101) replaces the raw sig_gain with
441                        // the boost-corrected sig_gain_sb_adj, so
442                        // do NOT pre-apply adjuster.apply here.
443                        aspx::inject_noise_and_tone_with_limiter(
444                            &mut q,
445                            &adjuster,
446                            tables,
447                            &patches,
448                            &atsg_noise,
449                            ah,
450                            aspx_tsg_ptr,
451                            state,
452                        );
453                    } else {
454                        adjuster.apply(&mut q);
455                        aspx::inject_noise_and_tone(
456                            &mut q,
457                            &adjuster,
458                            tables,
459                            &atsg_noise,
460                            ah,
461                            aspx_tsg_ptr,
462                            state,
463                        );
464                    }
465                    used_envelope = true;
466                }
467            }
468        }
469        if !used_envelope {
470            // Flat envelope gain fallback (scaffold kept for the
471            // non-FIXFIX / missing-envelope paths). Using 0.5 so the
472            // regenerated HF doesn't overwhelm the LF.
473            aspx::apply_flat_envelope_gain(&mut q, tables.sbx, tables.sbz, 0.5);
474            // Reset per-channel envelope/tone carry-over state — the
475            // envelope adjustment didn't run, so its index state has
476            // nothing consistent to advance. Next successful interval
477            // starts at master_reset semantics. The TNS chirp / α0 /
478            // α1 history (`state.tns` + `state.q_low_prev`) is
479            // independent and is *kept* — its update has already been
480            // recorded above when the TNS path ran.
481            state.noise.reset();
482            state.tone.reset();
483            state.sine_idx_sb_prev = None;
484            state.tsg_ptr_prev = 0;
485            state.num_atsg_sig_prev = 0;
486        }
487        // Phase-1 returns the post-extension QMF matrix + the (sbx,
488        // sbz) the §5.7.5 companding tool will need. Companding +
489        // inverse-QMF synthesis happen in `aspx_extend_pcm` (single
490        // channel) or in the caller via
491        // [`aspx::apply_synchronised_companding_across_channels`] +
492        // [`Self::qmf_synthesise_pcm`] (cross-channel sync_flag=1).
493        Some((q, tables.sbx, tables.sbz))
494    }
495
496    /// Round 44: phase-2 of the A-SPX HF-extension pipeline — runs the
497    /// inverse-QMF synthesis on a `q[sb][ts]` matrix and returns
498    /// `out_len`-long PCM. Caller is responsible for having applied
499    /// the §5.7.5 companding gain (per-channel via
500    /// [`aspx::apply_companding_on_qmf_with_mode`] or cross-channel
501    /// via [`aspx::apply_synchronised_companding_across_channels`]).
502    fn qmf_synthesise_pcm(q: &[Vec<(f32, f32)>], out_len: usize) -> Vec<f32> {
503        const NUM_QMF: usize = qmf::NUM_QMF_SUBBANDS;
504        if q.len() < NUM_QMF || out_len == 0 {
505            return Vec::new();
506        }
507        let n_slots = out_len / NUM_QMF;
508        let mut syn = qmf::QmfSynthesisBank::new();
509        let mut out = Vec::with_capacity(out_len);
510        #[allow(clippy::needless_range_loop)] // ETSI TS 103 190-2 §4.4.7 q[sb][ts] indexing
511        for ts in 0..n_slots {
512            let mut slot = [(0.0f32, 0.0f32); NUM_QMF];
513            for (sb, s) in slot.iter_mut().enumerate() {
514                *s = q[sb][ts];
515            }
516            let row = syn.process_slot(&slot);
517            out.extend_from_slice(&row);
518        }
519        out
520    }
521
522    /// Run IMDCT + KBD overlap-add for a single channel, returning
523    /// floating-point PCM (suitable for the A-SPX QMF pipeline).
524    fn imdct_channel_f32(&mut self, ch: usize, scaled: &[f32], n: usize) -> Vec<f32> {
525        // Transform-length change clears *all* channel overlap state so
526        // the next frame starts from a consistent history.
527        if self.prev_transform_length != n as u32 {
528            self.overlap.clear();
529            self.prev_transform_length = n as u32;
530        }
531        while self.overlap.len() <= ch {
532            self.overlap.push(vec![0.0_f32; n]);
533        }
534        if self.overlap[ch].len() != n {
535            self.overlap[ch] = vec![0.0_f32; n];
536        }
537        let mut x = vec![0.0_f32; n];
538        let copy = scaled.len().min(n);
539        x[..copy].copy_from_slice(&scaled[..copy]);
540        let y = mdct::imdct(&x);
541        let window = mdct::kbd_window(n as u32);
542        mdct::imdct_olap_symmetric(&y, &window, &mut self.overlap[ch])
543    }
544
545    /// Convert an f32 PCM buffer to i16, clamping to the i16 range.
546    fn pcm_f32_to_i16(pcm: &[f32]) -> Vec<i16> {
547        pcm.iter()
548            .map(|&s| (s * 32767.0).clamp(-32768.0, 32767.0) as i16)
549            .collect()
550    }
551
552    /// Run IMDCT + KBD overlap-add for a single channel. `ch` indexes
553    /// the per-channel overlap state (grown on demand). `scaled` is the
554    /// dequantised spectrum; bins past `scaled.len()` are zero-padded
555    /// up to N.
556    fn imdct_channel(&mut self, ch: usize, scaled: &[f32], n: usize) -> Vec<i16> {
557        let pcm_f = self.imdct_channel_f32(ch, scaled, n);
558        Self::pcm_f32_to_i16(&pcm_f)
559    }
560
561    /// SSF synthesis: drive §5.2.3-5.2.7 across every granule + block
562    /// in `data`, IMDCT each `n_mdct` block, overlap/add into the
563    /// channel's history, and emit a single
564    /// `frame_samples`-long S16 vector.
565    ///
566    /// Each SSF block produces an `n_mdct`-long spectrum; the IMDCT
567    /// then yields `2 * n_mdct` time-domain samples which the
568    /// overlap-add step combines with the previous block's tail to
569    /// emit `n_mdct` PCM samples. So one granule emits
570    /// `num_blocks * n_mdct = granule_length` samples; one frame's
571    /// `ssf_data` covers the entire frame_length.
572    fn run_ssf_channel(
573        &mut self,
574        ch: usize,
575        data: &crate::ssf::SsfData,
576        frame_samples: usize,
577    ) -> Vec<i16> {
578        // Drive the synth.
579        let state_idx = ch.min(self.ssf_synth_state.len().saturating_sub(1));
580        let mut spec_concat: Vec<f32> = Vec::new();
581        let mut block_lengths: Vec<usize> = Vec::new();
582        for granule in &data.granules {
583            let n_mdct = granule.n_mdct as usize;
584            if n_mdct == 0 {
585                continue;
586            }
587            // env_prev[] for SHORT_STRIDE P-frame interpolation now
588            // lives on `SsfSynthState` and the synth latches the
589            // resolved envelope at the end of each granule, so we pass
590            // an empty slice and let the synth pull the previous
591            // granule's envelope from `state.env_prev` (§5.2.3.0 Note 2).
592            let block =
593                ssf_synth::synthesize_granule(granule, &[], &mut self.ssf_synth_state[state_idx]);
594            // synthesize_granule returns num_blocks * n_mdct; track
595            // each block's n_mdct so the IMDCT loop can split them.
596            for _ in 0..(granule.num_blocks as usize) {
597                block_lengths.push(n_mdct);
598            }
599            spec_concat.extend_from_slice(&block);
600        }
601        if spec_concat.is_empty() || block_lengths.is_empty() {
602            return Vec::new();
603        }
604        // IMDCT each block independently and concat.
605        let mut pcm_out: Vec<f32> = Vec::with_capacity(frame_samples);
606        let mut off = 0usize;
607        for &n in &block_lengths {
608            if off + n > spec_concat.len() {
609                break;
610            }
611            let block_spec = &spec_concat[off..off + n];
612            // Use `imdct_channel_f32` for KBD-windowed overlap-add.
613            // SSF blocks share the channel's overlap state so the
614            // history chains across blocks within a frame.
615            let pcm_block = self.imdct_channel_f32(ch, block_spec, n);
616            pcm_out.extend_from_slice(&pcm_block);
617            off += n;
618        }
619        // Truncate / pad to frame_samples.
620        if pcm_out.len() > frame_samples {
621            pcm_out.truncate(frame_samples);
622        } else if pcm_out.len() < frame_samples {
623            pcm_out.resize(frame_samples, 0.0);
624        }
625        Self::pcm_f32_to_i16(&pcm_out)
626    }
627
628    /// IMDCT a `MonoLfeData` payload's `scaled_spec` to PCM `f32` using
629    /// the channel slot's overlap-add history. Returns `None` if the
630    /// mono shell didn't decode a body (LFE / SSF frontend / Huffman
631    /// miss) or if the carrier transform-length differs from `n`.
632    ///
633    /// `ch` is the per-channel overlap slot index (the centre channel
634    /// uses slot 2 for the 5.X path; surround Ls/Rs use 3/4 etc.).
635    fn imdct_mono_lfe_data_f32(
636        &mut self,
637        mono: &crate::mch::MonoLfeData,
638        ch: usize,
639        n: usize,
640    ) -> Option<Vec<f32>> {
641        let scaled = mono.scaled_spec.as_ref()?;
642        let ti = mono.transform_info.as_ref()?;
643        if ti.transform_length_0 as usize != n {
644            return None;
645        }
646        Some(self.imdct_channel_f32(ch, scaled, n))
647    }
648
649    /// §5.7.7.6.1 ASPX_ACPL_1 / ASPX_ACPL_2 5_X dispatch helper —
650    /// extracted from `receive_frame` so unit tests can drive it
651    /// without building a full 5_X TOC + body.
652    ///
653    /// Carries:
654    /// * `mode` — AspxAcpl1 (carrier-pair + Ls/Rs surround) or
655    ///   AspxAcpl2 (carrier-pair only).
656    /// * `cfg` — single `acpl_config_1ch` shared between both
657    ///   ACplModule's (per Pseudocode 117).
658    /// * `data_1` — `acpl_data_1ch_pair[0]` — L-side parameters.
659    /// * `data_2` — `acpl_data_1ch_pair[1]` — R-side parameters.
660    /// * `samples` — frame length in PCM samples.
661    /// * `centre_pcm` — optional centre channel PCM (already IMDCT +
662    ///   overlap-added). When present and length-matched, used as the
663    ///   `x2` carrier for Pseudocode 117's centre passthrough; when
664    ///   `None`, falls back to silence (round-36 behaviour). Round 37
665    ///   wires this from the parsed `cfg0_centre_mono.scaled_spec`.
666    /// * `ls_pcm` / `rs_pcm` — optional surround Ls/Rs carriers for
667    ///   ASPX_ACPL_1 (Mode 1's `x3`/`x4` driving channels). When `None`
668    ///   and `mode == AspxAcpl1`, falls back to silence (round-36
669    ///   behaviour). Ignored entirely for `AspxAcpl2`.
670    /// * `pcm_per_channel` — slot list. Reads slots 0/1 as L/R carriers
671    ///   (zero-fills if absent); writes slots 0..4 (L/R/C/Ls/Rs) on a
672    ///   successful synthesis.
673    #[allow(clippy::too_many_arguments)]
674    fn dispatch_acpl_5x_pair(
675        &mut self,
676        mode: acpl_synth::Acpl5xPairMode,
677        cfg: &crate::acpl::AcplConfig1ch,
678        data_1: &crate::acpl::AcplData1ch,
679        data_2: &crate::acpl::AcplData1ch,
680        samples: usize,
681        centre_pcm: Option<&[f32]>,
682        ls_pcm: Option<&[f32]>,
683        rs_pcm: Option<&[f32]>,
684        pcm_per_channel: &mut Vec<Option<Vec<i16>>>,
685    ) {
686        let n = samples;
687        // run_acpl_5x_pair_pcm requires every PCM input to be a multiple
688        // of 64 (one QMF slot). Frame length in AC-4 is always a
689        // multiple of 64 by spec, but be defensive.
690        if n == 0 || n % qmf::NUM_QMF_SUBBANDS != 0 {
691            return;
692        }
693        let pcm_l_f32: Vec<f32> = pcm_per_channel
694            .first()
695            .and_then(|p| p.as_ref())
696            .map(|v| v.iter().map(|&s| s as f32 / 32767.0).collect())
697            .unwrap_or_else(|| vec![0.0_f32; n]);
698        let pcm_r_f32: Vec<f32> = pcm_per_channel
699            .get(1)
700            .and_then(|p| p.as_ref())
701            .map(|v| v.iter().map(|&s| s as f32 / 32767.0).collect())
702            .unwrap_or_else(|| vec![0.0_f32; n]);
703        // Centre carrier: real PCM if the caller supplied a length-matched
704        // buffer (round 37 wires this from the parsed centre mono data),
705        // else silence (round-36 placeholder behaviour).
706        let pcm_c_f32: Vec<f32> = match centre_pcm {
707            Some(p) if p.len() == n => p.to_vec(),
708            _ => vec![0.0_f32; n],
709        };
710        // Surround Ls/Rs carriers — only used in ACPL_1 mode. Real PCM
711        // when supplied + length-matched, else silence (round-36
712        // behaviour).
713        let pcm_ls_owned: Option<Vec<f32>> =
714            if matches!(mode, acpl_synth::Acpl5xPairMode::AspxAcpl1) {
715                Some(match ls_pcm {
716                    Some(p) if p.len() == n => p.to_vec(),
717                    _ => vec![0.0_f32; n],
718                })
719            } else {
720                None
721            };
722        let pcm_rs_owned: Option<Vec<f32>> =
723            if matches!(mode, acpl_synth::Acpl5xPairMode::AspxAcpl1) {
724                Some(match rs_pcm {
725                    Some(p) if p.len() == n => p.to_vec(),
726                    _ => vec![0.0_f32; n],
727                })
728            } else {
729                None
730            };
731        if let Some(out) = acpl_synth::run_acpl_5x_pair_pcm(
732            mode,
733            &pcm_l_f32,
734            &pcm_r_f32,
735            &pcm_c_f32,
736            pcm_ls_owned.as_deref(),
737            pcm_rs_owned.as_deref(),
738            cfg,
739            data_1,
740            cfg,
741            data_2,
742            &mut self.acpl_5x_pair_state,
743        ) {
744            // Output channel mapping for 5.0/5.1:
745            //   ch0 = L, ch1 = R, ch2 = C, ch3 = Ls, ch4 = Rs.
746            while pcm_per_channel.len() < 5 {
747                pcm_per_channel.push(None);
748            }
749            pcm_per_channel[0] = Some(Self::pcm_f32_to_i16(&out.left));
750            pcm_per_channel[1] = Some(Self::pcm_f32_to_i16(&out.right));
751            pcm_per_channel[2] = Some(Self::pcm_f32_to_i16(&out.centre));
752            pcm_per_channel[3] = Some(Self::pcm_f32_to_i16(&out.left_surround));
753            pcm_per_channel[4] = Some(Self::pcm_f32_to_i16(&out.right_surround));
754        }
755    }
756
757    /// Apply A-SPX bandwidth-extension to one channel's IMDCT'd PCM
758    /// using a captured 5_X trailer slice. Wraps `aspx_extend_pcm` with
759    /// the trailer's per-channel envelopes / framing / hfgen state and
760    /// the trailer's frequency tables. `slot` indexes the per-channel
761    /// `aspx_ext_state` carry-over so each output slot keeps its own
762    /// noise / tone / TNS history.
763    #[allow(clippy::too_many_arguments)]
764    fn aspx_extend_with_trailer(
765        &mut self,
766        pcm_in: &[f32],
767        trailer: &aspx::FiveXAspxTrailer,
768        ch: &aspx::FiveXAspxChannelTrailer,
769        cfg: &aspx::AspxConfig,
770        slot: usize,
771        num_ts_in_ats: u32,
772        compand_mode: aspx::CompandingMode,
773        compand_sb0_override: Option<u32>,
774    ) -> Vec<f32> {
775        while self.aspx_ext_state.len() <= slot {
776            self.aspx_ext_state.push(aspx::AspxChannelExtState::new());
777        }
778        let state = &mut self.aspx_ext_state[slot];
779        Self::aspx_extend_pcm(
780            pcm_in,
781            &trailer.frequency_tables,
782            cfg,
783            Some(&ch.framing),
784            Some(&ch.data_sig),
785            Some(&ch.data_noise),
786            Some(ch.qmode_env),
787            Some(&ch.delta_dir),
788            ch.add_harmonic.as_deref(),
789            ch.tna_mode.as_deref(),
790            state,
791            num_ts_in_ats,
792            compand_mode,
793            compand_sb0_override,
794        )
795    }
796
797    /// Round 43: per-output-channel companding mode from the captured
798    /// `companding_control(num_chan)` for a 5_X frame. The Cfg2 / Cfg0 /
799    /// Cfg1 / Cfg3 paths all carry `companding_control(5)`, indexed by
800    /// the 5_X output channel `slot` (0..4 in L/R/C/Ls/Rs order). If
801    /// `sync_flag == true`, `compand_on[0]` applies to all five
802    /// channels (Table 116).
803    ///
804    /// Returns `CompandingMode::Off` whenever the parsed flags don't
805    /// reach the requested slot, otherwise resolves to one of the four
806    /// active sub-branches of Pseudocode 121
807    /// (`PerSlot` / `Averaged` / `SyncPerSlot` / `SyncAveraged`) per
808    /// [`aspx::CompandingMode::from_control`].
809    fn five_x_compand_mode_for_slot(
810        cc: Option<&aspx::CompandingControl>,
811        slot: usize,
812    ) -> aspx::CompandingMode {
813        match cc {
814            Some(cc) => aspx::CompandingMode::from_control(cc, slot),
815            None => aspx::CompandingMode::Off,
816        }
817    }
818
819    /// Backward-compat helper kept for round-42 unit tests — returns
820    /// the boolean "is companding active on this slot" derived from
821    /// the resolved [`aspx::CompandingMode`]. New code should call
822    /// [`Self::five_x_compand_mode_for_slot`] directly.
823    fn five_x_compand_on_for_slot(cc: Option<&aspx::CompandingControl>, slot: usize) -> bool {
824        !matches!(
825            Self::five_x_compand_mode_for_slot(cc, slot),
826            aspx::CompandingMode::Off
827        )
828    }
829
830    /// Round 44: cross-channel synchronised A-SPX bandwidth-extension
831    /// for the 5_X SIMPLE/ASPX path when the parsed
832    /// `companding_control()` carries `sync_flag == 1`.
833    ///
834    /// Pseudocode 121's `sync_flag == 1` branch defines the gain as
835    /// `g_synch(ts) = (∏_{ch=0..M} g_ch(ts))^(1/M)` and applies it
836    /// uniformly to every contributing channel — i.e. one cross-channel
837    /// gain per slot, NOT one per-channel gain. The pre-r44 pipeline
838    /// approximated this with the per-channel `g_ch(ts)` (exact for
839    /// `M = 1`); this entry-point closes the gap by:
840    ///
841    ///   1. Driving each contributing channel through phase-1
842    ///      [`Self::aspx_extend_to_qmf`] to capture the post-extension
843    ///      QMF matrix `q_ch[sb][ts]` along with each channel's
844    ///      `(sb0, sbz)` companding band.
845    ///   2. Calling [`aspx::apply_synchronised_companding_across_channels`]
846    ///      with the collected QMF matrices and bands — that walks
847    ///      Pseudocode 121's geometric-mean across channels and writes
848    ///      the synced gain back into every QMF matrix.
849    ///   3. Driving each channel through phase-2
850    ///      [`Self::qmf_synthesise_pcm`] to produce the final PCM.
851    ///
852    /// Channels whose phase-1 returned `None` (length / table /
853    /// patch-derivation guard tripped — e.g. a slot whose IMDCT'd PCM
854    /// length isn't a multiple of 64) fall back to the unmodified
855    /// input PCM for that slot — same behaviour as the per-channel
856    /// `aspx_extend_pcm` helper used to give.
857    ///
858    /// `entries[i]` is `(slot, pcm_in, trailer, ch, sb0_override)`:
859    ///   * `slot` — output channel index (0..=4 for 5_X), used to
860    ///     pick the right `aspx_ext_state[slot]` carry-over.
861    ///   * `pcm_in` — IMDCT'd LF PCM for that output channel.
862    ///   * `trailer` — captured 5_X trailer (carries
863    ///     `frequency_tables`).
864    ///   * `ch` — primary or secondary channel within `trailer`.
865    ///   * `sb0_override` — `Some(acpl_qmf_band)` for ASPX_ACPL_1
866    ///     (`acpl_qmf_band` replaces `aspx_xover_band` per §5.7.5.2);
867    ///     `None` for SIMPLE / ASPX (sb0 = trailer.sbx).
868    ///
869    /// Returns one `(slot, Vec<f32>)` per entry, in the order they
870    /// were passed in. The caller is responsible for the trailing
871    /// f32→i16 cast and writeback into `pcm_per_channel[slot]`.
872    ///
873    /// `mode` MUST be either [`aspx::CompandingMode::SyncPerSlot`] or
874    /// [`aspx::CompandingMode::SyncAveraged`]; no-op (i.e. the
875    /// per-channel pipeline outputs without companding gain) for any
876    /// other mode.
877    fn extend_5x_channels_with_sync_companding(
878        &mut self,
879        entries: &[SyncCompandingChannelEntry<'_>],
880        num_ts_in_ats: u32,
881        mode: aspx::CompandingMode,
882    ) -> Vec<(usize, Vec<f32>)> {
883        // Phase 1: drive each entry through aspx_extend_to_qmf,
884        // capturing the post-extension QMF matrix (or `None` if the
885        // extension preconditions tripped — that channel will pass
886        // through unchanged).
887        let mut phase1: Vec<(usize, usize, Option<AspxQmfPhase1>)> =
888            Vec::with_capacity(entries.len());
889        for (slot, pcm_in, trailer, ch, cfg, sb0_override) in entries.iter() {
890            while self.aspx_ext_state.len() <= *slot {
891                self.aspx_ext_state.push(aspx::AspxChannelExtState::new());
892            }
893            let state = &mut self.aspx_ext_state[*slot];
894            let qres = Self::aspx_extend_to_qmf(
895                pcm_in,
896                &trailer.frequency_tables,
897                cfg,
898                Some(&ch.framing),
899                Some(&ch.data_sig),
900                Some(&ch.data_noise),
901                Some(ch.qmode_env),
902                Some(&ch.delta_dir),
903                ch.add_harmonic.as_deref(),
904                ch.tna_mode.as_deref(),
905                state,
906                num_ts_in_ats,
907            );
908            // Resolve the effective sb0 for the synced companding —
909            // sb0_override (acpl_qmf_band for ASPX_ACPL_1) or sbx (the
910            // A-SPX crossover band for SIMPLE / ASPX).
911            let q_with_band = qres.map(|(q, sbx, sbz)| {
912                let sb0 = sb0_override.unwrap_or(sbx);
913                (q, sb0, sbz)
914            });
915            phase1.push((*slot, pcm_in.len(), q_with_band));
916        }
917        // Phase 2: collect every channel that survived phase 1 into
918        // the synced companding helper. Only mutable references to
919        // the QMF matrices are passed in; the helper reads each
920        // channel's level, computes geometric-mean across them, and
921        // writes back the synced scales.
922        {
923            let mut sync_view: Vec<aspx::SyncCompandingEntry<'_>> = Vec::new();
924            for (_, _, q_opt) in phase1.iter_mut() {
925                if let Some((q, sb0, sbz)) = q_opt.as_mut() {
926                    sync_view.push((q, *sb0, *sbz));
927                }
928            }
929            aspx::apply_synchronised_companding_across_channels(&mut sync_view, mode);
930        }
931        // Phase 3: synthesise per-channel PCM. Channels whose phase
932        // returned None fall back to a clone of the input PCM (same
933        // contract as the original `aspx_extend_pcm`).
934        let mut out: Vec<(usize, Vec<f32>)> = Vec::with_capacity(entries.len());
935        for (i, (slot, pcm_len, q_opt)) in phase1.into_iter().enumerate() {
936            let pcm = match q_opt {
937                Some((q, _, _)) => Self::qmf_synthesise_pcm(&q, pcm_len),
938                None => entries[i].1.to_vec(),
939            };
940            out.push((slot, pcm));
941        }
942        out
943    }
944
945    /// Round 45: stereo-CPE counterpart to
946    /// [`Self::extend_5x_channels_with_sync_companding`] for the M=2
947    /// case where the two channels are not 5_X trailer slots but the
948    /// primary / secondary of an `aspx_data_2ch` stereo CPE — in
949    /// particular the L/R carrier pair that drives a 5_X ASPX_ACPL_3
950    /// `run_acpl_5x_mch_pcm` synthesis (Pseudocode 118 expects the
951    /// extended carriers, not raw IMDCT'd PCM).
952    ///
953    /// When `companding_control(2)` carried `sync_flag == 1` the spec's
954    /// `g_synch(ts) = (∏_{ch=0..M} g_ch(ts))^(1/M)` collapses for M=2
955    /// to `√(g_0(ts) · g_1(ts))` — a single geometric-mean gain shared
956    /// across both channels rather than two independent per-channel
957    /// gains. This matches r44's 5_X SIMPLE/ASPX dispatch path:
958    /// phase-1 runs each channel through [`Self::aspx_extend_to_qmf`]
959    /// (capturing the post-extension QMF matrix + each channel's
960    /// `(sb0, sbz)` companding band), phase-2 calls
961    /// [`aspx::apply_synchronised_companding_across_channels`] to
962    /// write the synced gain into both QMF matrices, and phase-3
963    /// runs each channel through [`Self::qmf_synthesise_pcm`] to
964    /// produce the final PCM.
965    ///
966    /// `mode` MUST be one of
967    /// [`aspx::CompandingMode::SyncPerSlot`] / [`aspx::CompandingMode::SyncAveraged`]
968    /// (the cross-channel sync sub-branches of Pseudocode 121); any
969    /// other mode is a no-op for the synced pipeline and the caller
970    /// should run the per-channel `aspx_extend_pcm` path instead.
971    ///
972    /// `tables` / `cfg` are shared between the two channels in a
973    /// stereo CPE (one `aspx_config()` per substream). `sb0_override`
974    /// is `Some(acpl_qmf_band)` for the stereo ASPX_ACPL_1 path
975    /// (which substitutes `acpl_qmf_band` for `aspx_xover_band` per
976    /// §5.7.5.2 sb0 selection); `None` everywhere else (SIMPLE / ASPX
977    /// / ACPL_3 paths use `tables.sbx`).
978    ///
979    /// When either channel's phase-1 returns `None` (PCM length not a
980    /// multiple of 64, missing tables, etc.) that channel falls back
981    /// to its un-extended PCM — same contract as
982    /// [`Self::aspx_extend_pcm`] / [`Self::extend_5x_channels_with_sync_companding`].
983    #[allow(clippy::too_many_arguments)]
984    fn extend_stereo_cpe_pair_with_sync_companding(
985        &mut self,
986        primary: &StereoCpeChannelInput<'_>,
987        secondary: &StereoCpeChannelInput<'_>,
988        tables: &aspx::AspxFrequencyTables,
989        cfg: &aspx::AspxConfig,
990        num_ts_in_ats: u32,
991        mode: aspx::CompandingMode,
992        sb0_override: Option<u32>,
993    ) -> (Vec<f32>, Vec<f32>) {
994        // Phase 1: drive each channel through aspx_extend_to_qmf and
995        // capture the post-extension QMF matrix + (sb0, sbz) band.
996        // Lay out as Vec so indices are stable across the
997        // borrow-juggle below.
998        let mut phase1: [(usize, usize, Option<AspxQmfPhase1>); 2] = [
999            (primary.ch_index, primary.pcm_in.len(), None),
1000            (secondary.ch_index, secondary.pcm_in.len(), None),
1001        ];
1002        for (i, input) in [primary, secondary].iter().enumerate() {
1003            while self.aspx_ext_state.len() <= input.ch_index {
1004                self.aspx_ext_state.push(aspx::AspxChannelExtState::new());
1005            }
1006            let state = &mut self.aspx_ext_state[input.ch_index];
1007            phase1[i].2 = Self::aspx_extend_to_qmf(
1008                input.pcm_in,
1009                tables,
1010                cfg,
1011                input.framing,
1012                input.sig,
1013                input.noise,
1014                input.qmode,
1015                input.delta_dir,
1016                input.add_harmonic,
1017                input.tna_mode,
1018                state,
1019                num_ts_in_ats,
1020            )
1021            .map(|(q, sbx_eff, sbz_eff)| {
1022                // sb0_override is shared across both channels of the
1023                // stereo CPE (acpl_qmf_band for ASPX_ACPL_1 stereo,
1024                // sbx everywhere else).
1025                let sb0 = sb0_override.unwrap_or(sbx_eff);
1026                (q, sb0, sbz_eff)
1027            });
1028        }
1029        // Phase 2: collect every channel that survived phase 1 into
1030        // the synced companding helper. M=2 → `g_synch(ts) = √(g_0(ts) · g_1(ts))`
1031        // is written back into BOTH QMF matrices uniformly.
1032        {
1033            let mut sync_view: Vec<aspx::SyncCompandingEntry<'_>> = Vec::with_capacity(2);
1034            for (_, _, q_opt) in phase1.iter_mut() {
1035                if let Some((q, sb0, sbz)) = q_opt.as_mut() {
1036                    sync_view.push((q, *sb0, *sbz));
1037                }
1038            }
1039            aspx::apply_synchronised_companding_across_channels(&mut sync_view, mode);
1040        }
1041        // Phase 3: synthesise per-channel PCM. Channels whose phase-1
1042        // returned None fall back to the unmodified input PCM (same
1043        // contract as `aspx_extend_pcm`).
1044        let pcm_out = |idx: usize, fallback: &[f32]| -> Vec<f32> {
1045            match &phase1[idx].2 {
1046                Some((q, _, _)) => Self::qmf_synthesise_pcm(q, phase1[idx].1),
1047                None => fallback.to_vec(),
1048            }
1049        };
1050        let pri = pcm_out(0, primary.pcm_in);
1051        let sec = pcm_out(1, secondary.pcm_in);
1052        (pri, sec)
1053    }
1054
1055    /// Round 44: shared front-end for the 5_X SIMPLE/ASPX dispatchers
1056    /// that resolves the synced-companding mode for the whole 5_X
1057    /// frame. With `sync_flag == 1`, every channel resolves to the
1058    /// SAME mode (Pseudocode 121 broadcasts `compand_on[0]`); with
1059    /// `sync_flag == 0` (or no companding) the per-channel
1060    /// [`Self::five_x_compand_mode_for_slot`] is what callers want.
1061    ///
1062    /// Returns `Some(mode)` when the cross-channel synced pipeline
1063    /// should run (mode is `SyncPerSlot` or `SyncAveraged`); `None`
1064    /// when the per-channel pipeline should run (sync_flag missing /
1065    /// false, or sync_flag=true resolves to `Off`).
1066    fn five_x_synced_mode(cc: Option<&aspx::CompandingControl>) -> Option<aspx::CompandingMode> {
1067        let cc = cc?;
1068        if !matches!(cc.sync_flag, Some(true)) {
1069            return None;
1070        }
1071        let mode = aspx::CompandingMode::from_control(cc, 0);
1072        match mode {
1073            aspx::CompandingMode::SyncPerSlot | aspx::CompandingMode::SyncAveraged => Some(mode),
1074            _ => None,
1075        }
1076    }
1077
1078    /// Round 44: drive every entry through the synced-companding
1079    /// pipeline (when `synced_mode` is `Some`), apply the resulting
1080    /// PCM to `pcm_per_channel[slot]`. Otherwise (sync mode = None),
1081    /// drive each entry through the per-channel pipeline.
1082    ///
1083    /// Each entry is `(slot, pcm_f, trailer, ch, sb0_override)`.
1084    /// `aspx_cfg` is shared across all entries (one config per 5_X
1085    /// substream).
1086    #[allow(clippy::too_many_arguments)]
1087    fn extend_5x_entries(
1088        &mut self,
1089        entries: Vec<FiveXChannelEntry<'_>>,
1090        aspx_cfg: Option<aspx::AspxConfig>,
1091        companding: Option<&aspx::CompandingControl>,
1092        num_ts_in_ats: u32,
1093        pcm_per_channel: &mut [Option<Vec<i16>>],
1094    ) {
1095        let synced = Self::five_x_synced_mode(companding);
1096        if let (Some(mode), Some(cfg)) = (synced, aspx_cfg) {
1097            // Cross-channel synced path. Build the entries-with-trailer
1098            // list (skipping any whose trailer is missing — those fall
1099            // back to the unmodified PCM for that slot).
1100            let mut sync_entries: Vec<SyncCompandingChannelEntry<'_>> = Vec::new();
1101            // Track which entries had no trailer — they pass through
1102            // the PCM unchanged.
1103            let mut passthrough: Vec<(usize, &[f32])> = Vec::new();
1104            for (slot, pcm_f, trailer_pair) in entries.iter() {
1105                match trailer_pair {
1106                    Some((trailer, is_secondary)) => {
1107                        let ch = if *is_secondary {
1108                            trailer.secondary.as_ref().unwrap_or(&trailer.primary)
1109                        } else {
1110                            &trailer.primary
1111                        };
1112                        sync_entries.push((*slot, pcm_f.as_slice(), trailer, ch, &cfg, None));
1113                    }
1114                    None => {
1115                        passthrough.push((*slot, pcm_f.as_slice()));
1116                    }
1117                }
1118            }
1119            let extended =
1120                self.extend_5x_channels_with_sync_companding(&sync_entries, num_ts_in_ats, mode);
1121            for (slot, pcm) in extended {
1122                pcm_per_channel[slot] = Some(Self::pcm_f32_to_i16(&pcm));
1123            }
1124            for (slot, pcm) in passthrough {
1125                pcm_per_channel[slot] = Some(Self::pcm_f32_to_i16(pcm));
1126            }
1127            return;
1128        }
1129        // Per-channel path (sync_flag == 0 or sync_flag == 1 + Off).
1130        for (slot, pcm_f, trailer_pair) in entries.into_iter() {
1131            let pcm_i16 = match (aspx_cfg, trailer_pair) {
1132                (Some(cfg), Some((trailer, is_secondary))) => {
1133                    let ch = if is_secondary {
1134                        trailer.secondary.as_ref().unwrap_or(&trailer.primary)
1135                    } else {
1136                        &trailer.primary
1137                    };
1138                    let compand_mode = Self::five_x_compand_mode_for_slot(companding, slot);
1139                    let extended = self.aspx_extend_with_trailer(
1140                        &pcm_f,
1141                        trailer,
1142                        ch,
1143                        &cfg,
1144                        slot,
1145                        num_ts_in_ats,
1146                        compand_mode,
1147                        None,
1148                    );
1149                    Self::pcm_f32_to_i16(&extended)
1150                }
1151                _ => Self::pcm_f32_to_i16(&pcm_f),
1152            };
1153            pcm_per_channel[slot] = Some(pcm_i16);
1154        }
1155    }
1156
1157    /// §5.3.4.3.1 / Table 180 — 5_X SIMPLE/ASPX `coding_config == 2`
1158    /// dispatch: the parsed `four_channel_data` carries L/R/Ls/Rs in
1159    /// `scaled_spec_per_channel[0..4]` and the trailing
1160    /// `cfg2_back_mono.scaled_spec` carries the centre. Channel mapping
1161    /// per Table 180:
1162    ///
1163    /// ```text
1164    ///     four_channel_data[0] -> slot 0 (L)
1165    ///     four_channel_data[1] -> slot 1 (R)
1166    ///     four_channel_data[2] -> slot 3 (Ls)
1167    ///     four_channel_data[3] -> slot 4 (Rs)
1168    ///     mono_data           -> slot 2 (C)
1169    /// ```
1170    ///
1171    /// Round 41 wires the ASPX bandwidth-extension trailer per channel:
1172    /// `aspx_data_2ch[L,R] + aspx_data_2ch[Ls,Rs] + aspx_data_1ch[C]`
1173    /// (per Table 25 row `case ASPX:`). Each trailer's per-channel
1174    /// envelope set drives `aspx_extend_pcm` on the IMDCT'd low-band
1175    /// PCM before quantisation. When a trailer is absent (SIMPLE mode
1176    /// or trailer-parse miss) the channel passes through with low-band
1177    /// PCM only — matching the round-38 behaviour for those paths.
1178    ///
1179    /// The function is a no-op when any of the four per-channel scaled
1180    /// spectra are absent (short / grouped frame or Huffman miss);
1181    /// centre is silent when the trailing `mono_data` body is absent.
1182    #[allow(clippy::too_many_arguments)]
1183    fn dispatch_5x_cfg2_simple_aspx(
1184        &mut self,
1185        four: &crate::mch::FourChannelData,
1186        back_mono: Option<&crate::mch::MonoLfeData>,
1187        aspx_lr: Option<&aspx::FiveXAspxTrailer>,
1188        aspx_ls_rs: Option<&aspx::FiveXAspxTrailer>,
1189        aspx_centre: Option<&aspx::FiveXAspxTrailer>,
1190        aspx_cfg: Option<aspx::AspxConfig>,
1191        companding: Option<&aspx::CompandingControl>,
1192        num_ts_in_ats: u32,
1193        samples: usize,
1194        pcm_per_channel: &mut Vec<Option<Vec<i16>>>,
1195    ) {
1196        let Some(ti) = four.transform_info.as_ref() else {
1197            return;
1198        };
1199        let n = ti.transform_length_0 as usize;
1200        if n == 0 || n != samples {
1201            return;
1202        }
1203        if four.scaled_spec_per_channel.len() < 4 {
1204            return;
1205        }
1206        // Channel mapping: ch_in -> slot_out per Table 180 cfg2 column.
1207        // The ASPX trailers map (L,R) and (Ls,Rs) onto the front /
1208        // surround stereo pairs.
1209        const SLOT_MAP: [usize; 4] = [0, 1, 3, 4];
1210        // Need at least 5 output slots (L/R/C/Ls/Rs). Resize on demand.
1211        while pcm_per_channel.len() < 5 {
1212            pcm_per_channel.push(None);
1213        }
1214        // L (slot 0) — primary channel of the L/R 2ch trailer.
1215        // R (slot 1) — secondary channel of the L/R 2ch trailer.
1216        // Ls (slot 3) — primary channel of the Ls/Rs 2ch trailer.
1217        // Rs (slot 4) — secondary channel of the Ls/Rs 2ch trailer.
1218        let trailers_for_ch: [Option<(&aspx::FiveXAspxTrailer, bool)>; 4] = [
1219            aspx_lr.map(|t| (t, false)),    // L
1220            aspx_lr.map(|t| (t, true)),     // R
1221            aspx_ls_rs.map(|t| (t, false)), // Ls
1222            aspx_ls_rs.map(|t| (t, true)),  // Rs
1223        ];
1224        // Build the per-slot entries (slot, pcm_f, trailer_pair) for
1225        // the L/R/Ls/Rs quartet, plus the centre. The centre joins the
1226        // synced-companding cohort when both `back_mono` and a centre
1227        // trailer are present — that way Pseudocode 121's
1228        // `g_synch(ts) = (∏ g_ch(ts))^(1/M)` averages across all five
1229        // 5_X channels, not just the four front/surround.
1230        let mut entries: Vec<FiveXChannelEntry<'_>> = Vec::with_capacity(5);
1231        for (ch_in, &slot) in SLOT_MAP.iter().enumerate() {
1232            let Some(scaled) = four.scaled_spec_per_channel[ch_in].as_ref() else {
1233                continue;
1234            };
1235            let pcm_f = self.imdct_channel_f32(slot, scaled, n);
1236            entries.push((slot, pcm_f, trailers_for_ch[ch_in]));
1237        }
1238        if let Some(mono) = back_mono {
1239            if let Some(pcm_f) = self.imdct_mono_lfe_data_f32(mono, 2, samples) {
1240                let centre_pair = aspx_centre.map(|t| (t, false));
1241                entries.push((2, pcm_f, centre_pair));
1242            }
1243        }
1244        self.extend_5x_entries(
1245            entries,
1246            aspx_cfg,
1247            companding,
1248            num_ts_in_ats,
1249            pcm_per_channel,
1250        );
1251    }
1252
1253    /// §5.3.4.3.1 / Table 180 — 5_X SIMPLE/ASPX `coding_config == 0`
1254    /// dispatch. The body shape is
1255    /// `b_2ch_mode + two_channel_data + two_channel_data + mono_data(0)`,
1256    /// with channel mapping driven by the 1-bit `b_2ch_mode`:
1257    ///
1258    /// ```text
1259    /// 2ch_mode == 0 (Table 180 column 0a):
1260    ///     two_channel_data[0]      -> [0, 1] (L,  R)
1261    ///     two_channel_data[1]      -> [3, 4] (Ls, Rs)
1262    ///     mono_data                -> [2]    (C)
1263    ///
1264    /// 2ch_mode == 1 (Table 180 column 0b):
1265    ///     two_channel_data[0]      -> [0, 3] (L,  Ls)
1266    ///     two_channel_data[1]      -> [1, 4] (R,  Rs)
1267    ///     mono_data                -> [2]    (C)
1268    /// ```
1269    ///
1270    /// The function is a no-op when `tcd_a` doesn't carry a transform_info
1271    /// matching `samples`, when fewer than two `two_channel_data` shells
1272    /// are present, or when any per-channel scaled spectrum is missing
1273    /// (short / grouped / Huffman-miss path). Centre is silent when the
1274    /// trailing `mono_data` body is absent.
1275    #[allow(clippy::too_many_arguments)]
1276    fn dispatch_5x_cfg0_simple_aspx(
1277        &mut self,
1278        tcd_a: &crate::mch::TwoChannelData,
1279        tcd_b: &crate::mch::TwoChannelData,
1280        b_2ch_mode: bool,
1281        centre_mono: Option<&crate::mch::MonoLfeData>,
1282        aspx_lr: Option<&aspx::FiveXAspxTrailer>,
1283        aspx_ls_rs: Option<&aspx::FiveXAspxTrailer>,
1284        aspx_centre: Option<&aspx::FiveXAspxTrailer>,
1285        aspx_cfg: Option<aspx::AspxConfig>,
1286        companding: Option<&aspx::CompandingControl>,
1287        num_ts_in_ats: u32,
1288        samples: usize,
1289        pcm_per_channel: &mut Vec<Option<Vec<i16>>>,
1290    ) {
1291        let Some(ti_a) = tcd_a.transform_info.as_ref() else {
1292            return;
1293        };
1294        let n_a = ti_a.transform_length_0 as usize;
1295        if n_a == 0 || n_a != samples {
1296            return;
1297        }
1298        let Some(ti_b) = tcd_b.transform_info.as_ref() else {
1299            return;
1300        };
1301        let n_b = ti_b.transform_length_0 as usize;
1302        if n_b == 0 || n_b != samples {
1303            return;
1304        }
1305        if tcd_a.scaled_spec_per_channel.len() < 2 || tcd_b.scaled_spec_per_channel.len() < 2 {
1306            return;
1307        }
1308        // Slot map per Table 180 column 0:
1309        //   2ch_mode == 0: [0,1] then [3,4] (L,R / Ls,Rs)
1310        //   2ch_mode == 1: [0,3] then [1,4] (L,Ls / R,Rs)
1311        let slot_map_a: [usize; 2] = if b_2ch_mode { [0, 3] } else { [0, 1] };
1312        let slot_map_b: [usize; 2] = if b_2ch_mode { [1, 4] } else { [3, 4] };
1313        while pcm_per_channel.len() < 5 {
1314            pcm_per_channel.push(None);
1315        }
1316        // Trailer-to-output-slot mapping (independent of b_2ch_mode):
1317        // ASPX is applied per output channel after channel-element
1318        // decode produces PCM. Per Table 25 trailer order:
1319        //   slot 0 (L)  -> aspx_lr.primary
1320        //   slot 1 (R)  -> aspx_lr.secondary
1321        //   slot 3 (Ls) -> aspx_ls_rs.primary
1322        //   slot 4 (Rs) -> aspx_ls_rs.secondary
1323        //   slot 2 (C)  -> aspx_centre.primary
1324        let mut entries: Vec<FiveXChannelEntry<'_>> = Vec::with_capacity(5);
1325        for (ch_in, &slot) in slot_map_a.iter().enumerate() {
1326            let Some(scaled) = tcd_a.scaled_spec_per_channel[ch_in].as_ref() else {
1327                continue;
1328            };
1329            let pcm_f = self.imdct_channel_f32(slot, scaled, n_a);
1330            entries.push((
1331                slot,
1332                pcm_f,
1333                Self::trailer_for_5x_slot(slot, aspx_lr, aspx_ls_rs, aspx_centre),
1334            ));
1335        }
1336        for (ch_in, &slot) in slot_map_b.iter().enumerate() {
1337            let Some(scaled) = tcd_b.scaled_spec_per_channel[ch_in].as_ref() else {
1338                continue;
1339            };
1340            let pcm_f = self.imdct_channel_f32(slot, scaled, n_b);
1341            entries.push((
1342                slot,
1343                pcm_f,
1344                Self::trailer_for_5x_slot(slot, aspx_lr, aspx_ls_rs, aspx_centre),
1345            ));
1346        }
1347        if let Some(mono) = centre_mono {
1348            if let Some(pcm_f) = self.imdct_mono_lfe_data_f32(mono, 2, samples) {
1349                entries.push((
1350                    2,
1351                    pcm_f,
1352                    Self::trailer_for_5x_slot(2, aspx_lr, aspx_ls_rs, aspx_centre),
1353                ));
1354            }
1355        }
1356        self.extend_5x_entries(
1357            entries,
1358            aspx_cfg,
1359            companding,
1360            num_ts_in_ats,
1361            pcm_per_channel,
1362        );
1363    }
1364
1365    /// Round 42: canonical Table-25 trailer-to-slot mapping for the
1366    /// 5_X SIMPLE/ASPX dispatchers. Returns `(trailer, is_secondary)`
1367    /// when the appropriate trailer is present, else `None`.
1368    fn trailer_for_5x_slot<'a>(
1369        slot: usize,
1370        aspx_lr: Option<&'a aspx::FiveXAspxTrailer>,
1371        aspx_ls_rs: Option<&'a aspx::FiveXAspxTrailer>,
1372        aspx_centre: Option<&'a aspx::FiveXAspxTrailer>,
1373    ) -> Option<(&'a aspx::FiveXAspxTrailer, bool)> {
1374        match slot {
1375            0 => aspx_lr.map(|t| (t, false)),
1376            1 => aspx_lr.map(|t| (t, true)),
1377            2 => aspx_centre.map(|t| (t, false)),
1378            3 => aspx_ls_rs.map(|t| (t, false)),
1379            4 => aspx_ls_rs.map(|t| (t, true)),
1380            _ => None,
1381        }
1382    }
1383
1384    /// Round 42: trailer-aware ASPX extension on one 5_X output slot
1385    /// (0..=4). Used by `dispatch_5x_cfg{0,1,3}_simple_aspx` to apply
1386    /// the per-channel trailer + companding pulled from the per-cfg
1387    /// slots in [`crate::asf::SubstreamTools`].
1388    ///
1389    /// Trailer-to-slot mapping is the canonical Table-25 order
1390    /// `aspx_data_2ch + aspx_data_2ch + aspx_data_1ch` translated to
1391    /// 5.X output channels:
1392    ///   slot 0 (L)  -> aspx_lr.primary
1393    ///   slot 1 (R)  -> aspx_lr.secondary
1394    ///   slot 3 (Ls) -> aspx_ls_rs.primary
1395    ///   slot 4 (Rs) -> aspx_ls_rs.secondary
1396    ///   slot 2 (C)  -> aspx_centre.primary
1397    /// Trailers / config absent -> i16 cast of `pcm_f` only.
1398    #[allow(dead_code)]
1399    #[allow(clippy::too_many_arguments)]
1400    fn maybe_extend_5x_slot(
1401        &mut self,
1402        slot: usize,
1403        pcm_f: Vec<f32>,
1404        aspx_lr: Option<&aspx::FiveXAspxTrailer>,
1405        aspx_ls_rs: Option<&aspx::FiveXAspxTrailer>,
1406        aspx_centre: Option<&aspx::FiveXAspxTrailer>,
1407        aspx_cfg: Option<aspx::AspxConfig>,
1408        companding: Option<&aspx::CompandingControl>,
1409        num_ts_in_ats: u32,
1410    ) -> Vec<i16> {
1411        let trailer_pair = Self::trailer_for_5x_slot(slot, aspx_lr, aspx_ls_rs, aspx_centre);
1412        match (aspx_cfg, trailer_pair) {
1413            (Some(cfg), Some((trailer, is_secondary))) => {
1414                let ch = if is_secondary {
1415                    trailer.secondary.as_ref().unwrap_or(&trailer.primary)
1416                } else {
1417                    &trailer.primary
1418                };
1419                let compand_mode = Self::five_x_compand_mode_for_slot(companding, slot);
1420                let extended = self.aspx_extend_with_trailer(
1421                    &pcm_f,
1422                    trailer,
1423                    ch,
1424                    &cfg,
1425                    slot,
1426                    num_ts_in_ats,
1427                    compand_mode,
1428                    // SIMPLE/ASPX cfg{0,1,3} dispatchers never run on
1429                    // ASPX_ACPL_1, so sb0 stays at aspx_xover_band.
1430                    None,
1431                );
1432                Self::pcm_f32_to_i16(&extended)
1433            }
1434            _ => Self::pcm_f32_to_i16(&pcm_f),
1435        }
1436    }
1437
1438    /// §5.3.4.3.1 / Table 180 — 5_X SIMPLE/ASPX `coding_config == 1`
1439    /// dispatch. The body shape is
1440    /// `three_channel_data + two_channel_data`, with channel mapping per
1441    /// Table 180 column 1:
1442    ///
1443    /// ```text
1444    ///     three_channel_data[0..3] -> [0, 1, 2] (L, R, C)
1445    ///     two_channel_data[0..2]   -> [3, 4]    (Ls, Rs)
1446    /// ```
1447    ///
1448    /// No-op on transform-length / sample-count mismatch, or when a
1449    /// per-channel scaled spectrum is absent.
1450    #[allow(clippy::too_many_arguments)]
1451    fn dispatch_5x_cfg1_simple_aspx(
1452        &mut self,
1453        three: &crate::mch::ThreeChannelData,
1454        tcd: &crate::mch::TwoChannelData,
1455        aspx_lr: Option<&aspx::FiveXAspxTrailer>,
1456        aspx_ls_rs: Option<&aspx::FiveXAspxTrailer>,
1457        aspx_centre: Option<&aspx::FiveXAspxTrailer>,
1458        aspx_cfg: Option<aspx::AspxConfig>,
1459        companding: Option<&aspx::CompandingControl>,
1460        num_ts_in_ats: u32,
1461        samples: usize,
1462        pcm_per_channel: &mut Vec<Option<Vec<i16>>>,
1463    ) {
1464        let Some(ti3) = three.transform_info.as_ref() else {
1465            return;
1466        };
1467        let n3 = ti3.transform_length_0 as usize;
1468        if n3 == 0 || n3 != samples {
1469            return;
1470        }
1471        let Some(ti2) = tcd.transform_info.as_ref() else {
1472            return;
1473        };
1474        let n2 = ti2.transform_length_0 as usize;
1475        if n2 == 0 || n2 != samples {
1476            return;
1477        }
1478        if three.scaled_spec_per_channel.len() < 3 || tcd.scaled_spec_per_channel.len() < 2 {
1479            return;
1480        }
1481        while pcm_per_channel.len() < 5 {
1482            pcm_per_channel.push(None);
1483        }
1484        const THREE_SLOTS: [usize; 3] = [0, 1, 2];
1485        let mut entries: Vec<FiveXChannelEntry<'_>> = Vec::with_capacity(5);
1486        for (ch_in, &slot) in THREE_SLOTS.iter().enumerate() {
1487            let Some(scaled) = three.scaled_spec_per_channel[ch_in].as_ref() else {
1488                continue;
1489            };
1490            let pcm_f = self.imdct_channel_f32(slot, scaled, n3);
1491            entries.push((
1492                slot,
1493                pcm_f,
1494                Self::trailer_for_5x_slot(slot, aspx_lr, aspx_ls_rs, aspx_centre),
1495            ));
1496        }
1497        const TWO_SLOTS: [usize; 2] = [3, 4];
1498        for (ch_in, &slot) in TWO_SLOTS.iter().enumerate() {
1499            let Some(scaled) = tcd.scaled_spec_per_channel[ch_in].as_ref() else {
1500                continue;
1501            };
1502            let pcm_f = self.imdct_channel_f32(slot, scaled, n2);
1503            entries.push((
1504                slot,
1505                pcm_f,
1506                Self::trailer_for_5x_slot(slot, aspx_lr, aspx_ls_rs, aspx_centre),
1507            ));
1508        }
1509        self.extend_5x_entries(
1510            entries,
1511            aspx_cfg,
1512            companding,
1513            num_ts_in_ats,
1514            pcm_per_channel,
1515        );
1516    }
1517
1518    /// §5.3.4.3.1 / Table 180 — 5_X SIMPLE/ASPX `coding_config == 3`
1519    /// dispatch. The body is a single `five_channel_data`; channel
1520    /// mapping is the identity:
1521    ///
1522    /// ```text
1523    ///     five_channel_data[0..5] -> [0, 1, 2, 3, 4] (L, R, C, Ls, Rs)
1524    /// ```
1525    ///
1526    /// No-op on transform-length / sample-count mismatch, or when a
1527    /// per-channel scaled spectrum is absent.
1528    #[allow(clippy::too_many_arguments)]
1529    fn dispatch_5x_cfg3_simple_aspx(
1530        &mut self,
1531        five: &crate::mch::FiveChannelData,
1532        aspx_lr: Option<&aspx::FiveXAspxTrailer>,
1533        aspx_ls_rs: Option<&aspx::FiveXAspxTrailer>,
1534        aspx_centre: Option<&aspx::FiveXAspxTrailer>,
1535        aspx_cfg: Option<aspx::AspxConfig>,
1536        companding: Option<&aspx::CompandingControl>,
1537        num_ts_in_ats: u32,
1538        samples: usize,
1539        pcm_per_channel: &mut Vec<Option<Vec<i16>>>,
1540    ) {
1541        let Some(ti) = five.transform_info.as_ref() else {
1542            return;
1543        };
1544        let n = ti.transform_length_0 as usize;
1545        if n == 0 || n != samples {
1546            return;
1547        }
1548        if five.scaled_spec_per_channel.len() < 5 {
1549            return;
1550        }
1551        while pcm_per_channel.len() < 5 {
1552            pcm_per_channel.push(None);
1553        }
1554        const SLOT_MAP: [usize; 5] = [0, 1, 2, 3, 4];
1555        let mut entries: Vec<FiveXChannelEntry<'_>> = Vec::with_capacity(5);
1556        for (ch_in, &slot) in SLOT_MAP.iter().enumerate() {
1557            let Some(scaled) = five.scaled_spec_per_channel[ch_in].as_ref() else {
1558                continue;
1559            };
1560            let pcm_f = self.imdct_channel_f32(slot, scaled, n);
1561            entries.push((
1562                slot,
1563                pcm_f,
1564                Self::trailer_for_5x_slot(slot, aspx_lr, aspx_ls_rs, aspx_centre),
1565            ));
1566        }
1567        self.extend_5x_entries(
1568            entries,
1569            aspx_cfg,
1570            companding,
1571            num_ts_in_ats,
1572            pcm_per_channel,
1573        );
1574    }
1575
1576    /// §5.3.4.4.1 / Table 182 / Table 183 — 7_X SIMPLE/ASPX additional-
1577    /// channel pair dispatch. The `seven_x_additional_channel_data` shell
1578    /// carries two `sf_data(ASF)` bodies for the F / G preliminary output
1579    /// channels (Table 182). The optional `partner_pair_spectra` carry the
1580    /// 5.X-core counterparts D/E (or A/B per `channel_mode`) that pair
1581    /// with F/G in the Table 183 SAP matrix.
1582    ///
1583    /// With `b_use_sap_add_ch == false` (or absent), Table 183's SAP
1584    /// matrix collapses to identity — F / G land directly on slots 5 / 6
1585    /// and the partner spectra are untouched (their independent IMDCT
1586    /// path produces the unmodified slots 3 / 4 elsewhere in the
1587    /// pipeline).
1588    ///
1589    /// With `b_use_sap_add_ch == true`, the per-sfb (a, b, c, d)
1590    /// coefficients are extracted from each `chparam_info` (Pseudocode 59
1591    /// via [`crate::asf::extract_sap_abcd`]) and applied to the spectral
1592    /// pair (P, F) → (slot_partner, slot_F+1) and (Q, G) → (slot_partner+1,
1593    /// slot_F+2) per the Table 183 row for the active channel_mode:
1594    ///
1595    /// ```text
1596    ///     [out_high]   [a  b]   [partner]
1597    ///     [        ] = [    ] · [        ]
1598    ///     [out_low ]   [c  d]   [add_ch ]
1599    /// ```
1600    ///
1601    /// where `out_high` lands on the partner's existing slot (overwriting
1602    /// the unmixed PCM at that slot) and `out_low` lands on the
1603    /// additional-pair slot. When partner spectra are absent or the
1604    /// transform lengths don't match, falls back to identity render
1605    /// (slots 5 / 6 from F / G unmodified).
1606    ///
1607    /// No-op on transform-length / sample-count mismatch, or when either
1608    /// per-channel scaled spectrum is absent (short / grouped frame /
1609    /// Huffman miss).
1610    fn dispatch_7x_additional_channel_pair(
1611        &mut self,
1612        add: &crate::mch::TwoChannelData,
1613        partner_pair_spectra: Option<[&[f32]; 2]>,
1614        partner_slots: [usize; 2],
1615        chparam: Option<&[asf::ChparamInfo; 2]>,
1616        samples: usize,
1617        pcm_per_channel: &mut Vec<Option<Vec<i16>>>,
1618    ) {
1619        let Some(ti) = add.transform_info.as_ref() else {
1620            return;
1621        };
1622        let n = ti.transform_length_0 as usize;
1623        if n == 0 || n != samples {
1624            return;
1625        }
1626        if add.scaled_spec_per_channel.len() < 2 {
1627            return;
1628        }
1629        // Slots 5 / 6 are the additional-pair output channels (F, G).
1630        let pair_out_slots: [usize; 2] = [5, 6];
1631        while pcm_per_channel.len() < 7 {
1632            pcm_per_channel.push(None);
1633        }
1634        // Resize so partner slots are addressable too.
1635        for &slot in partner_slots.iter() {
1636            while pcm_per_channel.len() <= slot {
1637                pcm_per_channel.push(None);
1638            }
1639        }
1640        // Per-pair SAP application: for each i in 0..2, mix
1641        // `partner_pair_spectra[i]` (P or Q) with `add[i]` (F or G).
1642        // When partner is absent or chparam is None, falls through to
1643        // identity (only the additional-pair F/G is rendered).
1644        let tl = ti.transform_length_0;
1645        let max_sfb_cap = crate::tables::num_sfb_48(tl).unwrap_or(0);
1646        for ch_in in 0..2 {
1647            let Some(scaled_add) = add.scaled_spec_per_channel[ch_in].as_ref() else {
1648                continue;
1649            };
1650            // Build SAP coefficients for this pair if requested.
1651            let abcd: Option<Vec<(f32, f32, f32, f32)>> = match (chparam, partner_pair_spectra) {
1652                (Some(cps), Some(partners))
1653                    if max_sfb_cap > 0 && partners[ch_in].len() == n && scaled_add.len() == n =>
1654                {
1655                    let coeffs = asf::extract_sap_abcd(&cps[ch_in], &[max_sfb_cap]);
1656                    coeffs.abcd.into_iter().next()
1657                }
1658                _ => None,
1659            };
1660            if let (Some(abcd_row), Some(partners)) = (abcd.as_ref(), partner_pair_spectra) {
1661                // Spectral SAP per-sfb. Mix (P, F) -> (out_high, out_low).
1662                let partner = partners[ch_in];
1663                let sfbo = match crate::sfb_offset::sfb_offset_48(tl) {
1664                    Some(s) => s,
1665                    None => {
1666                        // SFB table missing — fall through to identity.
1667                        let pcm = self.imdct_channel(pair_out_slots[ch_in], scaled_add, n);
1668                        pcm_per_channel[pair_out_slots[ch_in]] = Some(pcm);
1669                        continue;
1670                    }
1671                };
1672                let mut out_high = vec![0.0f32; n];
1673                let mut out_low = vec![0.0f32; n];
1674                let usable_sfb = abcd_row.len().min(max_sfb_cap as usize);
1675                for sfb in 0..usable_sfb {
1676                    let lo = sfbo[sfb] as usize;
1677                    let hi = sfbo[sfb + 1] as usize;
1678                    let hi = hi.min(n).min(partner.len()).min(scaled_add.len());
1679                    let (a, b, c, d) = abcd_row[sfb];
1680                    for k in lo..hi {
1681                        let p = partner[k];
1682                        let f = scaled_add[k];
1683                        out_high[k] = a * p + b * f;
1684                        out_low[k] = c * p + d * f;
1685                    }
1686                }
1687                // Copy untouched bands (sfb >= usable_sfb) from the
1688                // partner / add spectra so the high half retains
1689                // the partner's bandwidth and the low half is silent
1690                // outside the SAP-coded range.
1691                let unmixed_start = sfbo
1692                    .get(usable_sfb)
1693                    .copied()
1694                    .map(|v| v as usize)
1695                    .unwrap_or(n);
1696                let unmixed_lo = unmixed_start.min(n);
1697                let unmixed_hi = n.min(partner.len());
1698                if unmixed_lo < unmixed_hi {
1699                    out_high[unmixed_lo..unmixed_hi]
1700                        .copy_from_slice(&partner[unmixed_lo..unmixed_hi]);
1701                }
1702                let pcm_high = self.imdct_channel(partner_slots[ch_in], &out_high, n);
1703                pcm_per_channel[partner_slots[ch_in]] = Some(pcm_high);
1704                let pcm_low = self.imdct_channel(pair_out_slots[ch_in], &out_low, n);
1705                pcm_per_channel[pair_out_slots[ch_in]] = Some(pcm_low);
1706            } else {
1707                // Identity passthrough — only render the additional pair
1708                // (slots 5/6). Partner slots untouched (their independent
1709                // 5_X-core IMDCT runs separately).
1710                let pcm = self.imdct_channel(pair_out_slots[ch_in], scaled_add, n);
1711                pcm_per_channel[pair_out_slots[ch_in]] = Some(pcm);
1712            }
1713        }
1714    }
1715}
1716
1717impl Decoder for Ac4Decoder {
1718    fn codec_id(&self) -> &CodecId {
1719        &self.codec_id
1720    }
1721
1722    fn send_packet(&mut self, packet: &Packet) -> Result<()> {
1723        if self.pending.is_some() {
1724            return Err(Error::other(
1725                "ac4 decoder: call receive_frame before sending another packet",
1726            ));
1727        }
1728        self.pending = Some(packet.clone());
1729        Ok(())
1730    }
1731
1732    fn receive_frame(&mut self) -> Result<Frame> {
1733        let Some(pkt) = self.pending.take() else {
1734            return if self.eof {
1735                Err(Error::Eof)
1736            } else {
1737                Err(Error::NeedMore)
1738            };
1739        };
1740        if pkt.data.is_empty() {
1741            // Empty packet — emit a 0-sample frame so the pipeline
1742            // continues rather than erroring.
1743            return Ok(Frame::Audio(AudioFrame {
1744                samples: 0,
1745                pts: pkt.pts,
1746                data: vec![Vec::new()],
1747            }));
1748        }
1749        let (raw, _had_sync) = self.extract_raw_frame(&pkt);
1750        let info = toc::parse_ac4_toc(raw)
1751            .map_err(|e| Error::invalid(format!("ac4 decoder: TOC parse failed: {e}")))?;
1752        // Resolve shape with fallbacks to the container hint when the
1753        // TOC carried a reserved / escape value.
1754        let channels = if info.channels == 0 {
1755            self.hint_channels
1756        } else {
1757            info.channels
1758        };
1759        let sample_rate = if info.sample_rate == 0 {
1760            self.hint_sample_rate
1761        } else {
1762            info.sample_rate
1763        };
1764        let samples = if info.frame_length == 0 {
1765            // Unknown frame length (reserved frame_rate_index): fall back
1766            // to 1024 samples at 48 kHz, 480 @ 44.1 kHz — both
1767            // round-numbers the resampler handles cleanly.
1768            if sample_rate == 44_100 {
1769                480
1770            } else {
1771                1024
1772            }
1773        } else {
1774            // frame_length in the table is expressed at the base sample
1775            // rate; for 96/192 kHz (sf_multiplier) we scale up.
1776            if sample_rate == 96_000 {
1777                info.frame_length * 2
1778            } else if sample_rate == 192_000 {
1779                info.frame_length * 4
1780            } else {
1781                info.frame_length
1782            }
1783        };
1784        // Best-effort walk of the first substream. The exact byte offset
1785        // of substream 0 is `toc_len + payload_base`, where `toc_len` is
1786        // the length of the byte-aligned ac4_toc() element. We don't
1787        // currently track `toc_len` out of [`toc::parse_ac4_toc`]; as a
1788        // cheap approximation we try the first substream size if the
1789        // substream_index_table exposed one, carving the tail of the
1790        // packet. This is fine for single-substream frames (the
1791        // overwhelmingly common case).
1792        let substream_try = {
1793            // Substream 0 starts at toc_size + payload_base.
1794            let start = (info.toc_size + info.payload_base) as usize;
1795            let first_size = info.substream_sizes.first().copied();
1796            if start >= raw.len() {
1797                None
1798            } else if let Some(sz) = first_size {
1799                let sz = sz as usize;
1800                let end = start.saturating_add(sz).min(raw.len());
1801                if sz > 0 {
1802                    Some(&raw[start..end])
1803                } else {
1804                    None
1805                }
1806            } else {
1807                // Single-substream frame with implicit size: the
1808                // substream spans to the end of the packet (possibly
1809                // minus CRC bytes, which the syncframe layer stripped).
1810                Some(&raw[start..])
1811            }
1812        };
1813        // Round 32: grow the per-channel SSF walker state vector to
1814        // match the current frame's channel count *before* invoking the
1815        // walker so the state borrow has the right shape.
1816        while self.ssf_walker_state.len() < channels as usize {
1817            self.ssf_walker_state.push(ssf::SsfChannelState::new());
1818        }
1819        self.last_substream = substream_try.and_then(|sb| {
1820            let channels_u16 = channels;
1821            let b_iframe = info
1822                .presentations
1823                .first()
1824                .map(|p| p.b_iframe)
1825                .unwrap_or(info.b_iframe_global);
1826            asf::walk_ac4_substream_stateful(
1827                sb,
1828                channels_u16,
1829                b_iframe,
1830                info.frame_length,
1831                Some(&mut self.ssf_walker_state[..channels as usize]),
1832            )
1833            .ok()
1834        });
1835        // If we have scaled spectra for the substream, run IMDCT + OLA
1836        // and produce real PCM. Per-channel PCM buffers live in
1837        // `pcm_per_channel`; the interleaver below lays them out to the
1838        // frame's channel count. Any channel without decoded spectra
1839        // stays silent. We detach the per-channel inputs from
1840        // `last_substream` up front so the IMDCT step can mutate
1841        // `self.overlap` without a borrow conflict.
1842        let mut pcm_per_channel: Vec<Option<Vec<i16>>> = vec![None; channels as usize];
1843        // Detach the inputs + the ASPX tables once so we can run IMDCT
1844        // (which mutates overlap state) and the ASPX extension without
1845        // a borrow conflict on self.
1846        // Detach A-CPL config + parsed data so the synth call below
1847        // doesn't conflict with the immutable borrow of `last_substream`
1848        // when we later mutate decoder state.
1849        let acpl_active_cfg = self.last_substream.as_ref().and_then(|sub| {
1850            sub.tools
1851                .acpl_config_1ch_full
1852                .or(sub.tools.acpl_config_1ch_partial)
1853        });
1854        let acpl_active_data = self
1855            .last_substream
1856            .as_ref()
1857            .and_then(|sub| sub.tools.acpl_data_1ch.clone());
1858        // Detach SSF data so we can run §5.2.3-5.2.7 synthesis without
1859        // a borrow conflict on `self`. SSF substreams are mutually
1860        // exclusive with ASF on a per-channel basis (per
1861        // `spec_frontend`), so when these are populated the IMDCT input
1862        // for that channel comes from `synthesize_ssf_data` instead of
1863        // the ASF Huffman path.
1864        let ssf_primary = self
1865            .last_substream
1866            .as_ref()
1867            .and_then(|sub| sub.tools.ssf_data_primary.clone());
1868        let ssf_secondary = self
1869            .last_substream
1870            .as_ref()
1871            .and_then(|sub| sub.tools.ssf_data_secondary.clone());
1872        // Detach 5_X ASPX_ACPL_3 synthesis inputs: two carrier spectra
1873        // land on scaled_spec_primary / scaled_spec_secondary (via the
1874        // stereo body walker), centre from cfg0_centre_mono, and the
1875        // A-CPL parameter pair from acpl_config_2ch / acpl_data_2ch.
1876        // Only populated when five_x_mode == AspxAcpl3.
1877        let five_x_acpl3_active = self
1878            .last_substream
1879            .as_ref()
1880            .map(|sub| {
1881                matches!(
1882                    sub.tools.five_x_mode,
1883                    Some(crate::mch::FiveXCodecMode::AspxAcpl3)
1884                ) && sub.tools.acpl_config_2ch.is_some()
1885                    && sub.tools.acpl_data_2ch.is_some()
1886                    && sub.tools.scaled_spec_primary.is_some()
1887                    && sub.tools.scaled_spec_secondary.is_some()
1888            })
1889            .unwrap_or(false);
1890        let five_x_acpl3_cfg = self
1891            .last_substream
1892            .as_ref()
1893            .and_then(|sub| sub.tools.acpl_config_2ch);
1894        let five_x_acpl3_data = self
1895            .last_substream
1896            .as_ref()
1897            .and_then(|sub| sub.tools.acpl_data_2ch.clone());
1898        // Detach 5_X ASPX_ACPL_1 / ASPX_ACPL_2 synthesis inputs
1899        // (Pseudocode 117). The active acpl_config_1ch is one of:
1900        //   - acpl_config_1ch_partial (ASPX_ACPL_1 — surround Ls/Rs
1901        //     carriers come from extra mono carriers; here we silence
1902        //     them as placeholders since the standalone Ls/Rs decode
1903        //     path isn't fleshed out yet).
1904        //   - acpl_config_1ch_full   (ASPX_ACPL_2 — no surround carriers).
1905        // The two `acpl_data_1ch_pair[]` entries drive the L-side
1906        // (alpha_1/beta_1) and R-side (alpha_2/beta_2) ACplModule's.
1907        let five_x_pair_mode: Option<acpl_synth::Acpl5xPairMode> = self
1908            .last_substream
1909            .as_ref()
1910            .and_then(|sub| match sub.tools.five_x_mode {
1911                Some(crate::mch::FiveXCodecMode::AspxAcpl1) => {
1912                    Some(acpl_synth::Acpl5xPairMode::AspxAcpl1)
1913                }
1914                Some(crate::mch::FiveXCodecMode::AspxAcpl2) => {
1915                    Some(acpl_synth::Acpl5xPairMode::AspxAcpl2)
1916                }
1917                _ => None,
1918            });
1919        let five_x_pair_cfg =
1920            self.last_substream
1921                .as_ref()
1922                .and_then(|sub| match sub.tools.five_x_mode {
1923                    Some(crate::mch::FiveXCodecMode::AspxAcpl1) => {
1924                        sub.tools.acpl_config_1ch_partial
1925                    }
1926                    Some(crate::mch::FiveXCodecMode::AspxAcpl2) => sub.tools.acpl_config_1ch_full,
1927                    _ => None,
1928                });
1929        let five_x_pair_data_1 = self
1930            .last_substream
1931            .as_ref()
1932            .and_then(|sub| sub.tools.acpl_data_1ch_pair[0].clone());
1933        let five_x_pair_data_2 = self
1934            .last_substream
1935            .as_ref()
1936            .and_then(|sub| sub.tools.acpl_data_1ch_pair[1].clone());
1937        let five_x_pair_active = five_x_pair_mode.is_some()
1938            && five_x_pair_cfg.is_some()
1939            && five_x_pair_data_1.is_some()
1940            && five_x_pair_data_2.is_some();
1941        // Round 37: detach the parsed `cfg0_centre_mono` payload (Cfg0
1942        // trailing `mono_data(0)`) for the 5_X pair / 7_X pair paths so
1943        // we can IMDCT its `scaled_spec` into a real centre carrier
1944        // (replacing the silence-placeholder used in round 36). For
1945        // ACPL_3 the centre is also pulled from the same source. The
1946        // detach is a clone so the substream tools borrow can be
1947        // released before we mutate decoder IMDCT state.
1948        let cfg0_centre_mono = self
1949            .last_substream
1950            .as_ref()
1951            .and_then(|sub| sub.tools.cfg0_centre_mono.clone());
1952        // Round 38 / 39: detach the 5_X SIMPLE/ASPX `coding_config`
1953        // payloads so we can drive end-to-end multichannel decode.
1954        // Round 38 wired Cfg2 (four_channel_data + cfg2_back_mono);
1955        // round 39 adds Cfg0 (b_2ch_mode + 2x two_channel_data +
1956        // cfg0_centre_mono), Cfg1 (three_channel_data + two_channel_data),
1957        // and Cfg3 (five_channel_data). Each helper computes its own
1958        // gating; we just detach the inputs once.
1959        let five_x_simple_aspx_active = self
1960            .last_substream
1961            .as_ref()
1962            .map(|sub| {
1963                matches!(
1964                    sub.tools.five_x_mode,
1965                    Some(crate::mch::FiveXCodecMode::Simple)
1966                        | Some(crate::mch::FiveXCodecMode::Aspx)
1967                )
1968            })
1969            .unwrap_or(false);
1970        let five_x_coding_cfg = self
1971            .last_substream
1972            .as_ref()
1973            .and_then(|sub| sub.tools.five_x_coding_config);
1974        let cfg2_four_channel_data = self
1975            .last_substream
1976            .as_ref()
1977            .and_then(|sub| sub.tools.four_channel_data.clone());
1978        let cfg2_back_mono = self
1979            .last_substream
1980            .as_ref()
1981            .and_then(|sub| sub.tools.cfg2_back_mono.clone());
1982        // Round 41: 5_X SIMPLE/ASPX cfg2 ASPX trailer detach. The
1983        // outer walker populates these when `5_X_codec_mode == ASPX`
1984        // (the SIMPLE path leaves them None and the dispatch falls
1985        // back to low-band only PCM).
1986        let cfg2_aspx_lr = self
1987            .last_substream
1988            .as_ref()
1989            .and_then(|sub| sub.tools.cfg2_aspx_lr.clone());
1990        let cfg2_aspx_ls_rs = self
1991            .last_substream
1992            .as_ref()
1993            .and_then(|sub| sub.tools.cfg2_aspx_ls_rs.clone());
1994        let cfg2_aspx_centre = self
1995            .last_substream
1996            .as_ref()
1997            .and_then(|sub| sub.tools.cfg2_aspx_centre.clone());
1998        // Round 42: cfg0 / cfg1 / cfg3 ASPX trailer detach.
1999        let cfg0_aspx_lr = self
2000            .last_substream
2001            .as_ref()
2002            .and_then(|sub| sub.tools.cfg0_aspx_lr.clone());
2003        let cfg0_aspx_ls_rs = self
2004            .last_substream
2005            .as_ref()
2006            .and_then(|sub| sub.tools.cfg0_aspx_ls_rs.clone());
2007        let cfg0_aspx_centre = self
2008            .last_substream
2009            .as_ref()
2010            .and_then(|sub| sub.tools.cfg0_aspx_centre.clone());
2011        let cfg1_aspx_lr = self
2012            .last_substream
2013            .as_ref()
2014            .and_then(|sub| sub.tools.cfg1_aspx_lr.clone());
2015        let cfg1_aspx_ls_rs = self
2016            .last_substream
2017            .as_ref()
2018            .and_then(|sub| sub.tools.cfg1_aspx_ls_rs.clone());
2019        let cfg1_aspx_centre = self
2020            .last_substream
2021            .as_ref()
2022            .and_then(|sub| sub.tools.cfg1_aspx_centre.clone());
2023        let cfg3_aspx_lr = self
2024            .last_substream
2025            .as_ref()
2026            .and_then(|sub| sub.tools.cfg3_aspx_lr.clone());
2027        let cfg3_aspx_ls_rs = self
2028            .last_substream
2029            .as_ref()
2030            .and_then(|sub| sub.tools.cfg3_aspx_ls_rs.clone());
2031        let cfg3_aspx_centre = self
2032            .last_substream
2033            .as_ref()
2034            .and_then(|sub| sub.tools.cfg3_aspx_centre.clone());
2035        let five_x_aspx_config = self
2036            .last_substream
2037            .as_ref()
2038            .and_then(|sub| sub.tools.aspx_config);
2039        // Round 42: companding_control() per-channel flags. The 5_X
2040        // ASPX path captures companding(3) (L/R, Ls/Rs, C) into
2041        // `tools.companding`; we lift the parsed flags here so the
2042        // dispatch can hand a per-channel companding-on bool to the
2043        // `aspx_extend_with_trailer` wrapper.
2044        let five_x_companding = self
2045            .last_substream
2046            .as_ref()
2047            .and_then(|sub| sub.tools.companding.clone());
2048        // Cfg0 / Cfg1 / Cfg3 5_X SIMPLE/ASPX detach. Round 39: the walker
2049        // already populates the same `tools.three_channel_data` /
2050        // `four_channel_data` / `five_channel_data` / `two_channel_data`
2051        // slots; here we detach clones for the dispatch helpers.
2052        let cfg_two_channel_data: Vec<crate::mch::TwoChannelData> = self
2053            .last_substream
2054            .as_ref()
2055            .map(|sub| sub.tools.two_channel_data.clone())
2056            .unwrap_or_default();
2057        let cfg_b_2ch_mode = self
2058            .last_substream
2059            .as_ref()
2060            .and_then(|sub| sub.tools.b_2ch_mode);
2061        let cfg_three_channel_data = self
2062            .last_substream
2063            .as_ref()
2064            .and_then(|sub| sub.tools.three_channel_data.clone());
2065        let cfg_five_channel_data = self
2066            .last_substream
2067            .as_ref()
2068            .and_then(|sub| sub.tools.five_channel_data.clone());
2069        // Round 39: 7_X SIMPLE/ASPX additional-channel pair (Table 182).
2070        // The walker populates `seven_x_additional_channel_data` with two
2071        // `sf_data(ASF)` bodies for the F / G preliminary outputs (slots
2072        // 5 / 6 in the bitstream order). Render with identity SAP for now.
2073        let seven_x_additional_channel_data = self
2074            .last_substream
2075            .as_ref()
2076            .and_then(|sub| sub.tools.seven_x_additional_channel_data.clone());
2077        let seven_x_simple_aspx_active = self
2078            .last_substream
2079            .as_ref()
2080            .map(|sub| {
2081                matches!(
2082                    sub.tools.seven_x_mode,
2083                    Some(crate::mch::SevenXCodecMode::Simple)
2084                        | Some(crate::mch::SevenXCodecMode::Aspx)
2085                )
2086            })
2087            .unwrap_or(false);
2088        // Round 37: 7_X ASPX_ACPL_1 / ASPX_ACPL_2 pair dispatch state
2089        // (mirrors the 5_X detach above). Both modes carry the same
2090        // shape of `acpl_config_1ch_*` + `acpl_data_1ch_pair`. The 7_X
2091        // walker also fires for 7.0 and 7.1 (b_has_lfe). Channel
2092        // mapping per Table 202 — for ACPL_1/_2 (no SIMPLE/ASPX
2093        // additional-channel block in scope), z6/z7 stay silent and
2094        // we populate slots 0..4 (L/R/C/Ls/Rs) only.
2095        let seven_x_pair_mode: Option<acpl_synth::Acpl5xPairMode> = self
2096            .last_substream
2097            .as_ref()
2098            .and_then(|sub| match sub.tools.seven_x_mode {
2099                Some(crate::mch::SevenXCodecMode::AspxAcpl1) => {
2100                    Some(acpl_synth::Acpl5xPairMode::AspxAcpl1)
2101                }
2102                Some(crate::mch::SevenXCodecMode::AspxAcpl2) => {
2103                    Some(acpl_synth::Acpl5xPairMode::AspxAcpl2)
2104                }
2105                _ => None,
2106            });
2107        let seven_x_pair_cfg =
2108            self.last_substream
2109                .as_ref()
2110                .and_then(|sub| match sub.tools.seven_x_mode {
2111                    Some(crate::mch::SevenXCodecMode::AspxAcpl1) => {
2112                        sub.tools.acpl_config_1ch_partial
2113                    }
2114                    Some(crate::mch::SevenXCodecMode::AspxAcpl2) => sub.tools.acpl_config_1ch_full,
2115                    _ => None,
2116                });
2117        let seven_x_pair_data_1 = self
2118            .last_substream
2119            .as_ref()
2120            .and_then(|sub| sub.tools.acpl_data_1ch_pair[0].clone());
2121        let seven_x_pair_data_2 = self
2122            .last_substream
2123            .as_ref()
2124            .and_then(|sub| sub.tools.acpl_data_1ch_pair[1].clone());
2125        let seven_x_pair_active = seven_x_pair_mode.is_some()
2126            && seven_x_pair_cfg.is_some()
2127            && seven_x_pair_data_1.is_some()
2128            && seven_x_pair_data_2.is_some();
2129        // Centre channel for ASPX_ACPL_3: round 38 wires the parsed
2130        // `cfg0_centre_mono.scaled_spec` (when present) through IMDCT +
2131        // overlap-add for slot 2 (centre). This replaces the round-37
2132        // silence placeholder used while the body decoder was deferred.
2133        // Falls back to a zero-filled placeholder when the centre body
2134        // isn't decoded (LFE / SSF / Huffman miss / ACPL_3 walker
2135        // doesn't populate cfg0_centre_mono on every frame) so the
2136        // length-checked run_acpl_5x_mch_pcm still fires and emits
2137        // shaped Ls/Rs from the L/R carriers.
2138        let five_x_centre_spec: Option<Vec<f32>> = if five_x_acpl3_active {
2139            let centre_pcm = cfg0_centre_mono
2140                .as_ref()
2141                .and_then(|m| self.imdct_mono_lfe_data_f32(m, 2, samples as usize));
2142            Some(centre_pcm.unwrap_or_else(|| vec![0.0_f32; samples as usize]))
2143        } else {
2144            None
2145        };
2146        // ASPX_ACPL_1 (joint-MDCT residual layer): M spectrum lives on
2147        // `scaled_spec_primary`, S on `scaled_spec_secondary`; both
2148        // share the same transform_info. Detect it via the parsed
2149        // stereo_codec_mode + acpl_config_1ch_partial (`partial` is the
2150        // ACPL_1 flavour).
2151        let acpl1_active = self
2152            .last_substream
2153            .as_ref()
2154            .map(|sub| {
2155                matches!(sub.tools.stereo_mode, Some(asf::StereoCodecMode::AspxAcpl1))
2156                    && sub.tools.acpl_config_1ch_partial.is_some()
2157                    && sub.tools.scaled_spec_primary.is_some()
2158                    && sub.tools.scaled_spec_secondary.is_some()
2159            })
2160            .unwrap_or(false);
2161        let (
2162            primary_in,
2163            secondary_in,
2164            aspx_tables,
2165            aspx_cfg,
2166            framing_pri,
2167            framing_sec,
2168            sig_pri,
2169            sig_sec,
2170            noise_pri,
2171            noise_sec,
2172            qmode_pri,
2173            qmode_sec,
2174            delta_dir_pri,
2175            delta_dir_sec,
2176            ah_pri,
2177            ah_sec,
2178            tna_pri,
2179            tna_sec,
2180        ) = if let Some(sub) = self.last_substream.as_ref() {
2181            let pri = sub
2182                .tools
2183                .scaled_spec_primary
2184                .as_ref()
2185                .zip(sub.tools.transform_info_primary.as_ref())
2186                .map(|(s, ti)| (s.clone(), ti.transform_length_0 as usize));
2187            let sec = sub
2188                .tools
2189                .scaled_spec_secondary
2190                .as_ref()
2191                .zip(sub.tools.transform_info_secondary.as_ref())
2192                .map(|(s, ti)| (s.clone(), ti.transform_length_0 as usize));
2193            let tables = sub.tools.aspx_frequency_tables.clone();
2194            let cfg = sub.tools.aspx_config;
2195            // add_harmonic flags per channel: prefer the 2-channel
2196            // hfgen payload when present, else fall back to the 1-ch
2197            // one for the primary channel (secondary inherits nothing
2198            // in that case — the 1-ch hfgen only covers one channel).
2199            let (ah_p, ah_s) = if let Some(h2) = sub.tools.aspx_hfgen_iwc_2ch.as_ref() {
2200                (
2201                    Some(h2.add_harmonic[0].clone()),
2202                    Some(h2.add_harmonic[1].clone()),
2203                )
2204            } else if let Some(h1) = sub.tools.aspx_hfgen_iwc_1ch.as_ref() {
2205                (Some(h1.add_harmonic.clone()), None)
2206            } else {
2207                (None, None)
2208            };
2209            // §5.7.6.4.1.3 Pseudocode 88 input — `aspx_tna_mode[ch][sbg]`.
2210            // 2-ch hfgen carries per-channel modes; 1-ch hfgen carries
2211            // a single channel's modes that we apply to the primary.
2212            let (tna_p, tna_s) = if let Some(h2) = sub.tools.aspx_hfgen_iwc_2ch.as_ref() {
2213                (Some(h2.tna_mode[0].clone()), Some(h2.tna_mode[1].clone()))
2214            } else if let Some(h1) = sub.tools.aspx_hfgen_iwc_1ch.as_ref() {
2215                (Some(h1.tna_mode.clone()), None)
2216            } else {
2217                (None, None)
2218            };
2219            (
2220                pri,
2221                sec,
2222                tables,
2223                cfg,
2224                sub.tools.aspx_framing_primary.clone(),
2225                sub.tools.aspx_framing_secondary.clone(),
2226                sub.tools.aspx_data_sig_primary.clone(),
2227                sub.tools.aspx_data_sig_secondary.clone(),
2228                sub.tools.aspx_data_noise_primary.clone(),
2229                sub.tools.aspx_data_noise_secondary.clone(),
2230                sub.tools.aspx_qmode_env_primary,
2231                sub.tools.aspx_qmode_env_secondary,
2232                sub.tools.aspx_delta_dir_primary.clone(),
2233                sub.tools.aspx_delta_dir_secondary.clone(),
2234                ah_p,
2235                ah_s,
2236                tna_p,
2237                tna_s,
2238            )
2239        } else {
2240            (
2241                None, None, None, None, None, None, None, None, None, None, None, None, None, None,
2242                None, None, None, None,
2243            )
2244        };
2245        // If the ASPX I-frame pipeline populated derived frequency
2246        // tables + config, run the A-SPX bandwidth-extension on top of
2247        // the IMDCT low-band PCM.
2248        let use_aspx_ext = aspx_tables.is_some() && aspx_cfg.is_some();
2249        let num_ts_in_ats = aspx::num_ts_in_ats(info.frame_length.max(1));
2250        // Round 43: per-channel companding mode from the parsed
2251        // `companding_control()`. For mono / stereo CPE paths the
2252        // grouping is `companding_control(1)` / `companding_control(2)`
2253        // — i.e. compand_on[0] is the primary channel, compand_on[1]
2254        // is the secondary (or the sole entry mirrors via sync_flag).
2255        let (compand_mode_pri, compand_mode_sec) = self
2256            .last_substream
2257            .as_ref()
2258            .map(|sub| {
2259                let cc = sub.tools.companding.as_ref();
2260                (
2261                    Self::five_x_compand_mode_for_slot(cc, 0),
2262                    Self::five_x_compand_mode_for_slot(cc, 1),
2263                )
2264            })
2265            .unwrap_or((aspx::CompandingMode::Off, aspx::CompandingMode::Off));
2266        // Round 43: §5.7.5.2 sb0 selection — for the ASPX_ACPL_1 codec
2267        // mode the companding tool starts at `acpl_qmf_band` instead of
2268        // `aspx_xover_band`. Both the stereo CPE ASPX_ACPL_1 path and
2269        // the 5_X ASPX_ACPL_1 path read this from
2270        // `acpl_config_1ch_partial.qmf_band`. `None` for any other
2271        // codec mode → falls back to `tables.sbx`.
2272        let compand_sb0_override: Option<u32> = self.last_substream.as_ref().and_then(|sub| {
2273            let stereo_acpl1 =
2274                matches!(sub.tools.stereo_mode, Some(asf::StereoCodecMode::AspxAcpl1));
2275            let five_x_acpl1 = matches!(
2276                sub.tools.five_x_mode,
2277                Some(crate::mch::FiveXCodecMode::AspxAcpl1)
2278            );
2279            if stereo_acpl1 || five_x_acpl1 {
2280                sub.tools
2281                    .acpl_config_1ch_partial
2282                    .as_ref()
2283                    .map(|c| c.qmf_band as u32)
2284            } else {
2285                None
2286            }
2287        });
2288        // Make sure the per-channel A-SPX state vector is large enough.
2289        while self.aspx_ext_state.len() < channels as usize {
2290            self.aspx_ext_state.push(aspx::AspxChannelExtState::new());
2291        }
2292        // Same for the SSF synth state.
2293        while self.ssf_synth_state.len() < channels as usize {
2294            self.ssf_synth_state.push(ssf_synth::SsfSynthState::new());
2295        }
2296        // §5.7.7 A-CPL: when the substream parsed `acpl_config_1ch` +
2297        // `acpl_data_1ch` we run the channel-pair synthesis on the
2298        // ASPX-extended primary PCM and emit two channels. The path
2299        // owns the primary IMDCT + ASPX path so `pcm_per_channel[1]`
2300        // ends up populated by the synth's `z1` output instead of by a
2301        // duplicate-of-primary fallback.
2302        let use_acpl =
2303            channels as usize >= 2 && acpl_active_cfg.is_some() && acpl_active_data.is_some();
2304        // Round 45: stereo-CPE M=2 synced companding. When
2305        // `companding_control(2)` carried `sync_flag == 1` and the
2306        // primary / secondary cohort both feed the standalone ASPX
2307        // path (i.e. `!use_acpl` — ACPL_1 stereo only ASPX-extends
2308        // the M-channel via the `acpl1_active` branch and so falls
2309        // outside the synced cohort), the two channels share one
2310        // geometric-mean gain `g_synch(ts) = √(g_0 · g_1)` per
2311        // Pseudocode 121's `sync_flag == 1` branch instead of two
2312        // independent per-channel gains. For 5_X ASPX_ACPL_3 the
2313        // primary / secondary are the L / R carriers feeding
2314        // Pseudocode 118's `run_acpl_5x_mch_pcm`, so this puts the
2315        // ACPL_3 surround-pair driver on the same synced footing as
2316        // r44's 5_X SIMPLE/ASPX dispatch. Resolves to `None` for
2317        // `sync_flag == 0`, missing companding, or any non-sync
2318        // sub-branch — falling back to the per-channel
2319        // `aspx_extend_pcm` path below.
2320        let stereo_cpe_synced_mode: Option<aspx::CompandingMode> = self
2321            .last_substream
2322            .as_ref()
2323            .and_then(|sub| sub.tools.companding.as_ref())
2324            .and_then(|cc| Self::five_x_synced_mode(Some(cc)));
2325        let use_stereo_cpe_synced = use_aspx_ext
2326            && !use_acpl
2327            && channels as usize >= 2
2328            && stereo_cpe_synced_mode.is_some()
2329            && primary_in.is_some()
2330            && secondary_in.is_some()
2331            && primary_in.as_ref().map(|(_, n)| *n) == secondary_in.as_ref().map(|(_, n)| *n);
2332        if use_stereo_cpe_synced {
2333            // Synced stereo-CPE pipeline. IMDCT each channel, then
2334            // run the M=2 phase-1 / sync-apply / phase-2 helper.
2335            // SAFETY of the unwraps: guarded by `use_stereo_cpe_synced`
2336            // (use_aspx_ext, primary_in.is_some(), secondary_in.is_some(),
2337            // stereo_cpe_synced_mode.is_some()).
2338            let (p_scaled, p_n) = primary_in.as_ref().unwrap();
2339            let (s_scaled, s_n) = secondary_in.as_ref().unwrap();
2340            let n = *p_n;
2341            if n > 0 && n == samples as usize && *s_n == n && !pcm_per_channel.is_empty() {
2342                let pcm_pri_f = self.imdct_channel_f32(0, p_scaled, n);
2343                let pcm_sec_f = self.imdct_channel_f32(1, s_scaled, n);
2344                let pri_input = StereoCpeChannelInput {
2345                    ch_index: 0,
2346                    pcm_in: &pcm_pri_f,
2347                    framing: framing_pri.as_ref(),
2348                    sig: sig_pri.as_deref(),
2349                    noise: noise_pri.as_deref(),
2350                    qmode: qmode_pri,
2351                    delta_dir: delta_dir_pri.as_ref(),
2352                    add_harmonic: ah_pri.as_deref(),
2353                    tna_mode: tna_pri.as_deref(),
2354                };
2355                let sec_input = StereoCpeChannelInput {
2356                    ch_index: 1,
2357                    pcm_in: &pcm_sec_f,
2358                    framing: framing_sec.as_ref().or(framing_pri.as_ref()),
2359                    sig: sig_sec.as_deref(),
2360                    noise: noise_sec.as_deref(),
2361                    qmode: qmode_sec.or(qmode_pri),
2362                    delta_dir: delta_dir_sec.as_ref().or(delta_dir_pri.as_ref()),
2363                    add_harmonic: ah_sec.as_deref().or(ah_pri.as_deref()),
2364                    tna_mode: tna_sec.as_deref().or(tna_pri.as_deref()),
2365                };
2366                let (ext_pri, ext_sec) = self.extend_stereo_cpe_pair_with_sync_companding(
2367                    &pri_input,
2368                    &sec_input,
2369                    aspx_tables.as_ref().unwrap(),
2370                    aspx_cfg.as_ref().unwrap(),
2371                    num_ts_in_ats,
2372                    stereo_cpe_synced_mode.unwrap(),
2373                    compand_sb0_override,
2374                );
2375                if pcm_per_channel.len() < 2 {
2376                    while pcm_per_channel.len() < 2 {
2377                        pcm_per_channel.push(None);
2378                    }
2379                }
2380                pcm_per_channel[0] = Some(Self::pcm_f32_to_i16(&ext_pri));
2381                pcm_per_channel[1] = Some(Self::pcm_f32_to_i16(&ext_sec));
2382            }
2383        }
2384        if !use_stereo_cpe_synced {
2385            if let Some((scaled, n)) = primary_in {
2386                if n > 0 && n == samples as usize && !pcm_per_channel.is_empty() {
2387                    if use_aspx_ext {
2388                        let pcm_f = self.imdct_channel_f32(0, &scaled, n);
2389                        let state = &mut self.aspx_ext_state[0];
2390                        let extended = Self::aspx_extend_pcm(
2391                            &pcm_f,
2392                            aspx_tables.as_ref().unwrap(),
2393                            aspx_cfg.as_ref().unwrap(),
2394                            framing_pri.as_ref(),
2395                            sig_pri.as_deref(),
2396                            noise_pri.as_deref(),
2397                            qmode_pri,
2398                            delta_dir_pri.as_ref(),
2399                            ah_pri.as_deref(),
2400                            tna_pri.as_deref(),
2401                            state,
2402                            num_ts_in_ats,
2403                            compand_mode_pri,
2404                            compand_sb0_override,
2405                        );
2406                        if use_acpl {
2407                            if let (Some(cfg), Some(data)) =
2408                                (acpl_active_cfg.as_ref(), acpl_active_data.as_ref())
2409                            {
2410                                // ASPX_ACPL_1: feed both M (extended) and S
2411                                // PCM into the stereo A-CPL. The S spectrum
2412                                // is already in `secondary_in`; we IMDCT it
2413                                // here without ASPX (the `aspx_data_1ch` in
2414                                // ACPL_1 covers the M channel only).
2415                                let acpl1_result = if acpl1_active {
2416                                    if let Some((s_scaled, s_n)) = secondary_in.as_ref() {
2417                                        if *s_n == n {
2418                                            let s_pcm = self.imdct_channel_f32(1, s_scaled, *s_n);
2419                                            acpl_synth::run_acpl_1ch_pcm_stereo(
2420                                                &extended,
2421                                                &s_pcm,
2422                                                cfg,
2423                                                data,
2424                                                &mut self.acpl_state,
2425                                            )
2426                                        } else {
2427                                            None
2428                                        }
2429                                    } else {
2430                                        None
2431                                    }
2432                                } else {
2433                                    acpl_synth::run_acpl_1ch_pcm(
2434                                        &extended,
2435                                        cfg,
2436                                        data,
2437                                        &mut self.acpl_state,
2438                                    )
2439                                };
2440                                if let Some((left, right)) = acpl1_result {
2441                                    pcm_per_channel[0] = Some(Self::pcm_f32_to_i16(&left));
2442                                    pcm_per_channel[1] = Some(Self::pcm_f32_to_i16(&right));
2443                                } else {
2444                                    pcm_per_channel[0] = Some(Self::pcm_f32_to_i16(&extended));
2445                                }
2446                            } else {
2447                                pcm_per_channel[0] = Some(Self::pcm_f32_to_i16(&extended));
2448                            }
2449                        } else {
2450                            pcm_per_channel[0] = Some(Self::pcm_f32_to_i16(&extended));
2451                        }
2452                    } else {
2453                        pcm_per_channel[0] = Some(self.imdct_channel(0, &scaled, n));
2454                    }
2455                }
2456            }
2457            if channels as usize >= 2 && !use_acpl {
2458                if let Some((scaled, n)) = secondary_in {
2459                    if n > 0 && n == samples as usize {
2460                        if use_aspx_ext {
2461                            let pcm_f = self.imdct_channel_f32(1, &scaled, n);
2462                            let state = &mut self.aspx_ext_state[1];
2463                            let extended = Self::aspx_extend_pcm(
2464                                &pcm_f,
2465                                aspx_tables.as_ref().unwrap(),
2466                                aspx_cfg.as_ref().unwrap(),
2467                                framing_sec.as_ref().or(framing_pri.as_ref()),
2468                                sig_sec.as_deref(),
2469                                noise_sec.as_deref(),
2470                                qmode_sec.or(qmode_pri),
2471                                delta_dir_sec.as_ref().or(delta_dir_pri.as_ref()),
2472                                ah_sec.as_deref().or(ah_pri.as_deref()),
2473                                tna_sec.as_deref().or(tna_pri.as_deref()),
2474                                state,
2475                                num_ts_in_ats,
2476                                compand_mode_sec,
2477                                compand_sb0_override,
2478                            );
2479                            pcm_per_channel[1] = Some(Self::pcm_f32_to_i16(&extended));
2480                        } else {
2481                            pcm_per_channel[1] = Some(self.imdct_channel(1, &scaled, n));
2482                        }
2483                    }
2484                }
2485            }
2486        } // end `if !use_stereo_cpe_synced`
2487          // SSF synthesis path — if either ssf_data_* is populated and
2488          // the corresponding `pcm_per_channel[ch]` slot is still empty
2489          // (the ASF Huffman pipeline didn't fire because spec_frontend
2490          // was SSF), drive §5.2.3-5.2.7 → IMDCT to produce real PCM.
2491          // Synthesize each granule into a `num_blocks * n_mdct`-long
2492          // spectrum vector, then IMDCT each `n_mdct` block independently
2493          // and concat the resulting overlap-added PCM.
2494        if let Some(data) = ssf_primary.as_ref() {
2495            if !pcm_per_channel.is_empty() && pcm_per_channel[0].is_none() {
2496                let pcm = self.run_ssf_channel(0, data, samples as usize);
2497                if !pcm.is_empty() {
2498                    pcm_per_channel[0] = Some(pcm);
2499                }
2500            }
2501        }
2502        if channels as usize >= 2 {
2503            if let Some(data) = ssf_secondary.as_ref() {
2504                if pcm_per_channel.len() >= 2 && pcm_per_channel[1].is_none() {
2505                    let pcm = self.run_ssf_channel(1, data, samples as usize);
2506                    if !pcm.is_empty() {
2507                        pcm_per_channel[1] = Some(pcm);
2508                    }
2509                }
2510            }
2511        }
2512        // §5.7.7.6.2 ASPX_ACPL_3 5_X synthesis (Pseudocode 118) —
2513        // When the substream parsed acpl_config_2ch + acpl_data_2ch and
2514        // the stereo-body path decoded the L/R carrier spectra, run the
2515        // full 5-channel A-CPL synthesis and populate channels 0..4.
2516        // Only fires when all five pcm_per_channel slots are still empty
2517        // (i.e. the standard stereo path didn't already claim them), or
2518        // when the frame is explicitly a 5_X ASPX_ACPL_3 substream.
2519        if five_x_acpl3_active {
2520            if let (Some(cfg), Some(data), Some(centre)) = (
2521                five_x_acpl3_cfg.as_ref(),
2522                five_x_acpl3_data.as_ref(),
2523                five_x_centre_spec.as_deref(),
2524            ) {
2525                // Carrier L and R come from pcm_per_channel[0] / [1] (already
2526                // filled by the stereo ASF / ASPX decode path above). If they
2527                // are present use them; otherwise zero-fill as placeholders so
2528                // the A-CPL synthesis still produces shaped Ls/Rs.
2529                let n = samples as usize;
2530                let pcm_l_f32: Vec<f32> = pcm_per_channel
2531                    .first()
2532                    .and_then(|p| p.as_ref())
2533                    .map(|v| v.iter().map(|&s| s as f32 / 32767.0).collect())
2534                    .unwrap_or_else(|| vec![0.0_f32; n]);
2535                let pcm_r_f32: Vec<f32> = pcm_per_channel
2536                    .get(1)
2537                    .and_then(|p| p.as_ref())
2538                    .map(|v| v.iter().map(|&s| s as f32 / 32767.0).collect())
2539                    .unwrap_or_else(|| vec![0.0_f32; n]);
2540                if let Some(out) = acpl_synth::run_acpl_5x_mch_pcm(
2541                    &pcm_l_f32,
2542                    &pcm_r_f32,
2543                    centre,
2544                    cfg,
2545                    data,
2546                    &mut self.acpl_5x_mch_state,
2547                ) {
2548                    // Output channel mapping for 5.0/5.1:
2549                    //   ch0 = L, ch1 = R, ch2 = C, ch3 = Ls, ch4 = Rs.
2550                    // Resize pcm_per_channel to 5 slots if needed.
2551                    while pcm_per_channel.len() < 5 {
2552                        pcm_per_channel.push(None);
2553                    }
2554                    pcm_per_channel[0] = Some(Self::pcm_f32_to_i16(&out.left));
2555                    pcm_per_channel[1] = Some(Self::pcm_f32_to_i16(&out.right));
2556                    pcm_per_channel[2] = Some(Self::pcm_f32_to_i16(&out.centre));
2557                    pcm_per_channel[3] = Some(Self::pcm_f32_to_i16(&out.left_surround));
2558                    pcm_per_channel[4] = Some(Self::pcm_f32_to_i16(&out.right_surround));
2559                }
2560            }
2561        }
2562        // §5.7.7.6.1 ASPX_ACPL_1 / ASPX_ACPL_2 5_X synthesis (Pseudocode 117) —
2563        // When the 5_X walker resolved `five_x_mode` to AspxAcpl1 / AspxAcpl2
2564        // and parsed the matching `acpl_config_1ch_*` + `acpl_data_1ch_pair`,
2565        // run the channel-pair synthesis on the L/R carrier PCM and emit
2566        // L / R / C / Ls / Rs.
2567        //
2568        // L/R carriers come from `pcm_per_channel[0]/[1]` (already filled
2569        // by the stereo ASF/ASPX decode path above when present, else
2570        // zero-filled placeholders). The centre carrier mirrors the
2571        // ACPL_3 path — `cfg0_centre_mono` exists in the tools struct
2572        // but lacks an end-to-end decode path; we use silence so the
2573        // QMF lengths line up. ACPL_1's Ls/Rs surround carriers are
2574        // similarly silence-placeholders for the same reason: A-CPL
2575        // synthesis still produces shaped Ls/Rs from the L/R carriers
2576        // and the pair parameters; the contribution from the surround
2577        // carriers (when those gain a real decode path) just adds in
2578        // on top.
2579        if five_x_pair_active && !five_x_acpl3_active {
2580            if let (Some(mode), Some(cfg), Some(data_1), Some(data_2)) = (
2581                five_x_pair_mode,
2582                five_x_pair_cfg.as_ref(),
2583                five_x_pair_data_1.as_ref(),
2584                five_x_pair_data_2.as_ref(),
2585            ) {
2586                // Round 37: IMDCT the parsed centre `mono_data(0)`
2587                // spectrum (Cfg0 trailing) into a real PCM carrier;
2588                // falls back to silence when `scaled_spec` is None
2589                // (LFE / SSF / Huffman miss) — see `imdct_mono_lfe_data_f32`.
2590                let centre_pcm = cfg0_centre_mono
2591                    .as_ref()
2592                    .and_then(|m| self.imdct_mono_lfe_data_f32(m, 2, samples as usize));
2593                // Round 40: standalone Ls/Rs surround mono walker for
2594                // ACPL_1's Mode 1 surround-driven path. The 5_X
2595                // ASPX_ACPL_1 inner walker now persists the joint-MDCT
2596                // residual pair (sSMP,3 / sSMP,4 per Table 181) on
2597                // `tools.acpl_1_residual_pair`; we IMDCT them here into
2598                // Ls/Rs PCM carriers and feed them as the `x3` / `x4`
2599                // inputs of Pseudocode 117. ACPL_2 mode never emits a
2600                // residual pair (no max_sfb_master in the walker), so
2601                // the detach is `None` for that path → silence — same
2602                // as the round-37 placeholder.
2603                //
2604                // Round 46 — ACPL_1 surround Ls/Rs ASPX extension:
2605                // SPEC-CONFIRMS-NOT-ASPX. Per ETSI TS 103 190-1 §4.2.6.6
2606                // Table 25 row `case ASPX_ACPL_1:` (the `5_X_codec_mode
2607                // == ASPX_ACPL_1` body parsed by
2608                // `parse_aspx_acpl_1_2_inner_body` in `mch.rs`) the
2609                // trailer order is `aspx_data_2ch()` (L/R primary
2610                // carriers) + `aspx_data_1ch()` (centre mono) + two
2611                // `acpl_data_1ch()` parameter sets — there is NO third
2612                // ASPX trailer for the surround Ls/Rs pair. The Ls/Rs
2613                // carriers are the joint-MDCT residual sSMP,3 / sSMP,4
2614                // straight out of the inner sf_data×2 walker; per
2615                // §5.7.5.2 / §5.7.6 ASPX BWE applies to the
2616                // M-channel-side carriers only (acpl_qmf_band-rooted
2617                // sb0 on the L/R primary pair + centre mono, never on
2618                // the residual surround pair). Feeding them raw into
2619                // Pseudocode 117 as `x3` / `x4` matches the spec — the
2620                // post-Pseudocode-117 surround output gets its
2621                // synthesis-bandwidth shape from the L/R carriers via
2622                // alpha/beta/decorrelator, not from independent
2623                // surround-pair extension. Same finding for the
2624                // matching M=2 surround-pair synced companding cohort:
2625                // no carriers means no companding to sync. Round-46
2626                // therefore wires no new surround-pair ASPX/companding
2627                // path here; the existing raw-PCM path is correct.
2628                let acpl_1_residual_pair = self
2629                    .last_substream
2630                    .as_ref()
2631                    .map(|sub| sub.tools.acpl_1_residual_pair.clone())
2632                    .unwrap_or([None, None]);
2633                // Round 41: §5.3.4.3.2 / Table 181 first-stage matrix —
2634                // when the 5_X ACPL_1 walker captured the two
2635                // `chparam_info()` payloads + the joint-MDCT residual
2636                // pair AND the inner `two_channel_data` carries
2637                // sSMP_A / sSMP_B spectra, mix per-sfb to produce
2638                // preliminary (L, R, Ls, Rs) spectra, IMDCT each, and
2639                // feed those PCMs into Pseudocode 117.
2640                //
2641                // When the SAP inputs aren't all available (ACPL_2 path,
2642                // or non-AspxAcpl1 mode, or any of the inputs missing)
2643                // fall through to the round-40 path: raw sSMP_3/sSMP_4
2644                // PCM as ls/rs, slots 0/1 untouched.
2645                let chparam_pair = self
2646                    .last_substream
2647                    .as_ref()
2648                    .map(|sub| sub.tools.acpl_1_residual_chparam.clone())
2649                    .unwrap_or([None, None]);
2650                let max_sfb_master_opt: Option<u32> = self
2651                    .last_substream
2652                    .as_ref()
2653                    .and_then(|sub| sub.tools.acpl_1_residual_max_sfb_master);
2654                let inner_tcd_specs: Option<(Vec<f32>, Vec<f32>)> =
2655                    self.last_substream.as_ref().and_then(|sub| {
2656                        let tcd = sub.tools.two_channel_data.first()?;
2657                        let a = tcd.scaled_spec_per_channel.first().cloned().flatten()?;
2658                        let b = tcd.scaled_spec_per_channel.get(1).cloned().flatten()?;
2659                        Some((a, b))
2660                    });
2661                let sap_outputs: Option<asf::SapTable181Output> = match (
2662                    mode,
2663                    inner_tcd_specs.as_ref(),
2664                    &chparam_pair,
2665                    &acpl_1_residual_pair,
2666                    max_sfb_master_opt,
2667                ) {
2668                    (
2669                        acpl_synth::Acpl5xPairMode::AspxAcpl1,
2670                        Some((a_spec, b_spec)),
2671                        [Some(cp0), Some(cp1)],
2672                        [Some((tl3, s3)), Some((tl4, s4))],
2673                        Some(max_sfb_master),
2674                    ) if *tl3 == *tl4
2675                        && *tl3 as usize == samples as usize
2676                        && max_sfb_master > 0 =>
2677                    {
2678                        asf::apply_sap_table_181(
2679                            a_spec,
2680                            b_spec,
2681                            s3,
2682                            s4,
2683                            &[cp0.clone(), cp1.clone()],
2684                            max_sfb_master,
2685                            *tl3,
2686                        )
2687                    }
2688                    _ => None,
2689                };
2690                let (ls_pcm, rs_pcm) =
2691                    if let Some((l_spec, r_spec, ls_spec, rs_spec)) = sap_outputs.as_ref() {
2692                        // SAP path: replace pcm_per_channel[0]/[1] with the
2693                        // mixed L/R PCM and pass mixed Ls/Rs PCM into the
2694                        // pair dispatcher.
2695                        let n = samples as usize;
2696                        let l_pcm = self.imdct_channel_f32(0, l_spec, n);
2697                        let r_pcm = self.imdct_channel_f32(1, r_spec, n);
2698                        while pcm_per_channel.len() < 2 {
2699                            pcm_per_channel.push(None);
2700                        }
2701                        pcm_per_channel[0] = Some(Self::pcm_f32_to_i16(&l_pcm));
2702                        pcm_per_channel[1] = Some(Self::pcm_f32_to_i16(&r_pcm));
2703                        let ls_pcm = self.imdct_channel_f32(3, ls_spec, n);
2704                        let rs_pcm = self.imdct_channel_f32(4, rs_spec, n);
2705                        (Some(ls_pcm), Some(rs_pcm))
2706                    } else {
2707                        let ls_pcm = acpl_1_residual_pair[0].as_ref().and_then(|(tl, scaled)| {
2708                            if *tl as usize == samples as usize {
2709                                Some(self.imdct_channel_f32(3, scaled, samples as usize))
2710                            } else {
2711                                None
2712                            }
2713                        });
2714                        let rs_pcm = acpl_1_residual_pair[1].as_ref().and_then(|(tl, scaled)| {
2715                            if *tl as usize == samples as usize {
2716                                Some(self.imdct_channel_f32(4, scaled, samples as usize))
2717                            } else {
2718                                None
2719                            }
2720                        });
2721                        (ls_pcm, rs_pcm)
2722                    };
2723                self.dispatch_acpl_5x_pair(
2724                    mode,
2725                    cfg,
2726                    data_1,
2727                    data_2,
2728                    samples as usize,
2729                    centre_pcm.as_deref(),
2730                    ls_pcm.as_deref(),
2731                    rs_pcm.as_deref(),
2732                    &mut pcm_per_channel,
2733                );
2734            }
2735        }
2736        // §5.7.7.6.3 Pseudocode 120 — 7_X ASPX_ACPL_1 / ASPX_ACPL_2
2737        // dispatch (mirrors the 5_X path above). Channel mapping is
2738        // Table 202 (channel_mode, add_ch_base) — for ACPL_1/_2 the
2739        // additional 2 channels (z6/z7 in Pseudocode 120) live outside
2740        // the A-CPL pair so they aren't generated here; we populate
2741        // slots 0..4 (L/R/C/Ls/Rs) and leave 5..7 for the per-channel
2742        // fallback path. The pair core itself is bit-equivalent to
2743        // Pseudocode 117 — same `(z0, z1) = ACplModule(...)` shape +
2744        // `z1 *= sqrt(2)` / `z3 *= sqrt(2)` scaling — modulo the extra
2745        // `add_ch_base == 0` z0/z2 sqrt(2) tweak which only fires when
2746        // the additional channels carry the L/R pair. Since we treat
2747        // the additional pair as silence here, that conditional scale
2748        // does not affect the produced 5-channel core.
2749        if seven_x_pair_active {
2750            if let (Some(mode), Some(cfg), Some(data_1), Some(data_2)) = (
2751                seven_x_pair_mode,
2752                seven_x_pair_cfg.as_ref(),
2753                seven_x_pair_data_1.as_ref(),
2754                seven_x_pair_data_2.as_ref(),
2755            ) {
2756                let centre_pcm = cfg0_centre_mono
2757                    .as_ref()
2758                    .and_then(|m| self.imdct_mono_lfe_data_f32(m, 2, samples as usize));
2759                // Round 40: same standalone Ls/Rs surround mono walker
2760                // as the 5_X path — the 7_X ASPX_ACPL_1 walker writes
2761                // to the same `acpl_1_residual_pair` slot. ACPL_2 path
2762                // detaches `None` (no residual pair).
2763                let acpl_1_residual_pair = self
2764                    .last_substream
2765                    .as_ref()
2766                    .map(|sub| sub.tools.acpl_1_residual_pair.clone())
2767                    .unwrap_or([None, None]);
2768                let ls_pcm = acpl_1_residual_pair[0].as_ref().and_then(|(tl, scaled)| {
2769                    if *tl as usize == samples as usize {
2770                        Some(self.imdct_channel_f32(3, scaled, samples as usize))
2771                    } else {
2772                        None
2773                    }
2774                });
2775                let rs_pcm = acpl_1_residual_pair[1].as_ref().and_then(|(tl, scaled)| {
2776                    if *tl as usize == samples as usize {
2777                        Some(self.imdct_channel_f32(4, scaled, samples as usize))
2778                    } else {
2779                        None
2780                    }
2781                });
2782                self.dispatch_acpl_5x_pair(
2783                    mode,
2784                    cfg,
2785                    data_1,
2786                    data_2,
2787                    samples as usize,
2788                    centre_pcm.as_deref(),
2789                    ls_pcm.as_deref(),
2790                    rs_pcm.as_deref(),
2791                    &mut pcm_per_channel,
2792                );
2793            }
2794        }
2795        // Round 38 / 39: §5.3.4.3.1 / Table 180 — 5_X SIMPLE/ASPX
2796        // end-to-end decode. Round 38 wired Cfg2; round 39 wires Cfg0,
2797        // Cfg1, Cfg3. Mutually exclusive with the ACPL_3 / pair paths
2798        // above (they own different `five_x_mode` enums), so each cfg
2799        // fires only when the SIMPLE/ASPX pure-MDCT path is in scope.
2800        if five_x_simple_aspx_active && !five_x_acpl3_active && !five_x_pair_active {
2801            match five_x_coding_cfg {
2802                Some(crate::mch::FiveXCodingConfig::Cfg0Stereo2plusMono)
2803                    if cfg_two_channel_data.len() >= 2 =>
2804                {
2805                    let b_2ch = cfg_b_2ch_mode.unwrap_or(false);
2806                    self.dispatch_5x_cfg0_simple_aspx(
2807                        &cfg_two_channel_data[0],
2808                        &cfg_two_channel_data[1],
2809                        b_2ch,
2810                        cfg0_centre_mono.as_ref(),
2811                        cfg0_aspx_lr.as_ref(),
2812                        cfg0_aspx_ls_rs.as_ref(),
2813                        cfg0_aspx_centre.as_ref(),
2814                        five_x_aspx_config,
2815                        five_x_companding.as_ref(),
2816                        num_ts_in_ats,
2817                        samples as usize,
2818                        &mut pcm_per_channel,
2819                    );
2820                }
2821                Some(crate::mch::FiveXCodingConfig::Cfg1ThreeStereo) => {
2822                    if let (Some(three), Some(tcd)) = (
2823                        cfg_three_channel_data.as_ref(),
2824                        cfg_two_channel_data.first(),
2825                    ) {
2826                        self.dispatch_5x_cfg1_simple_aspx(
2827                            three,
2828                            tcd,
2829                            cfg1_aspx_lr.as_ref(),
2830                            cfg1_aspx_ls_rs.as_ref(),
2831                            cfg1_aspx_centre.as_ref(),
2832                            five_x_aspx_config,
2833                            five_x_companding.as_ref(),
2834                            num_ts_in_ats,
2835                            samples as usize,
2836                            &mut pcm_per_channel,
2837                        );
2838                    }
2839                }
2840                Some(crate::mch::FiveXCodingConfig::Cfg2FourMono) => {
2841                    if let Some(four) = cfg2_four_channel_data.as_ref() {
2842                        self.dispatch_5x_cfg2_simple_aspx(
2843                            four,
2844                            cfg2_back_mono.as_ref(),
2845                            cfg2_aspx_lr.as_ref(),
2846                            cfg2_aspx_ls_rs.as_ref(),
2847                            cfg2_aspx_centre.as_ref(),
2848                            five_x_aspx_config,
2849                            five_x_companding.as_ref(),
2850                            num_ts_in_ats,
2851                            samples as usize,
2852                            &mut pcm_per_channel,
2853                        );
2854                    }
2855                }
2856                Some(crate::mch::FiveXCodingConfig::Cfg3Five) => {
2857                    if let Some(five) = cfg_five_channel_data.as_ref() {
2858                        self.dispatch_5x_cfg3_simple_aspx(
2859                            five,
2860                            cfg3_aspx_lr.as_ref(),
2861                            cfg3_aspx_ls_rs.as_ref(),
2862                            cfg3_aspx_centre.as_ref(),
2863                            five_x_aspx_config,
2864                            five_x_companding.as_ref(),
2865                            num_ts_in_ats,
2866                            samples as usize,
2867                            &mut pcm_per_channel,
2868                        );
2869                    }
2870                }
2871                _ => {}
2872            }
2873        }
2874        // Round 91: 7_X SIMPLE/ASPX inner 5-channel core render (slots
2875        // 0..4). The 7_X SIMPLE/Cfg3Five path inherits the inner
2876        // `five_channel_data()` from the 5_X Table 29 layout (5 SCEs in
2877        // L/R/C/Ls/Rs order, identity SAP via 5x `chparam_info(sap_mode
2878        // = 0)`); the only difference from the 5_X dispatch is which
2879        // walker populated `tools.five_channel_data` (7_X here, vs 5_X
2880        // for the 5.0/5.1 paths). The 5_X dispatch fires the same
2881        // IMDCT/KBD/overlap-add chain regardless of which walker
2882        // populated the slot, so we route the 7_X-walker-produced
2883        // five_channel_data through it. With identity SAP no joint-MDCT
2884        // mixing happens at decode time so each output slot 0..4 reflects
2885        // only its own input SCE. ASPX trailers for the 7_X path land in
2886        // different `tools.*_aspx_*` slots (the 7_X walker has its own
2887        // ASPX trailer plumbing — out of scope here); pass `None` for
2888        // the trailer slots so the round-91 SIMPLE path reduces to
2889        // low-band only. Cfg0/Cfg1/Cfg2 7_X variants need their own
2890        // wiring (queued for follow-up rounds — they share the same
2891        // 5_X core dispatchers, just with the 7_X-specific trailing
2892        // `mono_data(0)` gate and ASPX trailer plumbing).
2893        if seven_x_simple_aspx_active
2894            && matches!(
2895                self.last_substream
2896                    .as_ref()
2897                    .and_then(|sub| sub.tools.seven_x_coding_config),
2898                Some(crate::mch::FiveXCodingConfig::Cfg3Five)
2899            )
2900        {
2901            if let Some(five) = self
2902                .last_substream
2903                .as_ref()
2904                .and_then(|sub| sub.tools.five_channel_data.clone())
2905            {
2906                self.dispatch_5x_cfg3_simple_aspx(
2907                    &five,
2908                    None,
2909                    None,
2910                    None,
2911                    None,
2912                    None,
2913                    num_ts_in_ats,
2914                    samples as usize,
2915                    &mut pcm_per_channel,
2916                );
2917            }
2918        }
2919        // Round 39 / 40: §5.3.4.4.1 / Table 182 + Table 183 — 7_X
2920        // SIMPLE/ASPX additional-channel pair render. The walker populates
2921        // `seven_x_additional_channel_data` (two sf_data(ASF) bodies)
2922        // when `7_X_codec_mode in {SIMPLE, ASPX}`. Slots 5 / 6 (the F/G
2923        // preliminary outputs in Table 182) get the IMDCT'd low-band PCM.
2924        //
2925        // Round 40 wires the SAP a/b/c/d coefficient extraction
2926        // (`extract_sap_abcd` per Pseudocode 59) through Table 183's
2927        // 2-pair joint-stereo matrix when `b_use_sap_add_ch == true` AND
2928        // partner spectra (D, E for `coding_config in {0, 2, 3}` —
2929        // 3/4/0.x channel_mode) are present. The dispatch walks
2930        // (P, F) → (slot_high, slot_low) and (Q, G) → (slot_high+1,
2931        // slot_low+1) per-sfb in the spectral domain. With identity SAP
2932        // (`b_use_sap_add_ch == false`), the partner spectra are left
2933        // untouched at their 5.X-core slots and only F/G land at slots
2934        // 5/6 — matching the round-39 behaviour.
2935        //
2936        // The 7_X ACPL_1/_2 walker has its own additional-channel
2937        // handling per §5.3.4.4.2/.3 (z6/z7 in Pseudocode 120) — this
2938        // branch is gated on the SIMPLE/ASPX active-flag.
2939        if seven_x_simple_aspx_active {
2940            if let Some(add) = seven_x_additional_channel_data.as_ref() {
2941                // Resolve partner spectra + slots based on the active
2942                // 7_X coding_config. Per Table 183 row "3/4/0.x" (the
2943                // standard 7.0/7.1 layout that our 7_X walker handles)
2944                // the partner pair is (Ls, Rs) — slot 3 / slot 4 in our
2945                // 5.X-core dispatch; F/G lift to (Lb, Rb) on slot 5/6.
2946                let partner_slots: [usize; 2] = [3, 4];
2947                let (partner_d, partner_e): (Option<Vec<f32>>, Option<Vec<f32>>) =
2948                    match five_x_coding_cfg {
2949                        Some(crate::mch::FiveXCodingConfig::Cfg2FourMono) => {
2950                            // 5_X cfg2 four_channel_data carries [L, R, Ls, Rs]
2951                            // in indices [0, 1, 2, 3] per Table 180. The
2952                            // surround pair lives at four[2]/four[3].
2953                            let (d, e) = match cfg2_four_channel_data.as_ref() {
2954                                Some(four) => (
2955                                    four.scaled_spec_per_channel.get(2).cloned().flatten(),
2956                                    four.scaled_spec_per_channel.get(3).cloned().flatten(),
2957                                ),
2958                                None => (None, None),
2959                            };
2960                            (d, e)
2961                        }
2962                        Some(crate::mch::FiveXCodingConfig::Cfg3Five) => {
2963                            // 5_X cfg3 five_channel_data lays out [L, R, C,
2964                            // Ls, Rs] per Table 180. Surround pair lives at
2965                            // five[3]/five[4].
2966                            let (d, e) = match cfg_five_channel_data.as_ref() {
2967                                Some(five) => (
2968                                    five.scaled_spec_per_channel.get(3).cloned().flatten(),
2969                                    five.scaled_spec_per_channel.get(4).cloned().flatten(),
2970                                ),
2971                                None => (None, None),
2972                            };
2973                            (d, e)
2974                        }
2975                        Some(crate::mch::FiveXCodingConfig::Cfg1ThreeStereo) => {
2976                            // 5_X cfg1 three_channel_data + two_channel_data:
2977                            // surround pair lives at the trailing
2978                            // two_channel_data[0]/[1] (slots 3/4 in our
2979                            // dispatch). Use the parsed scaled_spec.
2980                            let (d, e) = match cfg_two_channel_data.first() {
2981                                Some(tcd) => (
2982                                    tcd.scaled_spec_per_channel.first().cloned().flatten(),
2983                                    tcd.scaled_spec_per_channel.get(1).cloned().flatten(),
2984                                ),
2985                                None => (None, None),
2986                            };
2987                            (d, e)
2988                        }
2989                        _ => (None, None),
2990                    };
2991                let chparam_pair = self
2992                    .last_substream
2993                    .as_ref()
2994                    .and_then(|sub| sub.tools.seven_x_add_chparam_info.as_ref().cloned());
2995                let partner_pair: Option<[&[f32]; 2]> =
2996                    match (partner_d.as_ref(), partner_e.as_ref()) {
2997                        (Some(d), Some(e)) => Some([d.as_slice(), e.as_slice()]),
2998                        _ => None,
2999                    };
3000                self.dispatch_7x_additional_channel_pair(
3001                    add,
3002                    partner_pair,
3003                    partner_slots,
3004                    chparam_pair.as_ref(),
3005                    samples as usize,
3006                    &mut pcm_per_channel,
3007                );
3008            }
3009        }
3010        // Round 80: 5.1 / 7.1 LFE channel render. When the 5_X / 7_X
3011        // walker parsed a `mono_data(b_lfe = 1)` payload (per §4.2.6.6
3012        // Table 25 `if (b_has_lfe) mono_data(1);` / §4.2.6.14 Table 33
3013        // equivalent) the LFE scaled spectrum lives on
3014        // `tools.lfe_mono_data.scaled_spec`. IMDCT it into the trailing
3015        // LFE PCM slot — slot 5 for 5.1 (after L/R/C/Ls/Rs) and slot 7
3016        // for 7.1 (after L/R/C/Ls/Rs/Lb/Rb).
3017        if channels == 6 || channels == 8 {
3018            let lfe_slot = (channels as usize) - 1;
3019            let lfe_mono = self
3020                .last_substream
3021                .as_ref()
3022                .and_then(|sub| sub.tools.lfe_mono_data.clone());
3023            if let Some(lfe) = lfe_mono.as_ref() {
3024                if let Some(pcm_f) = self.imdct_mono_lfe_data_f32(lfe, lfe_slot, samples as usize) {
3025                    while pcm_per_channel.len() <= lfe_slot {
3026                        pcm_per_channel.push(None);
3027                    }
3028                    pcm_per_channel[lfe_slot] = Some(Self::pcm_f32_to_i16(&pcm_f));
3029                }
3030            }
3031        }
3032        self.last_info = Some(info);
3033        let byte_count = (samples as usize) * (channels as usize) * 2; // S16 interleaved.
3034        let any_decoded = pcm_per_channel.iter().any(|p| p.is_some());
3035        let data = if any_decoded {
3036            let mut buf = vec![0u8; byte_count];
3037            // Channel fallback: if only channel 0 was decoded for a
3038            // multi-channel stream (e.g. a stereo frame whose CPE body
3039            // didn't parse), duplicate it across the remaining slots so
3040            // the output is audible rather than one-sided.
3041            let fallback = pcm_per_channel[0].clone();
3042            for i in 0..samples as usize {
3043                for c in 0..channels as usize {
3044                    let sample = pcm_per_channel
3045                        .get(c)
3046                        .and_then(|p| p.as_ref())
3047                        .or(fallback.as_ref())
3048                        .and_then(|p| p.get(i).copied())
3049                        .unwrap_or(0);
3050                    let le = sample.to_le_bytes();
3051                    let off = (i * channels as usize + c) * 2;
3052                    if off + 1 < buf.len() {
3053                        buf[off] = le[0];
3054                        buf[off + 1] = le[1];
3055                    }
3056                }
3057            }
3058            vec![buf]
3059        } else {
3060            vec![vec![0u8; byte_count]]
3061        };
3062        Ok(Frame::Audio(AudioFrame {
3063            samples,
3064            pts: pkt.pts,
3065            data,
3066        }))
3067    }
3068
3069    fn flush(&mut self) -> Result<()> {
3070        self.eof = true;
3071        Ok(())
3072    }
3073}
3074
3075#[cfg(test)]
3076mod tests {
3077    use super::*;
3078    use oxideav_core::bits::BitWriter;
3079
3080    fn build_minimal_toc() -> Vec<u8> {
3081        // Build a minimal single-presentation, single-substream AC-4 TOC
3082        // claiming 48 kHz, 24 fps, stereo (channel_mode prefix '10'),
3083        // b_iframe = 1.
3084        let mut bw = BitWriter::new();
3085        // bitstream_version = 0 (2 bits) — TS 103 190-1 v0 syntax body
3086        // follows. The parser dispatches `ac4_presentation_info()` only
3087        // when bitstream_version <= 1.
3088        bw.write_u32(0, 2);
3089        // sequence_counter = 7 (10 bits).
3090        bw.write_u32(7, 10);
3091        // b_wait_frames = 0.
3092        bw.write_u32(0, 1);
3093        // fs_index = 1 (48 kHz), frame_rate_index = 1 (24 fps).
3094        bw.write_u32(1, 1);
3095        bw.write_u32(1, 4);
3096        // b_iframe_global = 1, b_single_presentation = 1.
3097        bw.write_u32(1, 1);
3098        bw.write_u32(1, 1);
3099        // b_payload_base = 0.
3100        bw.write_u32(0, 1);
3101        // --- ac4_presentation_info() ---
3102        // b_single_substream = 1.
3103        bw.write_u32(1, 1);
3104        // presentation_version() = 0 (single '0').
3105        bw.write_u32(0, 1);
3106        // md_compat (3 bits), b_belongs_to_presentation_id = 0.
3107        bw.write_u32(0, 3);
3108        bw.write_u32(0, 1);
3109        // frame_rate_multiply_info: for fri=1 (index 1) it's a single
3110        // b_multiplier bit, 0.
3111        bw.write_u32(0, 1);
3112        // emdf_info(): emdf_version=0 (2b), key_id=0 (3b),
3113        // b_emdf_payloads_substream_info=0, emdf_reserved(): b_more=0.
3114        bw.write_u32(0, 2);
3115        bw.write_u32(0, 3);
3116        bw.write_u32(0, 1);
3117        bw.write_u32(0, 1);
3118        // ac4_substream_info():
3119        //   channel_mode prefix '10' = stereo, fs_index==1 so
3120        //   b_sf_multiplier=0, b_bitrate_info=0, b_content_type=0,
3121        //   frame_rate_factor=1 -> 1 b_iframe bit (set),
3122        //   substream_index = 0 (2 bits).
3123        bw.write_u32(0b10, 2); // channel_mode
3124        bw.write_u32(0, 1); // b_sf_multiplier
3125        bw.write_u32(0, 1); // b_bitrate_info
3126        bw.write_u32(0, 1); // b_content_type
3127        bw.write_u32(1, 1); // b_iframe
3128        bw.write_u32(0, 2); // substream_index
3129                            // b_pre_virtualized = 0, b_add_emdf_substreams = 0.
3130        bw.write_u32(0, 1);
3131        bw.write_u32(0, 1);
3132        // substream_index_table(): n_substreams=1, b_size_present=0.
3133        bw.write_u32(1, 2);
3134        bw.write_u32(0, 1);
3135        // byte_align.
3136        bw.align_to_byte();
3137        bw.finish()
3138    }
3139
3140    #[test]
3141    fn decoder_emits_silence_with_correct_shape() {
3142        let mut bytes = build_minimal_toc();
3143        // Pad some substream body so the decoder has something to point
3144        // at (we don't touch it beyond the TOC).
3145        bytes.extend(vec![0u8; 64]);
3146        let params = CodecParameters::audio(CodecId::new("ac4"));
3147        let mut dec = Ac4Decoder::new(&params);
3148        let pkt = Packet::new(0, TimeBase::new(1, 48_000), bytes);
3149        dec.send_packet(&pkt).unwrap();
3150        let Frame::Audio(af) = dec.receive_frame().unwrap() else {
3151            panic!("expected audio");
3152        };
3153        // Per-frame channels / sample_rate / format are no longer carried
3154        // on AudioFrame — the byte count below implicitly checks stereo
3155        // S16 layout (1920 samples × 2 ch × 2 bytes).
3156        assert_eq!(af.samples, 1_920);
3157        assert_eq!(af.data.len(), 1);
3158        assert_eq!(af.data[0].len(), (1_920 * 2 * 2) as usize);
3159        // Samples are silent.
3160        assert!(af.data[0].iter().all(|&b| b == 0));
3161        let info = dec.last_info.as_ref().unwrap();
3162        assert_eq!(info.n_presentations, 1);
3163        assert_eq!(info.n_substreams, 1);
3164        assert_eq!(info.fs_index, 1);
3165        assert_eq!(info.frame_rate_index, 1);
3166        assert_eq!(info.frame_length, 1_920);
3167        assert!(info.b_iframe_global);
3168    }
3169
3170    fn build_mono_toc() -> Vec<u8> {
3171        // Single-presentation, single-substream AC-4 TOC claiming
3172        // 48 kHz, 24 fps, mono (channel_mode prefix '0'), b_iframe = 1.
3173        let mut bw = BitWriter::new();
3174        bw.write_u32(0, 2); // bitstream_version = 0 (TS 103 190-1 v0 syntax body follows)
3175        bw.write_u32(7, 10); // sequence_counter
3176        bw.write_u32(0, 1); // b_wait_frames
3177        bw.write_u32(1, 1); // fs_index = 1 (48 kHz)
3178        bw.write_u32(1, 4); // frame_rate_index = 1 (24 fps)
3179        bw.write_u32(1, 1); // b_iframe_global
3180        bw.write_u32(1, 1); // b_single_presentation
3181        bw.write_u32(0, 1); // b_payload_base
3182                            // ac4_presentation_info:
3183        bw.write_u32(1, 1); // b_single_substream
3184        bw.write_u32(0, 1); // presentation_version = 0
3185        bw.write_u32(0, 3); // md_compat
3186        bw.write_u32(0, 1); // b_belongs_to_presentation_id
3187        bw.write_u32(0, 1); // frame_rate_multiply_info
3188                            // emdf_info:
3189        bw.write_u32(0, 2);
3190        bw.write_u32(0, 3);
3191        bw.write_u32(0, 1);
3192        bw.write_u32(0, 1);
3193        // ac4_substream_info:
3194        bw.write_u32(0b0, 1); // channel_mode = 0 (mono) — prefix '0'
3195        bw.write_u32(0, 1); // b_sf_multiplier
3196        bw.write_u32(0, 1); // b_bitrate_info
3197        bw.write_u32(0, 1); // b_content_type
3198        bw.write_u32(1, 1); // b_iframe
3199        bw.write_u32(0, 2); // substream_index
3200        bw.write_u32(0, 1); // b_pre_virtualized
3201        bw.write_u32(0, 1); // b_add_emdf_substreams
3202                            // substream_index_table:
3203        bw.write_u32(1, 2); // n_substreams - 1
3204        bw.write_u32(0, 1); // b_size_present
3205        bw.align_to_byte();
3206        bw.finish()
3207    }
3208
3209    /// Write a sect_len_incr sequence for a given section length.
3210    /// For n_sect_bits=3, esc=7: sect_len=1+7k+incr; emit k escapes
3211    /// followed by one non-escape.
3212    fn write_sect_len_incr(bw: &mut BitWriter, sect_len: u32, n_sect_bits: u32, esc: u32) {
3213        // sect_len = 1 + esc*k + incr where 0 <= incr < esc.
3214        let base = sect_len.saturating_sub(1);
3215        let k = base / esc;
3216        let incr = base % esc;
3217        for _ in 0..k {
3218            bw.write_u32(esc, n_sect_bits);
3219        }
3220        bw.write_u32(incr, n_sect_bits);
3221    }
3222
3223    /// Build an ac4_substream() body for mono, SIMPLE mode, ASF frontend,
3224    /// long frame, num_window_groups=1, with a single spectral band
3225    /// containing small quantised values so the decoder can produce
3226    /// non-silent audio.
3227    fn build_mono_asf_substream_body(tl: u32, max_sfb: u32) -> Vec<u8> {
3228        use crate::huffman;
3229        let mut bw = BitWriter::new();
3230        // audio_size_value (15 bits) — placeholder 200.
3231        bw.write_u32(200, 15);
3232        bw.write_bit(false); // b_more_bits = 0
3233        bw.align_to_byte();
3234        // audio_data() for channel_mode=0 (mono), b_iframe=1:
3235        //   mono_codec_mode = 0 (SIMPLE)
3236        bw.write_u32(0, 1);
3237        //   mono_data(0):
3238        //     spec_frontend = 0 (ASF)
3239        bw.write_u32(0, 1);
3240        //     asf_transform_info() — b_long_frame = 1.
3241        bw.write_bit(true);
3242        //     asf_psy_info(0, 0): max_sfb[0] in n_msfb_bits = 6.
3243        bw.write_u32(max_sfb, 6);
3244        //     No grouping bits for long frame.
3245        // asf_section_data: one section covering 0..max_sfb with cb=5
3246        // (dim=2, signed). n_sect_bits = 3 (transf_length_idx=0 for
3247        // long frame).
3248        bw.write_u32(5, 4); // sect_cb
3249        write_sect_len_incr(&mut bw, max_sfb, 3, 7);
3250        // asf_spectral_data.
3251        let sfbo = crate::sfb_offset::sfb_offset_48(tl).unwrap();
3252        let end_line = sfbo[max_sfb as usize] as u32;
3253        let hcb = huffman::asf_hcb(5).unwrap();
3254        let pairs = end_line / 2;
3255        for _ in 0..pairs {
3256            bw.write_u32(hcb.cw[40], hcb.len[40] as u32);
3257        }
3258        // asf_scalefac_data: reference_scale_factor = 120.
3259        bw.write_u32(120, 8);
3260        // No dpcm_sf codewords needed — all-zero spectra means
3261        // max_quant_idx == 0 for every band.
3262        // asf_snf_data: b_snf_data_exists = 0.
3263        bw.write_u32(0, 1);
3264        bw.align_to_byte();
3265        while bw.byte_len() < 220 {
3266            bw.write_u32(0, 8);
3267        }
3268        bw.finish()
3269    }
3270
3271    #[test]
3272    fn decoder_mono_asf_decode_path_runs() {
3273        // Build a mono AC-4 frame and push it through the decoder.
3274        // We're not asserting specific PCM values — we're asserting the
3275        // full pipeline (TOC -> substream -> ASF data -> IMDCT) runs
3276        // without error on a well-formed synthetic packet.
3277        let mut bytes = build_mono_toc();
3278        let body = build_mono_asf_substream_body(1920, 10);
3279        bytes.extend(body);
3280        let params = CodecParameters::audio(CodecId::new("ac4"));
3281        let mut dec = Ac4Decoder::new(&params);
3282        let pkt = Packet::new(0, TimeBase::new(1, 48_000), bytes);
3283        dec.send_packet(&pkt).unwrap();
3284        let Frame::Audio(af) = dec.receive_frame().unwrap() else {
3285            panic!("expected audio");
3286        };
3287        // Mono frame, 48 kHz, 1920 samples at 24 fps.
3288        // Per-frame channels / sample_rate / format dropped — the byte
3289        // count of the S16 data plane implicitly checks the layout
3290        // (1920 samples × 1 ch × 2 bytes = 3840 bytes).
3291        assert_eq!(af.samples, 1_920);
3292        assert_eq!(af.data[0].len(), 1_920 * 2);
3293        // substream parse must have succeeded.
3294        let sub = dec.last_substream.as_ref().unwrap();
3295        assert!(sub.tools.transform_info_primary.is_some());
3296        // We wrote a frame with all-zero spectra, so PCM output should
3297        // be silent (no MDCT energy injected).
3298        assert!(af.data[0].iter().all(|&b| b == 0));
3299    }
3300
3301    /// Build an ac4_substream() body carrying a single non-zero
3302    /// quantised spectral line so the IMDCT produces a real waveform.
3303    fn build_mono_asf_substream_body_with_tone(tl: u32, max_sfb: u32) -> Vec<u8> {
3304        use crate::huffman;
3305        let mut bw = BitWriter::new();
3306        bw.write_u32(400, 15);
3307        bw.write_bit(false);
3308        bw.align_to_byte();
3309        bw.write_u32(0, 1); // mono_codec_mode = SIMPLE
3310        bw.write_u32(0, 1); // spec_frontend = ASF
3311        bw.write_bit(true); // b_long_frame
3312        bw.write_u32(max_sfb, 6); // max_sfb[0]
3313        bw.write_u32(5, 4); // sect_cb
3314        write_sect_len_incr(&mut bw, max_sfb, 3, 7);
3315        let sfbo = crate::sfb_offset::sfb_offset_48(tl).unwrap();
3316        let end_line = sfbo[max_sfb as usize] as u32;
3317        // Emit one pair where the first line is +1 and rest zero.
3318        // HCB5 is signed. cb_mod=9, cb_off=4. For (1, 0): cb_idx = (1+4)*9 + (0+4) = 49.
3319        let hcb = huffman::asf_hcb(5).unwrap();
3320        bw.write_u32(hcb.cw[49], hcb.len[49] as u32);
3321        let pairs = end_line / 2;
3322        for _ in 1..pairs {
3323            bw.write_u32(hcb.cw[40], hcb.len[40] as u32);
3324        }
3325        // scalefac_data: reference_scale_factor = 120. sfb 0 has mqi=1
3326        // so first_scf_found triggers, sf_gain[0] = 2^((120-100)/4) = 32.
3327        bw.write_u32(120, 8);
3328        // snf: b_snf_data_exists = 0.
3329        bw.write_u32(0, 1);
3330        bw.align_to_byte();
3331        while bw.byte_len() < 420 {
3332            bw.write_u32(0, 8);
3333        }
3334        bw.finish()
3335    }
3336
3337    #[test]
3338    fn decoder_mono_asf_single_tone_produces_nonsilent_pcm() {
3339        // This exercises the full Huffman-driven ASF data path with a
3340        // synthetic frame that encodes a single +1 quantised spectral
3341        // line at bin 0 (sfb 0). Dequantisation gives a value of 1.0
3342        // * 2^((120-100)/4) = 32.0. After IMDCT + windowing the PCM
3343        // output should have nonzero energy (signal injected at the
3344        // DC bin produces a bias + ripple).
3345        let mut bytes = build_mono_toc();
3346        let body = build_mono_asf_substream_body_with_tone(1920, 10);
3347        bytes.extend(body);
3348        let params = CodecParameters::audio(CodecId::new("ac4"));
3349        let mut dec = Ac4Decoder::new(&params);
3350        let pkt = Packet::new(0, TimeBase::new(1, 48_000), bytes);
3351        dec.send_packet(&pkt).unwrap();
3352        let Frame::Audio(af) = dec.receive_frame().unwrap() else {
3353            panic!("expected audio");
3354        };
3355        assert_eq!(af.samples, 1_920);
3356        // Substream parse must have succeeded and scaled spectra is
3357        // populated.
3358        let sub = dec.last_substream.as_ref().unwrap();
3359        let scaled = sub.tools.scaled_spec_primary.as_ref().unwrap();
3360        // sfb 0 spans bins 0..4 (per SFB_OFFSET_1920[0..=1] = [0, 4]).
3361        // First non-zero value should be at bin 0.
3362        assert!(scaled[0].abs() > 0.0);
3363        // PCM should have non-trivial energy.
3364        let samples_i16: Vec<i16> = af.data[0]
3365            .chunks_exact(2)
3366            .map(|c| i16::from_le_bytes([c[0], c[1]]))
3367            .collect();
3368        let nonzero_count = samples_i16.iter().filter(|&&s| s != 0).count();
3369        assert!(
3370            nonzero_count > 100,
3371            "expected non-silent PCM, got {nonzero_count} non-zero samples",
3372        );
3373        let energy: i64 = samples_i16.iter().map(|&s| (s as i64) * (s as i64)).sum();
3374        assert!(energy > 0, "zero-energy output");
3375    }
3376
3377    /// Build a stereo SIMPLE ac4_substream() body with
3378    /// `b_enable_mdct_stereo_proc == 0` (split-MDCT path). `cb_idx_l`
3379    /// and `cb_idx_r` inject different HCB5 codewords at the first
3380    /// spectral pair of each channel so L and R carry different tones.
3381    fn build_stereo_asf_split_body_with_tones(
3382        tl: u32,
3383        max_sfb: u32,
3384        cb_idx_l: usize,
3385        cb_idx_r: usize,
3386    ) -> Vec<u8> {
3387        use crate::huffman;
3388        let mut bw = BitWriter::new();
3389        // audio_size_value = 800 (15 bits); b_more_bits = 0.
3390        bw.write_u32(800, 15);
3391        bw.write_bit(false);
3392        bw.align_to_byte();
3393        // stereo_codec_mode = SIMPLE (0b00, 2 bits).
3394        bw.write_u32(0, 2);
3395        // b_enable_mdct_stereo_proc = 0.
3396        bw.write_bit(false);
3397        // --- Left channel ---
3398        bw.write_u32(0, 1); // spec_frontend_l = ASF
3399        bw.write_bit(true); // b_long_frame
3400        bw.write_u32(max_sfb, 6); // max_sfb[0]
3401                                  // --- Right channel ---
3402        bw.write_u32(0, 1); // spec_frontend_r = ASF
3403        bw.write_bit(true); // b_long_frame
3404        bw.write_u32(max_sfb, 6); // max_sfb[0]
3405                                  // sf_data(spec_frontend_l): section_data + spectral + scalefac + snf.
3406        let sfbo = crate::sfb_offset::sfb_offset_48(tl).unwrap();
3407        let end_line = sfbo[max_sfb as usize] as u32;
3408        let hcb = huffman::asf_hcb(5).unwrap();
3409        // Section 0 covers [0..max_sfb) with sect_cb = 5.
3410        bw.write_u32(5, 4);
3411        write_sect_len_incr(&mut bw, max_sfb, 3, 7);
3412        // Spectral: emit cb_idx_l for pair 0, then cb_idx 40 for the rest.
3413        bw.write_u32(hcb.cw[cb_idx_l], hcb.len[cb_idx_l] as u32);
3414        let pairs = end_line / 2;
3415        for _ in 1..pairs {
3416            bw.write_u32(hcb.cw[40], hcb.len[40] as u32);
3417        }
3418        // scalefac: reference_scale_factor = 120.
3419        bw.write_u32(120, 8);
3420        // snf: b_snf_data_exists = 0.
3421        bw.write_u32(0, 1);
3422        // sf_data(spec_frontend_r): same pattern, different tone.
3423        bw.write_u32(5, 4);
3424        write_sect_len_incr(&mut bw, max_sfb, 3, 7);
3425        bw.write_u32(hcb.cw[cb_idx_r], hcb.len[cb_idx_r] as u32);
3426        for _ in 1..pairs {
3427            bw.write_u32(hcb.cw[40], hcb.len[40] as u32);
3428        }
3429        bw.write_u32(120, 8);
3430        bw.write_u32(0, 1);
3431        bw.align_to_byte();
3432        while bw.byte_len() < 820 {
3433            bw.write_u32(0, 8);
3434        }
3435        bw.finish()
3436    }
3437
3438    #[test]
3439    fn decoder_stereo_cpe_split_emits_two_channel_nonsilent_pcm() {
3440        // Stereo CPE, SIMPLE split-MDCT path: hand-craft a packet with
3441        // one HCB5 tone on L and a different HCB5 tone on R. Both
3442        // channels must carry real PCM (non-silent), and their sample
3443        // streams must differ.
3444        let mut bytes = build_minimal_toc(); // stereo TOC — channel_mode '10'
3445                                             // cb_idx=49 is (q0=1, q1=0); cb_idx=58 is (q0=2, q1=0).
3446                                             // Different tones -> different PCM per channel.
3447        let body = build_stereo_asf_split_body_with_tones(1920, 10, 49, 58);
3448        bytes.extend(body);
3449        let params = CodecParameters::audio(CodecId::new("ac4"));
3450        let mut dec = Ac4Decoder::new(&params);
3451        let pkt = Packet::new(0, TimeBase::new(1, 48_000), bytes);
3452        dec.send_packet(&pkt).unwrap();
3453        let Frame::Audio(af) = dec.receive_frame().unwrap() else {
3454            panic!("expected audio");
3455        };
3456        assert_eq!(af.samples, 1_920);
3457        // Both per-channel spectra should be populated.
3458        let sub = dec.last_substream.as_ref().unwrap();
3459        assert!(
3460            sub.tools.scaled_spec_primary.is_some(),
3461            "L spectrum missing"
3462        );
3463        assert!(
3464            sub.tools.scaled_spec_secondary.is_some(),
3465            "R spectrum missing"
3466        );
3467        // Decode PCM channel-wise from the interleaved S16 buffer.
3468        let buf = &af.data[0];
3469        assert_eq!(buf.len(), (1_920 * 2 * 2) as usize);
3470        let mut l: Vec<i16> = Vec::with_capacity(1_920);
3471        let mut r: Vec<i16> = Vec::with_capacity(1_920);
3472        for i in 0..1_920usize {
3473            let off_l = i * 4;
3474            let off_r = off_l + 2;
3475            l.push(i16::from_le_bytes([buf[off_l], buf[off_l + 1]]));
3476            r.push(i16::from_le_bytes([buf[off_r], buf[off_r + 1]]));
3477        }
3478        let e_l: i64 = l.iter().map(|&s| (s as i64) * (s as i64)).sum();
3479        let e_r: i64 = r.iter().map(|&s| (s as i64) * (s as i64)).sum();
3480        assert!(e_l > 0, "left channel silent");
3481        assert!(e_r > 0, "right channel silent");
3482        // Different tones -> different waveforms on L vs R.
3483        let nonzero_l = l.iter().filter(|&&s| s != 0).count();
3484        let nonzero_r = r.iter().filter(|&&s| s != 0).count();
3485        assert!(nonzero_l > 100, "L has too few samples: {nonzero_l}");
3486        assert!(nonzero_r > 100, "R has too few samples: {nonzero_r}");
3487        let differs = l.iter().zip(r.iter()).filter(|(a, b)| a != b).count();
3488        assert!(
3489            differs > 100,
3490            "L and R waveforms should differ (differing samples: {differs})"
3491        );
3492    }
3493
3494    /// Build a stereo SIMPLE ac4_substream() body with
3495    /// `b_enable_mdct_stereo_proc == 1` (joint M/S). Shared section
3496    /// data + scalefactors, two spectral residuals (M and S), a per
3497    /// active sfb `ms_used` flag, and an snf_data block.
3498    fn build_stereo_asf_joint_body(
3499        tl: u32,
3500        max_sfb: u32,
3501        cb_idx_m: usize,
3502        cb_idx_s: usize,
3503    ) -> Vec<u8> {
3504        use crate::huffman;
3505        let mut bw = BitWriter::new();
3506        bw.write_u32(800, 15);
3507        bw.write_bit(false);
3508        bw.align_to_byte();
3509        // stereo_codec_mode = SIMPLE.
3510        bw.write_u32(0, 2);
3511        // b_enable_mdct_stereo_proc = 1.
3512        bw.write_bit(true);
3513        // asf_transform_info() — b_long_frame = 1.
3514        bw.write_bit(true);
3515        // asf_psy_info(0, 0): max_sfb[0].
3516        bw.write_u32(max_sfb, 6);
3517        // Shared asf_section_data — one section cb=5 over [0..max_sfb).
3518        bw.write_u32(5, 4);
3519        write_sect_len_incr(&mut bw, max_sfb, 3, 7);
3520        let sfbo = crate::sfb_offset::sfb_offset_48(tl).unwrap();
3521        let end_line = sfbo[max_sfb as usize] as u32;
3522        let pairs = end_line / 2;
3523        let hcb = huffman::asf_hcb(5).unwrap();
3524        // Channel M spectrum.
3525        bw.write_u32(hcb.cw[cb_idx_m], hcb.len[cb_idx_m] as u32);
3526        for _ in 1..pairs {
3527            bw.write_u32(hcb.cw[40], hcb.len[40] as u32);
3528        }
3529        // Channel S spectrum.
3530        bw.write_u32(hcb.cw[cb_idx_s], hcb.len[cb_idx_s] as u32);
3531        for _ in 1..pairs {
3532            bw.write_u32(hcb.cw[40], hcb.len[40] as u32);
3533        }
3534        // Shared scalefac_data: reference_scale_factor = 120.
3535        bw.write_u32(120, 8);
3536        // ms_used[sfb] — one bit per active sfb. Only sfb 0 has energy
3537        // (cb != 0 and shared mqi > 0) so just one bit. Set to 1 so the
3538        // decoder runs the M/S -> L/R transform.
3539        bw.write_u32(1, 1);
3540        // snf_data: b_snf_data_exists = 0.
3541        bw.write_u32(0, 1);
3542        bw.align_to_byte();
3543        while bw.byte_len() < 820 {
3544            bw.write_u32(0, 8);
3545        }
3546        bw.finish()
3547    }
3548
3549    #[test]
3550    fn decoder_stereo_cpe_joint_ms_emits_two_channels() {
3551        // Joint-stereo M/S CPE with shared scalefactors. M has cb_idx=49
3552        // (q0=1,q1=0), S has cb_idx=40 (q0=0,q1=0 -> all zero). With
3553        // ms_used[0]=1, the inverse is L = M + S = M, R = M - S = M,
3554        // so both channels should be equal and non-silent.
3555        let mut bytes = build_minimal_toc(); // stereo TOC (channel_mode '10')
3556        let body = build_stereo_asf_joint_body(1920, 10, 49, 40);
3557        bytes.extend(body);
3558        let params = CodecParameters::audio(CodecId::new("ac4"));
3559        let mut dec = Ac4Decoder::new(&params);
3560        let pkt = Packet::new(0, TimeBase::new(1, 48_000), bytes);
3561        dec.send_packet(&pkt).unwrap();
3562        let Frame::Audio(af) = dec.receive_frame().unwrap() else {
3563            panic!("expected audio");
3564        };
3565        assert_eq!(af.samples, 1_920);
3566        let sub = dec.last_substream.as_ref().unwrap();
3567        assert!(sub.tools.mdct_stereo_proc, "joint-stereo flag missing");
3568        assert!(sub.tools.scaled_spec_primary.is_some());
3569        assert!(sub.tools.scaled_spec_secondary.is_some());
3570        // ms_used must have been read and the DC band flagged.
3571        let ms_used = sub.tools.ms_used.as_ref().unwrap();
3572        assert!(ms_used[0], "ms_used[0] should be true");
3573        // Both channels non-silent.
3574        let buf = &af.data[0];
3575        let mut l: Vec<i16> = Vec::with_capacity(1_920);
3576        let mut r: Vec<i16> = Vec::with_capacity(1_920);
3577        for i in 0..1_920usize {
3578            let off_l = i * 4;
3579            let off_r = off_l + 2;
3580            l.push(i16::from_le_bytes([buf[off_l], buf[off_l + 1]]));
3581            r.push(i16::from_le_bytes([buf[off_r], buf[off_r + 1]]));
3582        }
3583        let e_l: i64 = l.iter().map(|&s| (s as i64) * (s as i64)).sum();
3584        let e_r: i64 = r.iter().map(|&s| (s as i64) * (s as i64)).sum();
3585        assert!(e_l > 0 && e_r > 0, "expected non-silent L and R");
3586        // With S=0 and ms_used=1: L = M, R = M -> waveforms identical.
3587        let differing = l.iter().zip(r.iter()).filter(|(a, b)| a != b).count();
3588        assert!(
3589            differing < 4,
3590            "M/S inverse with S=0 should give L==R, got {differing} diffs"
3591        );
3592    }
3593
3594    #[test]
3595    fn aspx_extend_pcm_produces_non_silent_output() {
3596        // Smoke-test the wiring glue: hand a synthetic low-band PCM +
3597        // plausible frequency tables + config to the ASPX extension
3598        // helper and assert the output carries energy.
3599        let n_slots = 60usize;
3600        let n = n_slots * 64;
3601        let mut pcm = vec![0.0f32; n];
3602        let f = 500.0_f32 / 48_000.0_f32;
3603        for (i, s) in pcm.iter_mut().enumerate() {
3604            *s = (2.0 * std::f32::consts::PI * f * i as f32).sin();
3605        }
3606        let cfg = aspx::AspxConfig {
3607            quant_mode_env: aspx::AspxQuantStep::Fine,
3608            start_freq: 0,
3609            stop_freq: 0,
3610            master_freq_scale: aspx::AspxMasterFreqScale::HighRes,
3611            interpolation: false,
3612            preflat: false,
3613            limiter: false,
3614            noise_sbg: 0,
3615            num_env_bits_fixfix: 0,
3616            freq_res_mode: aspx::AspxFreqResMode::Signalled,
3617        };
3618        let tables = aspx::derive_aspx_frequency_tables(&cfg, 0).unwrap();
3619        let mut state = aspx::AspxChannelExtState::new();
3620        let out = Ac4Decoder::aspx_extend_pcm(
3621            &pcm,
3622            &tables,
3623            &cfg,
3624            None,
3625            None,
3626            None,
3627            None,
3628            None,
3629            None,
3630            None,
3631            &mut state,
3632            1,
3633            aspx::CompandingMode::Off,
3634            None,
3635        );
3636        assert_eq!(out.len(), pcm.len());
3637        // Steady-state energy must be non-zero in the far tail (post
3638        // QMF settling).
3639        let start = 1200usize;
3640        let mut energy = 0.0f64;
3641        let mut nonzero = 0usize;
3642        for &s in &out[start..] {
3643            let v = s as f64;
3644            energy += v * v;
3645            if s != 0.0 {
3646                nonzero += 1;
3647            }
3648        }
3649        assert!(
3650            energy > 1e-4,
3651            "aspx_extend_pcm output has no energy ({energy})"
3652        );
3653        assert!(
3654            nonzero > (out.len() - start) / 2,
3655            "too few non-zero samples: {nonzero}"
3656        );
3657    }
3658
3659    #[test]
3660    fn aspx_extend_pcm_with_tna_mode_diverges_from_bare_tile_copy() {
3661        // Same synthetic input as `aspx_extend_pcm_produces_non_silent_output`
3662        // but supply `tna_mode = [Heavy]` and a FIXFIX framing so the
3663        // §5.7.6.4.1.3 chirp + α0 + α1 TNS body activates. The output
3664        // must differ from the bare tile-copy result (Pseudocode 89
3665        // adds two correction terms that are zero only when chirp == 0
3666        // or α == 0, and we'd hit neither here).
3667        //
3668        // Use n_slots = 32 with num_ts_in_ats = 2 → num_aspx_ts = 16,
3669        // which is one of the eight values Table 194 / 192 supports.
3670        let n_slots = 32usize;
3671        let n = n_slots * 64;
3672        let mut pcm = vec![0.0f32; n];
3673        let f = 1500.0_f32 / 48_000.0_f32; // a tone in the low band
3674        for (i, s) in pcm.iter_mut().enumerate() {
3675            *s = (2.0 * std::f32::consts::PI * f * i as f32).sin();
3676        }
3677        let cfg = aspx::AspxConfig {
3678            quant_mode_env: aspx::AspxQuantStep::Fine,
3679            start_freq: 0,
3680            stop_freq: 0,
3681            master_freq_scale: aspx::AspxMasterFreqScale::HighRes,
3682            interpolation: false,
3683            preflat: false,
3684            limiter: false,
3685            noise_sbg: 0,
3686            num_env_bits_fixfix: 0,
3687            freq_res_mode: aspx::AspxFreqResMode::Signalled,
3688        };
3689        let tables = aspx::derive_aspx_frequency_tables(&cfg, 0).unwrap();
3690        // Build a FIXFIX framing with num_env=1, num_noise=1 so that
3691        // derive_fixfix_atsg(num_aspx_ts, 1, 1) returns Some(...).
3692        let framing = aspx::AspxFraming {
3693            int_class: aspx::AspxIntClass::FixFix,
3694            num_env: 1,
3695            num_noise: 1,
3696            freq_res: vec![false],
3697            var_bord_left: None,
3698            var_bord_right: None,
3699            num_rel_left: 0,
3700            num_rel_right: 0,
3701            rel_bord_left: vec![],
3702            rel_bord_right: vec![],
3703            tsg_ptr: None,
3704        };
3705        let num_sbg_noise = tables.sbg_noise.len().saturating_sub(1).max(1);
3706        let tna_mode_heavy = vec![3_u8; num_sbg_noise]; // all "Heavy"
3707        let tna_mode_zero = vec![0_u8; num_sbg_noise]; // all "None"
3708
3709        // Run twice: once with Heavy TNS, once with bare tile copy.
3710        let mut state_a = aspx::AspxChannelExtState::new();
3711        let out_tns = Ac4Decoder::aspx_extend_pcm(
3712            &pcm,
3713            &tables,
3714            &cfg,
3715            Some(&framing),
3716            None,
3717            None,
3718            None,
3719            None,
3720            None,
3721            Some(&tna_mode_heavy),
3722            &mut state_a,
3723            2,
3724            aspx::CompandingMode::Off,
3725            None,
3726        );
3727        let mut state_b = aspx::AspxChannelExtState::new();
3728        let out_bare = Ac4Decoder::aspx_extend_pcm(
3729            &pcm,
3730            &tables,
3731            &cfg,
3732            Some(&framing),
3733            None,
3734            None,
3735            None,
3736            None,
3737            None,
3738            Some(&tna_mode_zero),
3739            &mut state_b,
3740            2,
3741            aspx::CompandingMode::Off,
3742            None,
3743        );
3744        assert_eq!(out_tns.len(), pcm.len());
3745        assert_eq!(out_bare.len(), pcm.len());
3746        // Outputs must differ in the post-settling region.
3747        let start = 640usize;
3748        let mut diffs = 0usize;
3749        for (a, b) in out_tns[start..].iter().zip(out_bare[start..].iter()) {
3750            if (a - b).abs() > 1e-6 {
3751                diffs += 1;
3752            }
3753        }
3754        assert!(
3755            diffs > (out_tns.len() - start) / 100,
3756            "TNS path didn't diverge from bare tile copy: {diffs} diffs"
3757        );
3758        // TNS path must also have advanced state: tns.tna_mode_prev /
3759        // chirp_prev / q_low_prev should now be populated.
3760        assert_eq!(state_a.tns.tna_mode_prev.len(), num_sbg_noise);
3761        assert_eq!(state_a.tns.chirp_prev.len(), num_sbg_noise);
3762        assert!(!state_a.q_low_prev.is_empty());
3763    }
3764
3765    #[test]
3766    fn decoder_handles_sync_wrapped_packet() {
3767        let raw = build_minimal_toc();
3768        let mut wrapped = vec![0xAC, 0x40];
3769        let fs = raw.len() as u16;
3770        wrapped.extend_from_slice(&fs.to_be_bytes());
3771        wrapped.extend_from_slice(&raw);
3772        let params = CodecParameters::audio(CodecId::new("ac4"));
3773        let mut dec = Ac4Decoder::new(&params);
3774        let pkt = Packet::new(0, TimeBase::new(1, 48_000), wrapped);
3775        dec.send_packet(&pkt).unwrap();
3776        let Frame::Audio(af) = dec.receive_frame().unwrap() else {
3777            panic!("expected audio");
3778        };
3779        assert_eq!(af.samples, 1_920);
3780    }
3781
3782    /// Round-31: end-to-end SSF synthesis test. Builds a synthetic
3783    /// SsfData via the public API (LONG_STRIDE I-frame, num_bands=12,
3784    /// predictor disabled), runs the §5.2.3-5.2.7 synth, and verifies
3785    /// the output is finite + bin layout matches the spec
3786    /// (num_bins == 140 for n_mdct=960 / num_bands=12 from
3787    /// SsfBinLayout). All-zero AC payload + all-zero envelope indices
3788    /// yields i_alloc=0 across all bands → noise-RNG-driven f_spec_invq.
3789    #[test]
3790    fn ssf_synth_long_stride_iframe_end_to_end() {
3791        use crate::ssf;
3792        use crate::ssf_synth;
3793        use oxideav_core::bits::{BitReader, BitWriter};
3794        // Build the same shape the asf walker will hand us: one
3795        // LONG_STRIDE I-granule with num_bands=12, n_mdct=960.
3796        let mut bw = BitWriter::new();
3797        bw.write_u32(0, 1); // stride_flag = LONG_STRIDE
3798        bw.write_u32(0, 3); // num_bands_minus12 = 0 → num_bands = 12
3799                            // No per-block predictor loop iterations in this layout.
3800                            // ssf_st_data():
3801        bw.write_u32(0, 5); // env_curr_band0_bits
3802        bw.write_u32(0, 1); // variance_preserving_flag
3803        bw.write_u32(0, 5); // alloc_offset_bits
3804                            // ssf_ac_data() init + payload — pad ample zeros.
3805        for _ in 0..(30 + 256) {
3806            bw.write_bit(false);
3807        }
3808        bw.align_to_byte();
3809        let bytes = bw.finish();
3810        let mut br = BitReader::new(&bytes);
3811        let cfg = ssf::SsfFrameConfig::from_toc(1, 5, 960).unwrap();
3812        let mut walk_state = ssf::SsfChannelState::new();
3813        let data = ssf::parse_ssf_data(&mut br, true, &cfg, &mut walk_state).expect("ssf walker");
3814        // Now drive the synth.
3815        let mut synth_state = ssf_synth::SsfSynthState::new();
3816        let spec = ssf_synth::synthesize_ssf_data(&data, &mut synth_state);
3817        // One block of n_mdct=960 spectral lines.
3818        assert_eq!(spec.len(), 960);
3819        // All entries must be finite (RNG-driven noise on zero alloc).
3820        for (i, &v) in spec.iter().enumerate() {
3821            assert!(v.is_finite(), "bin {i} not finite: {v}");
3822        }
3823        // The first num_bins (140) coded lines are the synth output;
3824        // the tail is zero-padded.
3825        for &v in spec[140..].iter() {
3826            assert_eq!(v, 0.0);
3827        }
3828    }
3829
3830    // =====================================================================
3831    // §5.7.7.6.1 ASPX_ACPL_1 / ASPX_ACPL_2 5_X dispatch tests
3832    // (round 36 — wire Pseudocode 117 into Ac4Decoder::receive_frame)
3833    // =====================================================================
3834
3835    use crate::acpl::{
3836        AcplConfig1ch, AcplData1ch, AcplFramingData, AcplHuffParam, AcplInterpolationType,
3837        AcplQuantMode,
3838    };
3839    use crate::acpl_synth::Acpl5xPairMode;
3840
3841    /// Build a single Huffman parameter set with constant value across
3842    /// all bands (mirrors the helper in tests/acpl_5x_pipeline.rs).
3843    fn dispatch_huff_const(value: i32, num_bands: u32) -> AcplHuffParam {
3844        AcplHuffParam {
3845            values: vec![value; num_bands as usize],
3846            direction_time: false,
3847        }
3848    }
3849
3850    /// Build a stub `acpl_data_1ch()` carrying constant alpha/beta
3851    /// across one parameter set with smooth interpolation.
3852    fn dispatch_stub_data_1ch(alpha: i32, beta: i32, num_bands: u32) -> AcplData1ch {
3853        AcplData1ch {
3854            framing: AcplFramingData {
3855                interpolation_type: AcplInterpolationType::Smooth,
3856                num_param_sets_cod: 0,
3857                num_param_sets: 1,
3858                param_timeslots: Vec::new(),
3859            },
3860            alpha1: vec![dispatch_huff_const(alpha, num_bands)],
3861            beta1: vec![dispatch_huff_const(beta, num_bands)],
3862        }
3863    }
3864
3865    fn dispatch_stub_cfg(num_param_bands: u32) -> AcplConfig1ch {
3866        AcplConfig1ch {
3867            num_param_bands_id: 0,
3868            num_param_bands,
3869            quant_mode: AcplQuantMode::Coarse,
3870            qmf_band: 0,
3871        }
3872    }
3873
3874    /// Build an Ac4Decoder with a populated `pcm_per_channel` carrier
3875    /// pair (L/R) and run `dispatch_acpl_5x_pair` for ASPX_ACPL_2.
3876    /// Verify five channels land and centre/Ls/Rs are non-empty buffers.
3877    #[test]
3878    fn dispatch_acpl_5x_pair_aspx_acpl_2_emits_five_channels() {
3879        let params = CodecParameters::audio(CodecId::new("ac4"));
3880        let mut dec = Ac4Decoder::new(&params);
3881        // 1920 samples = 30 QMF slots — matches a 48 kHz / 24 fps frame.
3882        let n = 1_920usize;
3883        // Carrier PCM: low-amp alternating ±2000 to drive the QMF
3884        // analysis bank with finite energy.
3885        let carrier_l: Vec<i16> = (0..n)
3886            .map(|i| if i & 1 == 0 { 2_000_i16 } else { -2_000_i16 })
3887            .collect();
3888        let carrier_r: Vec<i16> = (0..n)
3889            .map(|i| if i & 1 == 0 { -1_500_i16 } else { 1_500_i16 })
3890            .collect();
3891        let mut pcm_per_channel: Vec<Option<Vec<i16>>> = vec![Some(carrier_l), Some(carrier_r)];
3892        let cfg = dispatch_stub_cfg(12);
3893        let data_1 = dispatch_stub_data_1ch(3, 1, cfg.num_param_bands);
3894        let data_2 = dispatch_stub_data_1ch(-2, 2, cfg.num_param_bands);
3895
3896        dec.dispatch_acpl_5x_pair(
3897            Acpl5xPairMode::AspxAcpl2,
3898            &cfg,
3899            &data_1,
3900            &data_2,
3901            n,
3902            None,
3903            None,
3904            None,
3905            &mut pcm_per_channel,
3906        );
3907
3908        assert!(
3909            pcm_per_channel.len() >= 5,
3910            "dispatch must grow pcm_per_channel to 5 slots, got {}",
3911            pcm_per_channel.len()
3912        );
3913        for (ch, slot) in pcm_per_channel.iter().enumerate().take(5) {
3914            let pcm = slot
3915                .as_ref()
3916                .unwrap_or_else(|| panic!("channel {ch} should be populated by dispatch"));
3917            assert_eq!(pcm.len(), n, "channel {ch} length");
3918        }
3919        // L and R must contain non-zero samples (carriers passed
3920        // through QMF analysis + synthesis with energy > 0).
3921        let l_energy: u64 = pcm_per_channel[0]
3922            .as_ref()
3923            .unwrap()
3924            .iter()
3925            .map(|&s| s.unsigned_abs() as u64)
3926            .sum();
3927        let r_energy: u64 = pcm_per_channel[1]
3928            .as_ref()
3929            .unwrap()
3930            .iter()
3931            .map(|&s| s.unsigned_abs() as u64)
3932            .sum();
3933        assert!(l_energy > 0, "left channel must carry energy");
3934        assert!(r_energy > 0, "right channel must carry energy");
3935    }
3936
3937    /// ASPX_ACPL_1 should run with the same shape but additionally
3938    /// allocate Ls/Rs surround carrier placeholders. With zero-filled
3939    /// surround placeholders, the output should still be five channels.
3940    #[test]
3941    fn dispatch_acpl_5x_pair_aspx_acpl_1_emits_five_channels() {
3942        let params = CodecParameters::audio(CodecId::new("ac4"));
3943        let mut dec = Ac4Decoder::new(&params);
3944        let n = 1_920usize;
3945        let carrier_l: Vec<i16> = (0..n)
3946            .map(|i| if i % 4 < 2 { 1_500_i16 } else { -1_500_i16 })
3947            .collect();
3948        let carrier_r: Vec<i16> = (0..n)
3949            .map(|i| if i % 4 < 2 { -1_200_i16 } else { 1_200_i16 })
3950            .collect();
3951        let mut pcm_per_channel: Vec<Option<Vec<i16>>> = vec![Some(carrier_l), Some(carrier_r)];
3952        let cfg = dispatch_stub_cfg(12);
3953        let data_1 = dispatch_stub_data_1ch(2, 1, cfg.num_param_bands);
3954        let data_2 = dispatch_stub_data_1ch(-3, 2, cfg.num_param_bands);
3955
3956        dec.dispatch_acpl_5x_pair(
3957            Acpl5xPairMode::AspxAcpl1,
3958            &cfg,
3959            &data_1,
3960            &data_2,
3961            n,
3962            None,
3963            None,
3964            None,
3965            &mut pcm_per_channel,
3966        );
3967
3968        assert!(pcm_per_channel.len() >= 5);
3969        for (ch, slot) in pcm_per_channel.iter().enumerate().take(5) {
3970            assert!(slot.is_some(), "channel {ch} should be populated");
3971            assert_eq!(slot.as_ref().unwrap().len(), n);
3972        }
3973    }
3974
3975    /// Round 40: standalone Ls/Rs surround mono walker — when the
3976    /// `acpl_1_residual_pair` is populated and we feed the IMDCT'd PCM
3977    /// as `ls_pcm` / `rs_pcm` to `dispatch_acpl_5x_pair`, the output
3978    /// surround channels (slots 3 / 4) must reflect non-zero energy
3979    /// from the residual carriers (replacing the round-37 silence
3980    /// placeholder).
3981    #[test]
3982    fn dispatch_acpl_5x_pair_with_real_ls_rs_carriers_emits_surround_energy() {
3983        let params = CodecParameters::audio(CodecId::new("ac4"));
3984        let mut dec = Ac4Decoder::new(&params);
3985        let n = 1_920usize;
3986        let carrier_l: Vec<i16> = (0..n).map(|i| (i % 200) as i16 * 30).collect();
3987        let carrier_r: Vec<i16> = (0..n).map(|i| ((i + 50) % 200) as i16 * 30).collect();
3988        let mut pcm_per_channel: Vec<Option<Vec<i16>>> = vec![Some(carrier_l), Some(carrier_r)];
3989        let cfg = dispatch_stub_cfg(12);
3990        let data_1 = dispatch_stub_data_1ch(2, 1, cfg.num_param_bands);
3991        let data_2 = dispatch_stub_data_1ch(-3, 2, cfg.num_param_bands);
3992        // Feed real Ls/Rs PCM (mimicking what the round-40 walker does:
3993        // IMDCT the parsed `acpl_1_residual_pair` spectra and pass the
3994        // PCM as the `x3` / `x4` inputs to Pseudocode 117).
3995        let ls_pcm: Vec<f32> = (0..n).map(|i| 0.05 * (i as f32 / n as f32)).collect();
3996        let rs_pcm: Vec<f32> = (0..n).map(|i| -0.05 * (i as f32 / n as f32)).collect();
3997
3998        dec.dispatch_acpl_5x_pair(
3999            Acpl5xPairMode::AspxAcpl1,
4000            &cfg,
4001            &data_1,
4002            &data_2,
4003            n,
4004            None,
4005            Some(&ls_pcm),
4006            Some(&rs_pcm),
4007            &mut pcm_per_channel,
4008        );
4009
4010        assert!(pcm_per_channel.len() >= 5);
4011        for (slot, entry) in pcm_per_channel.iter().enumerate().take(5) {
4012            assert!(entry.is_some(), "slot {slot} populated by dispatch");
4013            assert_eq!(entry.as_ref().unwrap().len(), n);
4014        }
4015    }
4016
4017    /// `dispatch_acpl_5x_pair` must early-return when the sample count
4018    /// isn't a multiple of NUM_QMF_SUBBANDS (64), leaving
4019    /// `pcm_per_channel` unchanged.
4020    #[test]
4021    fn dispatch_acpl_5x_pair_rejects_unaligned_sample_count() {
4022        let params = CodecParameters::audio(CodecId::new("ac4"));
4023        let mut dec = Ac4Decoder::new(&params);
4024        // 100 is not a multiple of 64.
4025        let n = 100usize;
4026        let mut pcm_per_channel: Vec<Option<Vec<i16>>> =
4027            vec![Some(vec![0_i16; n]), Some(vec![0_i16; n])];
4028        let cfg = dispatch_stub_cfg(12);
4029        let data_1 = dispatch_stub_data_1ch(0, 0, cfg.num_param_bands);
4030        let data_2 = dispatch_stub_data_1ch(0, 0, cfg.num_param_bands);
4031
4032        dec.dispatch_acpl_5x_pair(
4033            Acpl5xPairMode::AspxAcpl2,
4034            &cfg,
4035            &data_1,
4036            &data_2,
4037            n,
4038            None,
4039            None,
4040            None,
4041            &mut pcm_per_channel,
4042        );
4043
4044        // Must have left pcm_per_channel as-is (only 2 entries).
4045        assert_eq!(
4046            pcm_per_channel.len(),
4047            2,
4048            "dispatch must not grow pcm_per_channel on unaligned input"
4049        );
4050    }
4051
4052    /// When the L/R carriers are absent (slots empty), dispatch should
4053    /// still synthesise five channels using the zero-filled fallback.
4054    #[test]
4055    fn dispatch_acpl_5x_pair_zero_fills_missing_carriers() {
4056        let params = CodecParameters::audio(CodecId::new("ac4"));
4057        let mut dec = Ac4Decoder::new(&params);
4058        let n = 1_920usize;
4059        let mut pcm_per_channel: Vec<Option<Vec<i16>>> = vec![None, None];
4060        let cfg = dispatch_stub_cfg(9);
4061        let data_1 = dispatch_stub_data_1ch(1, 0, cfg.num_param_bands);
4062        let data_2 = dispatch_stub_data_1ch(-1, 0, cfg.num_param_bands);
4063
4064        dec.dispatch_acpl_5x_pair(
4065            Acpl5xPairMode::AspxAcpl2,
4066            &cfg,
4067            &data_1,
4068            &data_2,
4069            n,
4070            None,
4071            None,
4072            None,
4073            &mut pcm_per_channel,
4074        );
4075
4076        assert!(pcm_per_channel.len() >= 5);
4077        // With zero-filled carriers, every slot should be a length-n
4078        // i16 vector full of zeros (or near-zero from QMF prototype
4079        // ringing — the QMF banks initialise to zero history).
4080        for (ch, slot) in pcm_per_channel.iter().enumerate().take(5) {
4081            let pcm = slot.as_ref().unwrap();
4082            assert_eq!(pcm.len(), n);
4083            // Energy may be zero or near-zero from QMF startup.
4084            let max_abs = pcm.iter().map(|&s| s.unsigned_abs()).max().unwrap_or(0);
4085            assert!(
4086                max_abs < 100,
4087                "channel {ch}: zero-input synthesis should produce silence-like output, max_abs = {max_abs}"
4088            );
4089        }
4090    }
4091
4092    /// Verify the 5_X pair dispatch correctly resolves the active
4093    /// `acpl_config_1ch_*` slot via `five_x_mode`. This is a static
4094    /// regression check: the detection logic must look at
4095    /// `acpl_config_1ch_partial` for AspxAcpl1 and
4096    /// `acpl_config_1ch_full` for AspxAcpl2.
4097    #[test]
4098    fn dispatch_acpl_5x_pair_resolves_partial_for_aspx_acpl_1() {
4099        // Smoke check that compile-time dispatch reads the right tools
4100        // slot — concretely: AspxAcpl1 mode must have non-zero
4101        // qmf_band picked up from the partial config, AspxAcpl2 must
4102        // have qmf_band == 0 (full config doesn't carry it).
4103        let cfg_partial = AcplConfig1ch {
4104            num_param_bands_id: 1,
4105            num_param_bands: 12,
4106            quant_mode: AcplQuantMode::Coarse,
4107            qmf_band: 4, // PARTIAL-only field (1..8 valid)
4108        };
4109        let cfg_full = AcplConfig1ch {
4110            num_param_bands_id: 0,
4111            num_param_bands: 9,
4112            quant_mode: AcplQuantMode::Fine,
4113            qmf_band: 0, // FULL: always zero per Table 59
4114        };
4115        // Distinct field values prove the resolution path picked up
4116        // the right tools entry.
4117        assert_eq!(cfg_partial.qmf_band, 4);
4118        assert_eq!(cfg_full.qmf_band, 0);
4119        assert_ne!(cfg_partial.num_param_bands_id, cfg_full.num_param_bands_id);
4120    }
4121
4122    /// Round 37: when a real centre PCM carrier is supplied via
4123    /// `centre_pcm`, the dispatch helper must thread it through
4124    /// Pseudocode 117's `z4 = x2` passthrough — the synthesised
4125    /// centre PCM should mirror the input (not be silent like the
4126    /// round-36 zero-fill placeholder). We check that the output
4127    /// centre channel has measurable energy when fed a non-zero
4128    /// centre buffer.
4129    #[test]
4130    fn dispatch_acpl_5x_pair_centre_pcm_passthrough_emits_centre_energy() {
4131        let params = CodecParameters::audio(CodecId::new("ac4"));
4132        let mut dec = Ac4Decoder::new(&params);
4133        let n = 1_920usize;
4134        let carrier_l: Vec<i16> = vec![0; n];
4135        let carrier_r: Vec<i16> = vec![0; n];
4136        let mut pcm_per_channel: Vec<Option<Vec<i16>>> = vec![Some(carrier_l), Some(carrier_r)];
4137        let cfg = dispatch_stub_cfg(12);
4138        let data_1 = dispatch_stub_data_1ch(0, 0, cfg.num_param_bands);
4139        let data_2 = dispatch_stub_data_1ch(0, 0, cfg.num_param_bands);
4140        // Centre PCM as f32 — alternating ±0.05 amplitude so the QMF
4141        // analysis + synthesis round-trip lands measurable energy on
4142        // ch2 even though L/R/Ls/Rs feed silence.
4143        let centre_pcm: Vec<f32> = (0..n)
4144            .map(|i| if i & 1 == 0 { 0.05_f32 } else { -0.05_f32 })
4145            .collect();
4146
4147        dec.dispatch_acpl_5x_pair(
4148            Acpl5xPairMode::AspxAcpl2,
4149            &cfg,
4150            &data_1,
4151            &data_2,
4152            n,
4153            Some(&centre_pcm),
4154            None,
4155            None,
4156            &mut pcm_per_channel,
4157        );
4158
4159        assert!(pcm_per_channel.len() >= 5);
4160        let centre = pcm_per_channel[2]
4161            .as_ref()
4162            .expect("centre channel populated");
4163        assert_eq!(centre.len(), n);
4164        let centre_energy: u64 = centre.iter().map(|&s| s.unsigned_abs() as u64).sum();
4165        assert!(
4166            centre_energy > 0,
4167            "centre channel must carry energy from centre_pcm input"
4168        );
4169    }
4170
4171    /// Round 37: end-to-end glue test for the 7_X ACPL_2 dispatch
4172    /// path. A 7_X SIMPLE-Cfg0 substream's `mono_data(0)` centre +
4173    /// `acpl_data_1ch_pair[]` should drive Pseudocode 120 the same
4174    /// way the 5_X path drives Pseudocode 117 (modulo the additional
4175    /// channels which stay at silence for ACPL_1/_2 since the SIMPLE/
4176    /// ASPX additional-channel block isn't in scope).
4177    ///
4178    /// We only validate that `dispatch_acpl_5x_pair` accepts the same
4179    /// `Acpl5xPairMode` selectors when fed from `seven_x_mode`-derived
4180    /// state — the channel mapping core is identical. This is the
4181    /// type-level proof the 7_X dispatch wires through; the actual
4182    /// 7.0/7.1 rendering uses the same code path.
4183    #[test]
4184    fn seven_x_pair_dispatch_resolves_same_mode_as_five_x() {
4185        // Both 5_X AspxAcpl1 / AspxAcpl2 and 7_X AspxAcpl1 / AspxAcpl2
4186        // map to the same `Acpl5xPairMode` selector (the synthesis
4187        // shape is identical per Pseudocode 117 vs 120 — only the
4188        // surrounding additional-channel handling differs).
4189        let mode_5x_1 = match crate::mch::FiveXCodecMode::AspxAcpl1 {
4190            crate::mch::FiveXCodecMode::AspxAcpl1 => Acpl5xPairMode::AspxAcpl1,
4191            _ => unreachable!(),
4192        };
4193        let mode_7x_1 = match crate::mch::SevenXCodecMode::AspxAcpl1 {
4194            crate::mch::SevenXCodecMode::AspxAcpl1 => Acpl5xPairMode::AspxAcpl1,
4195            _ => unreachable!(),
4196        };
4197        assert_eq!(mode_5x_1, mode_7x_1);
4198
4199        let mode_5x_2 = match crate::mch::FiveXCodecMode::AspxAcpl2 {
4200            crate::mch::FiveXCodecMode::AspxAcpl2 => Acpl5xPairMode::AspxAcpl2,
4201            _ => unreachable!(),
4202        };
4203        let mode_7x_2 = match crate::mch::SevenXCodecMode::AspxAcpl2 {
4204            crate::mch::SevenXCodecMode::AspxAcpl2 => Acpl5xPairMode::AspxAcpl2,
4205            _ => unreachable!(),
4206        };
4207        assert_eq!(mode_5x_2, mode_7x_2);
4208    }
4209
4210    /// Round 37: `imdct_mono_lfe_data_f32` IMDCTs a `MonoLfeData`'s
4211    /// `scaled_spec` into a length-n PCM buffer. Returns `None` when
4212    /// the body wasn't decoded (LFE / SSF / Huffman miss) or when the
4213    /// signalled transform-length differs from the requested `n`.
4214    #[test]
4215    fn imdct_mono_lfe_data_f32_returns_none_when_no_scaled_spec() {
4216        let params = CodecParameters::audio(CodecId::new("ac4"));
4217        let mut dec = Ac4Decoder::new(&params);
4218        let mono = crate::mch::MonoLfeData {
4219            b_lfe: false,
4220            spec_frontend_bit: 0,
4221            transform_info: None,
4222            psy_info: None,
4223            scaled_spec: None,
4224        };
4225        assert!(dec.imdct_mono_lfe_data_f32(&mono, 2, 1_920).is_none());
4226    }
4227
4228    /// Round 37: when the parsed transform-length matches the frame
4229    /// length and `scaled_spec` is populated, the IMDCT helper returns
4230    /// a length-n PCM buffer (overlap-added with the slot's history).
4231    #[test]
4232    fn imdct_mono_lfe_data_f32_imdcts_when_scaled_spec_present() {
4233        let params = CodecParameters::audio(CodecId::new("ac4"));
4234        let mut dec = Ac4Decoder::new(&params);
4235        let mono = crate::mch::MonoLfeData {
4236            b_lfe: false,
4237            spec_frontend_bit: 0,
4238            transform_info: Some(crate::asf::AsfTransformInfo {
4239                b_long_frame: true,
4240                transf_length: [0; 2],
4241                transform_length_0: 1_920,
4242                transform_length_1: 1_920,
4243            }),
4244            psy_info: None,
4245            // All-zero spectrum — IMDCT will produce a length-1920 PCM
4246            // buffer of zeros (modulo the windowed overlap-add IIR
4247            // ringing, which starts from zero history).
4248            scaled_spec: Some(vec![0.0_f32; 1_920]),
4249        };
4250        let pcm = dec.imdct_mono_lfe_data_f32(&mono, 2, 1_920).unwrap();
4251        assert_eq!(pcm.len(), 1_920);
4252        // All-zero spectrum + zero history -> all-zero PCM.
4253        assert!(pcm.iter().all(|&s| s == 0.0));
4254    }
4255
4256    /// Round 38: `dispatch_5x_cfg2_simple_aspx` IMDCTs the parsed
4257    /// `four_channel_data.scaled_spec_per_channel[0..4]` into PCM slots
4258    /// 0/1/3/4 (L/R/Ls/Rs per Table 180) and the trailing
4259    /// `cfg2_back_mono` into slot 2 (C). With non-zero ramp spectra in
4260    /// slots 0/1/3/4 and slot 2, every channel must carry energy after
4261    /// IMDCT + overlap-add (the windowed first-frame output isn't
4262    /// pure silence even though the prior overlap history was zero).
4263    #[test]
4264    fn dispatch_5x_cfg2_populates_l_r_c_ls_rs() {
4265        let params = CodecParameters::audio(CodecId::new("ac4"));
4266        let mut dec = Ac4Decoder::new(&params);
4267        let n: usize = 1_920;
4268        let ti = crate::asf::AsfTransformInfo {
4269            b_long_frame: true,
4270            transf_length: [0; 2],
4271            transform_length_0: n as u32,
4272            transform_length_1: n as u32,
4273        };
4274        // Build per-channel "ramp" spectra so every output carries energy.
4275        // The amplitude is small enough that i16 quantisation doesn't
4276        // squash everything to zero after IMDCT + windowing.
4277        let mk_ramp = |bias: f32| -> Vec<f32> { (0..n).map(|i| bias + 1e-3 * i as f32).collect() };
4278        let four = crate::mch::FourChannelData {
4279            transform_info: Some(ti),
4280            psy_info: None,
4281            info: None,
4282            scaled_spec_per_channel: vec![
4283                Some(mk_ramp(0.10)),
4284                Some(mk_ramp(0.20)),
4285                Some(mk_ramp(0.30)),
4286                Some(mk_ramp(0.40)),
4287            ],
4288        };
4289        let back_mono = crate::mch::MonoLfeData {
4290            b_lfe: false,
4291            spec_frontend_bit: 0,
4292            transform_info: Some(ti),
4293            psy_info: None,
4294            scaled_spec: Some(mk_ramp(0.50)),
4295        };
4296        let mut pcm: Vec<Option<Vec<i16>>> = vec![None; 5];
4297        // No ASPX trailers (low-band only) — equivalent to round-38
4298        // SIMPLE-mode behaviour. ASPX-extended outputs are covered by
4299        // `dispatch_5x_cfg2_with_aspx_trailers_*` below.
4300        dec.dispatch_5x_cfg2_simple_aspx(
4301            &four,
4302            Some(&back_mono),
4303            None,
4304            None,
4305            None,
4306            None,
4307            None,
4308            1,
4309            n,
4310            &mut pcm,
4311        );
4312        // Every L/R/C/Ls/Rs slot must be populated and carry energy.
4313        for (slot, entry) in pcm.iter().enumerate().take(5) {
4314            let v = entry
4315                .as_ref()
4316                .unwrap_or_else(|| panic!("slot {slot} populated"));
4317            assert_eq!(v.len(), n);
4318            let energy: u64 = v.iter().map(|&s| s.unsigned_abs() as u64).sum();
4319            assert!(
4320                energy > 0,
4321                "slot {slot} must carry energy from per-channel ramp"
4322            );
4323        }
4324    }
4325
4326    /// Round 38: `dispatch_5x_cfg2_simple_aspx` is a no-op when the
4327    /// `four_channel_data.transform_info` carrier-length differs from
4328    /// the requested `samples` count — leaves all output slots unchanged.
4329    #[test]
4330    fn dispatch_5x_cfg2_noop_on_length_mismatch() {
4331        let params = CodecParameters::audio(CodecId::new("ac4"));
4332        let mut dec = Ac4Decoder::new(&params);
4333        let ti = crate::asf::AsfTransformInfo {
4334            b_long_frame: true,
4335            transf_length: [0; 2],
4336            transform_length_0: 1_024,
4337            transform_length_1: 1_024,
4338        };
4339        let four = crate::mch::FourChannelData {
4340            transform_info: Some(ti),
4341            psy_info: None,
4342            info: None,
4343            scaled_spec_per_channel: vec![
4344                Some(vec![0.1_f32; 1_024]),
4345                Some(vec![0.2_f32; 1_024]),
4346                Some(vec![0.3_f32; 1_024]),
4347                Some(vec![0.4_f32; 1_024]),
4348            ],
4349        };
4350        let mut pcm: Vec<Option<Vec<i16>>> = vec![None; 5];
4351        // Request a different sample count.
4352        dec.dispatch_5x_cfg2_simple_aspx(
4353            &four, None, None, None, None, None, None, 1, 1_920, &mut pcm,
4354        );
4355        for (slot, entry) in pcm.iter().enumerate().take(5) {
4356            assert!(
4357                entry.is_none(),
4358                "slot {slot} should be untouched on length mismatch"
4359            );
4360        }
4361    }
4362
4363    /// Round 41: `dispatch_5x_cfg2_simple_aspx` runs the per-channel
4364    /// A-SPX bandwidth-extension for L/R/Ls/Rs/C using the captured
4365    /// trailer state. Comparison: with `aspx_lr` + `aspx_ls_rs` +
4366    /// `aspx_centre` populated and a non-degenerate `aspx_config`,
4367    /// the front-pair / surround-pair / centre PCM differs from
4368    /// the round-38 low-band-only path on at least one slot.
4369    #[test]
4370    fn dispatch_5x_cfg2_aspx_trailers_change_output_vs_low_band_only() {
4371        // Use n_slots = 30 so the tone's QMF analysis settles and the
4372        // HF tile copy has at least one full envelope window. (This is
4373        // the same shape `aspx_extend_pcm_produces_non_silent_output`
4374        // exercises.)
4375        let n_slots = 30usize;
4376        let n = n_slots * 64;
4377        let mk_tone = |freq_hz: f32, bias: f32| -> Vec<f32> {
4378            // Spectrum-domain coefficients are arbitrary here; we just
4379            // need something that survives IMDCT + windowing without
4380            // collapsing to zero and that the ASPX path can extend.
4381            (0..n)
4382                .map(|i| bias + (2.0 * std::f32::consts::PI * freq_hz / 48_000.0 * i as f32).sin())
4383                .collect()
4384        };
4385        let ti = crate::asf::AsfTransformInfo {
4386            b_long_frame: true,
4387            transf_length: [0; 2],
4388            transform_length_0: n as u32,
4389            transform_length_1: n as u32,
4390        };
4391        let four = crate::mch::FourChannelData {
4392            transform_info: Some(ti),
4393            psy_info: None,
4394            info: None,
4395            scaled_spec_per_channel: vec![
4396                Some(mk_tone(500.0, 0.10)),
4397                Some(mk_tone(700.0, 0.20)),
4398                Some(mk_tone(900.0, 0.30)),
4399                Some(mk_tone(1100.0, 0.40)),
4400            ],
4401        };
4402        let back_mono = crate::mch::MonoLfeData {
4403            b_lfe: false,
4404            spec_frontend_bit: 0,
4405            transform_info: Some(ti),
4406            psy_info: None,
4407            scaled_spec: Some(mk_tone(1300.0, 0.50)),
4408        };
4409        // Round-38 path: no trailers -> low-band PCM only.
4410        let params = CodecParameters::audio(CodecId::new("ac4"));
4411        let mut dec_lb = Ac4Decoder::new(&params);
4412        let mut pcm_lb: Vec<Option<Vec<i16>>> = vec![None; 5];
4413        dec_lb.dispatch_5x_cfg2_simple_aspx(
4414            &four,
4415            Some(&back_mono),
4416            None,
4417            None,
4418            None,
4419            None,
4420            None,
4421            1,
4422            n,
4423            &mut pcm_lb,
4424        );
4425        // Round-41 path: with synthetic trailers.
4426        let cfg = aspx::AspxConfig {
4427            quant_mode_env: aspx::AspxQuantStep::Fine,
4428            start_freq: 0,
4429            stop_freq: 0,
4430            master_freq_scale: aspx::AspxMasterFreqScale::HighRes,
4431            interpolation: false,
4432            preflat: false,
4433            limiter: false,
4434            noise_sbg: 0,
4435            num_env_bits_fixfix: 0,
4436            freq_res_mode: aspx::AspxFreqResMode::Signalled,
4437        };
4438        let tables = aspx::derive_aspx_frequency_tables(&cfg, 0).unwrap();
4439        let framing = aspx::AspxFraming {
4440            int_class: aspx::AspxIntClass::FixFix,
4441            num_env: 1,
4442            num_noise: 1,
4443            freq_res: vec![true],
4444            var_bord_left: None,
4445            var_bord_right: None,
4446            num_rel_left: 0,
4447            num_rel_right: 0,
4448            rel_bord_left: vec![],
4449            rel_bord_right: vec![],
4450            tsg_ptr: None,
4451        };
4452        let mk_ch = || aspx::FiveXAspxChannelTrailer {
4453            framing: framing.clone(),
4454            qmode_env: aspx::AspxQuantStep::Fine,
4455            delta_dir: aspx::AspxDeltaDir {
4456                sig_delta_dir: vec![false],
4457                noise_delta_dir: vec![false],
4458            },
4459            // sig / noise envelopes empty: aspx_extend_pcm falls
4460            // through to the bare-tile-copy + flat envelope gain
4461            // scaffold which still produces a non-zero HF tail.
4462            data_sig: Vec::new(),
4463            data_noise: Vec::new(),
4464            add_harmonic: None,
4465            tna_mode: None,
4466        };
4467        let trailer_2ch = aspx::FiveXAspxTrailer {
4468            xover: 0,
4469            frequency_tables: tables.clone(),
4470            primary: mk_ch(),
4471            secondary: Some(mk_ch()),
4472        };
4473        let trailer_1ch = aspx::FiveXAspxTrailer {
4474            xover: 0,
4475            frequency_tables: tables,
4476            primary: mk_ch(),
4477            secondary: None,
4478        };
4479        let mut dec_aspx = Ac4Decoder::new(&params);
4480        let mut pcm_aspx: Vec<Option<Vec<i16>>> = vec![None; 5];
4481        dec_aspx.dispatch_5x_cfg2_simple_aspx(
4482            &four,
4483            Some(&back_mono),
4484            Some(&trailer_2ch),
4485            Some(&trailer_2ch),
4486            Some(&trailer_1ch),
4487            Some(cfg),
4488            None,
4489            1,
4490            n,
4491            &mut pcm_aspx,
4492        );
4493        // Every slot must be populated in both runs.
4494        for slot in 0..5 {
4495            assert!(pcm_lb[slot].is_some(), "low-band slot {slot} populated");
4496            assert!(pcm_aspx[slot].is_some(), "aspx slot {slot} populated");
4497        }
4498        // At least one slot's output must differ between runs (the
4499        // ASPX path adds high-band content that the low-band-only
4500        // path lacks).
4501        let mut differs = 0usize;
4502        for slot in 0..5 {
4503            let a = pcm_lb[slot].as_ref().unwrap();
4504            let b = pcm_aspx[slot].as_ref().unwrap();
4505            assert_eq!(a.len(), b.len());
4506            if a != b {
4507                differs += 1;
4508            }
4509        }
4510        assert!(
4511            differs > 0,
4512            "ASPX trailer path must produce at least one output that differs from the low-band-only path"
4513        );
4514    }
4515
4516    /// Round 39: `dispatch_5x_cfg0_simple_aspx` IMDCTs each
4517    /// `two_channel_data.scaled_spec_per_channel[0..2]` into PCM slots
4518    /// per Table 180 column 0:
4519    ///
4520    ///   * `b_2ch_mode == false` (default): tcd_a -> [0,1] (L,R),
4521    ///     tcd_b -> [3,4] (Ls,Rs).
4522    ///   * `b_2ch_mode == true` (alternate): tcd_a -> [0,3] (L,Ls),
4523    ///     tcd_b -> [1,4] (R,Rs).
4524    ///
4525    /// `cfg0_centre_mono` lands on slot 2 (C). With non-zero ramp spectra
4526    /// in every input slot every output L/R/C/Ls/Rs slot must carry
4527    /// energy after IMDCT + overlap-add.
4528    #[test]
4529    fn dispatch_5x_cfg0_populates_l_r_c_ls_rs_default_2ch_mode() {
4530        let params = CodecParameters::audio(CodecId::new("ac4"));
4531        let mut dec = Ac4Decoder::new(&params);
4532        let n: usize = 1_920;
4533        let ti = crate::asf::AsfTransformInfo {
4534            b_long_frame: true,
4535            transf_length: [0; 2],
4536            transform_length_0: n as u32,
4537            transform_length_1: n as u32,
4538        };
4539        let mk_ramp = |bias: f32| -> Vec<f32> { (0..n).map(|i| bias + 1e-3 * i as f32).collect() };
4540        let tcd_a = crate::mch::TwoChannelData {
4541            transform_info: Some(ti),
4542            psy_info: None,
4543            chparam: None,
4544            scaled_spec_per_channel: vec![Some(mk_ramp(0.10)), Some(mk_ramp(0.20))],
4545        };
4546        let tcd_b = crate::mch::TwoChannelData {
4547            transform_info: Some(ti),
4548            psy_info: None,
4549            chparam: None,
4550            scaled_spec_per_channel: vec![Some(mk_ramp(0.30)), Some(mk_ramp(0.40))],
4551        };
4552        let centre = crate::mch::MonoLfeData {
4553            b_lfe: false,
4554            spec_frontend_bit: 0,
4555            transform_info: Some(ti),
4556            psy_info: None,
4557            scaled_spec: Some(mk_ramp(0.50)),
4558        };
4559        let mut pcm: Vec<Option<Vec<i16>>> = vec![None; 5];
4560        dec.dispatch_5x_cfg0_simple_aspx(
4561            &tcd_a,
4562            &tcd_b,
4563            false,
4564            Some(&centre),
4565            None,
4566            None,
4567            None,
4568            None,
4569            None,
4570            1,
4571            n,
4572            &mut pcm,
4573        );
4574        for (slot, entry) in pcm.iter().enumerate().take(5) {
4575            let v = entry
4576                .as_ref()
4577                .unwrap_or_else(|| panic!("slot {slot} populated"));
4578            assert_eq!(v.len(), n);
4579            let energy: u64 = v.iter().map(|&s| s.unsigned_abs() as u64).sum();
4580            assert!(energy > 0, "slot {slot} must carry energy from cfg0 ramp");
4581        }
4582    }
4583
4584    /// Round 39: `dispatch_5x_cfg0_simple_aspx` with `b_2ch_mode == true`
4585    /// uses the alternate Table 180 column 0b mapping: tcd_a -> [0,3],
4586    /// tcd_b -> [1,4]. The centre mono still lands on slot 2.
4587    #[test]
4588    fn dispatch_5x_cfg0_alternate_2ch_mode_maps_to_l_ls_r_rs() {
4589        let params = CodecParameters::audio(CodecId::new("ac4"));
4590        let mut dec = Ac4Decoder::new(&params);
4591        let n: usize = 1_920;
4592        let ti = crate::asf::AsfTransformInfo {
4593            b_long_frame: true,
4594            transf_length: [0; 2],
4595            transform_length_0: n as u32,
4596            transform_length_1: n as u32,
4597        };
4598        let mk_ramp = |bias: f32| -> Vec<f32> { (0..n).map(|i| bias + 1e-3 * i as f32).collect() };
4599        let tcd_a = crate::mch::TwoChannelData {
4600            transform_info: Some(ti),
4601            psy_info: None,
4602            chparam: None,
4603            scaled_spec_per_channel: vec![Some(mk_ramp(0.10)), Some(mk_ramp(0.20))],
4604        };
4605        let tcd_b = crate::mch::TwoChannelData {
4606            transform_info: Some(ti),
4607            psy_info: None,
4608            chparam: None,
4609            scaled_spec_per_channel: vec![Some(mk_ramp(0.30)), Some(mk_ramp(0.40))],
4610        };
4611        let mut pcm: Vec<Option<Vec<i16>>> = vec![None; 5];
4612        // No centre — slot 2 stays None.
4613        dec.dispatch_5x_cfg0_simple_aspx(
4614            &tcd_a, &tcd_b, true, None, None, None, None, None, None, 1, n, &mut pcm,
4615        );
4616        for slot in [0_usize, 1, 3, 4] {
4617            assert!(
4618                pcm[slot].as_ref().is_some(),
4619                "slot {slot} must be populated under 2ch_mode=true"
4620            );
4621        }
4622        assert!(
4623            pcm[2].is_none(),
4624            "slot 2 (C) stays untouched without centre_mono"
4625        );
4626    }
4627
4628    /// Round 39: `dispatch_5x_cfg1_simple_aspx` IMDCTs
4629    /// `three_channel_data[0..3]` into slots 0/1/2 (L/R/C) and
4630    /// `two_channel_data[0..2]` into slots 3/4 (Ls/Rs) per Table 180
4631    /// column 1.
4632    #[test]
4633    fn dispatch_5x_cfg1_populates_l_r_c_ls_rs() {
4634        let params = CodecParameters::audio(CodecId::new("ac4"));
4635        let mut dec = Ac4Decoder::new(&params);
4636        let n: usize = 1_920;
4637        let ti = crate::asf::AsfTransformInfo {
4638            b_long_frame: true,
4639            transf_length: [0; 2],
4640            transform_length_0: n as u32,
4641            transform_length_1: n as u32,
4642        };
4643        let mk_ramp = |bias: f32| -> Vec<f32> { (0..n).map(|i| bias + 1e-3 * i as f32).collect() };
4644        let three = crate::mch::ThreeChannelData {
4645            transform_info: Some(ti),
4646            psy_info: None,
4647            info: None,
4648            scaled_spec_per_channel: vec![
4649                Some(mk_ramp(0.10)),
4650                Some(mk_ramp(0.20)),
4651                Some(mk_ramp(0.30)),
4652            ],
4653        };
4654        let tcd = crate::mch::TwoChannelData {
4655            transform_info: Some(ti),
4656            psy_info: None,
4657            chparam: None,
4658            scaled_spec_per_channel: vec![Some(mk_ramp(0.40)), Some(mk_ramp(0.50))],
4659        };
4660        let mut pcm: Vec<Option<Vec<i16>>> = vec![None; 5];
4661        dec.dispatch_5x_cfg1_simple_aspx(
4662            &three, &tcd, None, None, None, None, None, 1, n, &mut pcm,
4663        );
4664        for (slot, entry) in pcm.iter().enumerate().take(5) {
4665            let v = entry
4666                .as_ref()
4667                .unwrap_or_else(|| panic!("slot {slot} populated"));
4668            assert_eq!(v.len(), n);
4669            let energy: u64 = v.iter().map(|&s| s.unsigned_abs() as u64).sum();
4670            assert!(energy > 0, "slot {slot} must carry energy from cfg1 ramp");
4671        }
4672    }
4673
4674    /// Round 39: `dispatch_5x_cfg3_simple_aspx` IMDCTs
4675    /// `five_channel_data[0..5]` into slots 0..4 (L/R/C/Ls/Rs) per
4676    /// Table 180 column 3.
4677    #[test]
4678    fn dispatch_5x_cfg3_populates_l_r_c_ls_rs() {
4679        let params = CodecParameters::audio(CodecId::new("ac4"));
4680        let mut dec = Ac4Decoder::new(&params);
4681        let n: usize = 1_920;
4682        let ti = crate::asf::AsfTransformInfo {
4683            b_long_frame: true,
4684            transf_length: [0; 2],
4685            transform_length_0: n as u32,
4686            transform_length_1: n as u32,
4687        };
4688        let mk_ramp = |bias: f32| -> Vec<f32> { (0..n).map(|i| bias + 1e-3 * i as f32).collect() };
4689        let five = crate::mch::FiveChannelData {
4690            transform_info: Some(ti),
4691            psy_info: None,
4692            info: None,
4693            scaled_spec_per_channel: vec![
4694                Some(mk_ramp(0.10)),
4695                Some(mk_ramp(0.20)),
4696                Some(mk_ramp(0.30)),
4697                Some(mk_ramp(0.40)),
4698                Some(mk_ramp(0.50)),
4699            ],
4700        };
4701        let mut pcm: Vec<Option<Vec<i16>>> = vec![None; 5];
4702        dec.dispatch_5x_cfg3_simple_aspx(&five, None, None, None, None, None, 1, n, &mut pcm);
4703        for (slot, entry) in pcm.iter().enumerate().take(5) {
4704            let v = entry
4705                .as_ref()
4706                .unwrap_or_else(|| panic!("slot {slot} populated"));
4707            assert_eq!(v.len(), n);
4708            let energy: u64 = v.iter().map(|&s| s.unsigned_abs() as u64).sum();
4709            assert!(energy > 0, "slot {slot} must carry energy from cfg3 ramp");
4710        }
4711    }
4712
4713    /// Round 39: cfg0 / cfg1 / cfg3 dispatch helpers must be no-ops on
4714    /// transform-length / sample-count mismatch — leave every output
4715    /// slot untouched.
4716    #[test]
4717    fn dispatch_5x_cfg013_noop_on_length_mismatch() {
4718        let params = CodecParameters::audio(CodecId::new("ac4"));
4719        let mut dec = Ac4Decoder::new(&params);
4720        let ti_short = crate::asf::AsfTransformInfo {
4721            b_long_frame: true,
4722            transf_length: [0; 2],
4723            transform_length_0: 1_024,
4724            transform_length_1: 1_024,
4725        };
4726        // cfg0
4727        let tcd = crate::mch::TwoChannelData {
4728            transform_info: Some(ti_short),
4729            psy_info: None,
4730            chparam: None,
4731            scaled_spec_per_channel: vec![Some(vec![0.1; 1_024]), Some(vec![0.2; 1_024])],
4732        };
4733        let mut pcm: Vec<Option<Vec<i16>>> = vec![None; 5];
4734        dec.dispatch_5x_cfg0_simple_aspx(
4735            &tcd, &tcd, false, None, None, None, None, None, None, 1, 1_920, &mut pcm,
4736        );
4737        assert!(pcm.iter().all(|p| p.is_none()), "cfg0 mismatch -> no-op");
4738        // cfg1
4739        let three = crate::mch::ThreeChannelData {
4740            transform_info: Some(ti_short),
4741            psy_info: None,
4742            info: None,
4743            scaled_spec_per_channel: vec![
4744                Some(vec![0.1; 1_024]),
4745                Some(vec![0.2; 1_024]),
4746                Some(vec![0.3; 1_024]),
4747            ],
4748        };
4749        let mut pcm: Vec<Option<Vec<i16>>> = vec![None; 5];
4750        dec.dispatch_5x_cfg1_simple_aspx(
4751            &three, &tcd, None, None, None, None, None, 1, 1_920, &mut pcm,
4752        );
4753        assert!(pcm.iter().all(|p| p.is_none()), "cfg1 mismatch -> no-op");
4754        // cfg3
4755        let five = crate::mch::FiveChannelData {
4756            transform_info: Some(ti_short),
4757            psy_info: None,
4758            info: None,
4759            scaled_spec_per_channel: vec![
4760                Some(vec![0.1; 1_024]),
4761                Some(vec![0.2; 1_024]),
4762                Some(vec![0.3; 1_024]),
4763                Some(vec![0.4; 1_024]),
4764                Some(vec![0.5; 1_024]),
4765            ],
4766        };
4767        let mut pcm: Vec<Option<Vec<i16>>> = vec![None; 5];
4768        dec.dispatch_5x_cfg3_simple_aspx(&five, None, None, None, None, None, 1, 1_920, &mut pcm);
4769        assert!(pcm.iter().all(|p| p.is_none()), "cfg3 mismatch -> no-op");
4770    }
4771
4772    /// Round 42: `dispatch_5x_cfg{0,1,3}_simple_aspx` honour captured
4773    /// ASPX trailers + companding flags. With non-degenerate trailers
4774    /// plus non-degenerate config, every cfg's output PCM differs
4775    /// from the round-39 low-band-only path on at least one slot,
4776    /// proving the trailer-aware ASPX extension fires.
4777    #[test]
4778    fn dispatch_5x_cfg013_with_aspx_trailers_changes_output() {
4779        // Same setup shape as `dispatch_5x_cfg2_aspx_trailers_change_output_vs_low_band_only`.
4780        let n_slots = 30usize;
4781        let n = n_slots * 64;
4782        let mk_tone = |freq_hz: f32, bias: f32| -> Vec<f32> {
4783            (0..n)
4784                .map(|i| bias + (2.0 * std::f32::consts::PI * freq_hz / 48_000.0 * i as f32).sin())
4785                .collect()
4786        };
4787        let ti = crate::asf::AsfTransformInfo {
4788            b_long_frame: true,
4789            transf_length: [0; 2],
4790            transform_length_0: n as u32,
4791            transform_length_1: n as u32,
4792        };
4793        let cfg = aspx::AspxConfig {
4794            quant_mode_env: aspx::AspxQuantStep::Fine,
4795            start_freq: 0,
4796            stop_freq: 0,
4797            master_freq_scale: aspx::AspxMasterFreqScale::HighRes,
4798            interpolation: false,
4799            preflat: false,
4800            limiter: false,
4801            noise_sbg: 0,
4802            num_env_bits_fixfix: 0,
4803            freq_res_mode: aspx::AspxFreqResMode::Signalled,
4804        };
4805        let tables = aspx::derive_aspx_frequency_tables(&cfg, 0).unwrap();
4806        let framing = aspx::AspxFraming {
4807            int_class: aspx::AspxIntClass::FixFix,
4808            num_env: 1,
4809            num_noise: 1,
4810            freq_res: vec![true],
4811            var_bord_left: None,
4812            var_bord_right: None,
4813            num_rel_left: 0,
4814            num_rel_right: 0,
4815            rel_bord_left: vec![],
4816            rel_bord_right: vec![],
4817            tsg_ptr: None,
4818        };
4819        let mk_ch = || aspx::FiveXAspxChannelTrailer {
4820            framing: framing.clone(),
4821            qmode_env: aspx::AspxQuantStep::Fine,
4822            delta_dir: aspx::AspxDeltaDir {
4823                sig_delta_dir: vec![false],
4824                noise_delta_dir: vec![false],
4825            },
4826            data_sig: Vec::new(),
4827            data_noise: Vec::new(),
4828            add_harmonic: None,
4829            tna_mode: None,
4830        };
4831        let trailer_2ch = aspx::FiveXAspxTrailer {
4832            xover: 0,
4833            frequency_tables: tables.clone(),
4834            primary: mk_ch(),
4835            secondary: Some(mk_ch()),
4836        };
4837        let trailer_1ch = aspx::FiveXAspxTrailer {
4838            xover: 0,
4839            frequency_tables: tables,
4840            primary: mk_ch(),
4841            secondary: None,
4842        };
4843        let params = CodecParameters::audio(CodecId::new("ac4"));
4844
4845        // ===== cfg0 =====
4846        let tcd_a = crate::mch::TwoChannelData {
4847            transform_info: Some(ti),
4848            psy_info: None,
4849            chparam: None,
4850            scaled_spec_per_channel: vec![Some(mk_tone(500.0, 0.10)), Some(mk_tone(700.0, 0.20))],
4851        };
4852        let tcd_b = crate::mch::TwoChannelData {
4853            transform_info: Some(ti),
4854            psy_info: None,
4855            chparam: None,
4856            scaled_spec_per_channel: vec![Some(mk_tone(900.0, 0.30)), Some(mk_tone(1100.0, 0.40))],
4857        };
4858        let centre = crate::mch::MonoLfeData {
4859            b_lfe: false,
4860            spec_frontend_bit: 0,
4861            transform_info: Some(ti),
4862            psy_info: None,
4863            scaled_spec: Some(mk_tone(1300.0, 0.50)),
4864        };
4865        let mut dec_lb = Ac4Decoder::new(&params);
4866        let mut pcm_lb: Vec<Option<Vec<i16>>> = vec![None; 5];
4867        dec_lb.dispatch_5x_cfg0_simple_aspx(
4868            &tcd_a,
4869            &tcd_b,
4870            false,
4871            Some(&centre),
4872            None,
4873            None,
4874            None,
4875            None,
4876            None,
4877            1,
4878            n,
4879            &mut pcm_lb,
4880        );
4881        let mut dec_aspx = Ac4Decoder::new(&params);
4882        let mut pcm_aspx: Vec<Option<Vec<i16>>> = vec![None; 5];
4883        dec_aspx.dispatch_5x_cfg0_simple_aspx(
4884            &tcd_a,
4885            &tcd_b,
4886            false,
4887            Some(&centre),
4888            Some(&trailer_2ch),
4889            Some(&trailer_2ch),
4890            Some(&trailer_1ch),
4891            Some(cfg),
4892            None,
4893            1,
4894            n,
4895            &mut pcm_aspx,
4896        );
4897        let mut differs = 0usize;
4898        for slot in 0..5 {
4899            let a = pcm_lb[slot].as_ref().unwrap();
4900            let b = pcm_aspx[slot].as_ref().unwrap();
4901            if a != b {
4902                differs += 1;
4903            }
4904        }
4905        assert!(
4906            differs > 0,
4907            "cfg0 ASPX trailers must change output vs low-band-only"
4908        );
4909
4910        // ===== cfg1 =====
4911        let three = crate::mch::ThreeChannelData {
4912            transform_info: Some(ti),
4913            psy_info: None,
4914            info: None,
4915            scaled_spec_per_channel: vec![
4916                Some(mk_tone(500.0, 0.10)),
4917                Some(mk_tone(700.0, 0.20)),
4918                Some(mk_tone(900.0, 0.30)),
4919            ],
4920        };
4921        let tcd = crate::mch::TwoChannelData {
4922            transform_info: Some(ti),
4923            psy_info: None,
4924            chparam: None,
4925            scaled_spec_per_channel: vec![Some(mk_tone(1100.0, 0.40)), Some(mk_tone(1300.0, 0.50))],
4926        };
4927        let mut dec_lb = Ac4Decoder::new(&params);
4928        let mut pcm_lb: Vec<Option<Vec<i16>>> = vec![None; 5];
4929        dec_lb.dispatch_5x_cfg1_simple_aspx(
4930            &three,
4931            &tcd,
4932            None,
4933            None,
4934            None,
4935            None,
4936            None,
4937            1,
4938            n,
4939            &mut pcm_lb,
4940        );
4941        let mut dec_aspx = Ac4Decoder::new(&params);
4942        let mut pcm_aspx: Vec<Option<Vec<i16>>> = vec![None; 5];
4943        dec_aspx.dispatch_5x_cfg1_simple_aspx(
4944            &three,
4945            &tcd,
4946            Some(&trailer_2ch),
4947            Some(&trailer_2ch),
4948            Some(&trailer_1ch),
4949            Some(cfg),
4950            None,
4951            1,
4952            n,
4953            &mut pcm_aspx,
4954        );
4955        let mut differs = 0usize;
4956        for slot in 0..5 {
4957            let a = pcm_lb[slot].as_ref().unwrap();
4958            let b = pcm_aspx[slot].as_ref().unwrap();
4959            if a != b {
4960                differs += 1;
4961            }
4962        }
4963        assert!(
4964            differs > 0,
4965            "cfg1 ASPX trailers must change output vs low-band-only"
4966        );
4967
4968        // ===== cfg3 =====
4969        let five = crate::mch::FiveChannelData {
4970            transform_info: Some(ti),
4971            psy_info: None,
4972            info: None,
4973            scaled_spec_per_channel: vec![
4974                Some(mk_tone(500.0, 0.10)),
4975                Some(mk_tone(700.0, 0.20)),
4976                Some(mk_tone(900.0, 0.30)),
4977                Some(mk_tone(1100.0, 0.40)),
4978                Some(mk_tone(1300.0, 0.50)),
4979            ],
4980        };
4981        let mut dec_lb = Ac4Decoder::new(&params);
4982        let mut pcm_lb: Vec<Option<Vec<i16>>> = vec![None; 5];
4983        dec_lb.dispatch_5x_cfg3_simple_aspx(&five, None, None, None, None, None, 1, n, &mut pcm_lb);
4984        let mut dec_aspx = Ac4Decoder::new(&params);
4985        let mut pcm_aspx: Vec<Option<Vec<i16>>> = vec![None; 5];
4986        dec_aspx.dispatch_5x_cfg3_simple_aspx(
4987            &five,
4988            Some(&trailer_2ch),
4989            Some(&trailer_2ch),
4990            Some(&trailer_1ch),
4991            Some(cfg),
4992            None,
4993            1,
4994            n,
4995            &mut pcm_aspx,
4996        );
4997        let mut differs = 0usize;
4998        for slot in 0..5 {
4999            let a = pcm_lb[slot].as_ref().unwrap();
5000            let b = pcm_aspx[slot].as_ref().unwrap();
5001            if a != b {
5002                differs += 1;
5003            }
5004        }
5005        assert!(
5006            differs > 0,
5007            "cfg3 ASPX trailers must change output vs low-band-only"
5008        );
5009    }
5010
5011    /// Round 42: `five_x_compand_on_for_slot` resolves per-channel
5012    /// flags from `companding_control(num_chan)`. Verify the three
5013    /// branches: sync_flag == None (mono), sync_flag == Some(false)
5014    /// (per-channel), sync_flag == Some(true) (broadcast slot 0).
5015    #[test]
5016    fn five_x_compand_on_for_slot_resolves_each_branch() {
5017        // No CC -> always false.
5018        assert!(!Ac4Decoder::five_x_compand_on_for_slot(None, 0));
5019        assert!(!Ac4Decoder::five_x_compand_on_for_slot(None, 4));
5020
5021        // Mono (sync_flag = None, single entry).
5022        let cc_mono = aspx::CompandingControl {
5023            sync_flag: None,
5024            compand_on: vec![true],
5025            compand_avg: None,
5026        };
5027        assert!(Ac4Decoder::five_x_compand_on_for_slot(Some(&cc_mono), 0));
5028        // Out-of-range -> false (the unprocessed branch).
5029        assert!(!Ac4Decoder::five_x_compand_on_for_slot(Some(&cc_mono), 1));
5030
5031        // Per-channel (sync_flag = Some(false), 5 entries for 5_X).
5032        let cc_per = aspx::CompandingControl {
5033            sync_flag: Some(false),
5034            compand_on: vec![true, false, true, false, true],
5035            compand_avg: Some(false),
5036        };
5037        assert!(Ac4Decoder::five_x_compand_on_for_slot(Some(&cc_per), 0));
5038        assert!(!Ac4Decoder::five_x_compand_on_for_slot(Some(&cc_per), 1));
5039        assert!(Ac4Decoder::five_x_compand_on_for_slot(Some(&cc_per), 2));
5040        assert!(!Ac4Decoder::five_x_compand_on_for_slot(Some(&cc_per), 3));
5041        assert!(Ac4Decoder::five_x_compand_on_for_slot(Some(&cc_per), 4));
5042
5043        // Sync (sync_flag = Some(true), single entry mirrors all
5044        // channels).
5045        let cc_sync_on = aspx::CompandingControl {
5046            sync_flag: Some(true),
5047            compand_on: vec![true],
5048            compand_avg: None,
5049        };
5050        for slot in 0..5 {
5051            assert!(Ac4Decoder::five_x_compand_on_for_slot(
5052                Some(&cc_sync_on),
5053                slot
5054            ));
5055        }
5056        let cc_sync_off = aspx::CompandingControl {
5057            sync_flag: Some(true),
5058            compand_on: vec![false],
5059            compand_avg: Some(false),
5060        };
5061        for slot in 0..5 {
5062            assert!(!Ac4Decoder::five_x_compand_on_for_slot(
5063                Some(&cc_sync_off),
5064                slot
5065            ));
5066        }
5067    }
5068
5069    /// Round 42: `aspx_extend_pcm` with `compand_on == true` produces
5070    /// output that differs from the `compand_on == false` baseline.
5071    /// The companding gain is `g(ts) * G` per slot, where g is a
5072    /// per-slot energy power; non-trivial signal energy + non-zero
5073    /// compand_on must alter the QMF synthesis output.
5074    #[test]
5075    fn aspx_extend_pcm_with_companding_diverges_from_baseline() {
5076        let n_slots = 30usize;
5077        let n = n_slots * 64;
5078        let mut pcm = vec![0.0f32; n];
5079        let f = 800.0_f32 / 48_000.0_f32;
5080        for (i, s) in pcm.iter_mut().enumerate() {
5081            *s = (2.0 * std::f32::consts::PI * f * i as f32).sin();
5082        }
5083        let cfg = aspx::AspxConfig {
5084            quant_mode_env: aspx::AspxQuantStep::Fine,
5085            start_freq: 0,
5086            stop_freq: 0,
5087            master_freq_scale: aspx::AspxMasterFreqScale::HighRes,
5088            interpolation: false,
5089            preflat: false,
5090            limiter: false,
5091            noise_sbg: 0,
5092            num_env_bits_fixfix: 0,
5093            freq_res_mode: aspx::AspxFreqResMode::Signalled,
5094        };
5095        let tables = aspx::derive_aspx_frequency_tables(&cfg, 0).unwrap();
5096        let mut state_off = aspx::AspxChannelExtState::new();
5097        let out_off = Ac4Decoder::aspx_extend_pcm(
5098            &pcm,
5099            &tables,
5100            &cfg,
5101            None,
5102            None,
5103            None,
5104            None,
5105            None,
5106            None,
5107            None,
5108            &mut state_off,
5109            1,
5110            aspx::CompandingMode::Off,
5111            None,
5112        );
5113        let mut state_on = aspx::AspxChannelExtState::new();
5114        let out_on = Ac4Decoder::aspx_extend_pcm(
5115            &pcm,
5116            &tables,
5117            &cfg,
5118            None,
5119            None,
5120            None,
5121            None,
5122            None,
5123            None,
5124            None,
5125            &mut state_on,
5126            1,
5127            aspx::CompandingMode::PerSlot,
5128            None,
5129        );
5130        assert_eq!(out_off.len(), out_on.len());
5131        let start = 1200usize;
5132        let mut diffs = 0usize;
5133        for (a, b) in out_off[start..].iter().zip(out_on[start..].iter()) {
5134            if (a - b).abs() > 1e-6 {
5135                diffs += 1;
5136            }
5137        }
5138        assert!(
5139            diffs > (out_off.len() - start) / 4,
5140            "companding=on must alter the QMF-synthesis output (diffs={diffs})"
5141        );
5142    }
5143
5144    /// Round 42: `apply_companding_on_qmf` is a no-op when sbz <= sbx
5145    /// (degenerate band) — it must not panic on edge cases, and must
5146    /// leave the QMF matrix untouched.
5147    #[test]
5148    fn apply_companding_on_qmf_noop_on_empty_band() {
5149        let mut q = vec![vec![(1.0_f32, 1.0_f32); 16]; 64];
5150        let q_orig = q.clone();
5151        // sbx == sbz: no affected band.
5152        aspx::apply_companding_on_qmf(&mut q, 32, 32);
5153        assert_eq!(q, q_orig);
5154        // sbz < sbx: no-op.
5155        aspx::apply_companding_on_qmf(&mut q, 40, 32);
5156        assert_eq!(q, q_orig);
5157    }
5158
5159    /// Round 42: `apply_companding_on_qmf` produces unit-gain output
5160    /// on a pure-zero matrix (the `l == 0` early-return branch).
5161    #[test]
5162    fn apply_companding_on_qmf_unit_gain_on_zero_signal() {
5163        let mut q = vec![vec![(0.0_f32, 0.0_f32); 16]; 64];
5164        // All zeros + sbx=2, sbz=10: every slot's L_ch == 0 -> g = 1
5165        // -> Q stays at zero (no NaN / inf).
5166        aspx::apply_companding_on_qmf(&mut q, 2, 10);
5167        for row in q.iter() {
5168            for (re, im) in row.iter() {
5169                assert_eq!(*re, 0.0);
5170                assert_eq!(*im, 0.0);
5171            }
5172        }
5173    }
5174
5175    /// Round 39: `dispatch_7x_additional_channel_pair` IMDCTs
5176    /// `seven_x_additional_channel_data.scaled_spec_per_channel[0..2]`
5177    /// into PCM slots 5 / 6 (the F / G preliminary outputs per Table 182).
5178    /// SAP companding is the identity for now (b_use_sap_add_ch == false).
5179    #[test]
5180    fn dispatch_7x_additional_pair_populates_slots_5_and_6() {
5181        let params = CodecParameters::audio(CodecId::new("ac4"));
5182        let mut dec = Ac4Decoder::new(&params);
5183        let n: usize = 1_920;
5184        let ti = crate::asf::AsfTransformInfo {
5185            b_long_frame: true,
5186            transf_length: [0; 2],
5187            transform_length_0: n as u32,
5188            transform_length_1: n as u32,
5189        };
5190        let mk_ramp = |bias: f32| -> Vec<f32> { (0..n).map(|i| bias + 1e-3 * i as f32).collect() };
5191        let add = crate::mch::TwoChannelData {
5192            transform_info: Some(ti),
5193            psy_info: None,
5194            chparam: None,
5195            scaled_spec_per_channel: vec![Some(mk_ramp(0.30)), Some(mk_ramp(0.40))],
5196        };
5197        let mut pcm: Vec<Option<Vec<i16>>> = vec![None; 5];
5198        dec.dispatch_7x_additional_channel_pair(&add, None, [3, 4], None, n, &mut pcm);
5199        // Slots 0..4 untouched, slots 5/6 populated.
5200        for (slot, entry) in pcm.iter().enumerate().take(5) {
5201            assert!(entry.is_none(), "slot {slot} stays untouched");
5202        }
5203        assert_eq!(pcm.len(), 7);
5204        for slot in [5_usize, 6] {
5205            let v = pcm[slot]
5206                .as_ref()
5207                .unwrap_or_else(|| panic!("slot {slot} populated"));
5208            assert_eq!(v.len(), n);
5209            let energy: u64 = v.iter().map(|&s| s.unsigned_abs() as u64).sum();
5210            assert!(energy > 0, "slot {slot} must carry F/G energy");
5211        }
5212    }
5213
5214    /// Round 39: `dispatch_7x_additional_channel_pair` is a no-op when
5215    /// the carrier-length differs from the requested sample count.
5216    #[test]
5217    fn dispatch_7x_additional_pair_noop_on_length_mismatch() {
5218        let params = CodecParameters::audio(CodecId::new("ac4"));
5219        let mut dec = Ac4Decoder::new(&params);
5220        let ti = crate::asf::AsfTransformInfo {
5221            b_long_frame: true,
5222            transf_length: [0; 2],
5223            transform_length_0: 1_024,
5224            transform_length_1: 1_024,
5225        };
5226        let add = crate::mch::TwoChannelData {
5227            transform_info: Some(ti),
5228            psy_info: None,
5229            chparam: None,
5230            scaled_spec_per_channel: vec![Some(vec![0.1; 1_024]), Some(vec![0.2; 1_024])],
5231        };
5232        let mut pcm: Vec<Option<Vec<i16>>> = vec![None; 7];
5233        dec.dispatch_7x_additional_channel_pair(&add, None, [3, 4], None, 1_920, &mut pcm);
5234        for (slot, entry) in pcm.iter().enumerate() {
5235            assert!(
5236                entry.is_none(),
5237                "slot {slot} should be untouched on length mismatch"
5238            );
5239        }
5240    }
5241
5242    /// Round 40: with SAP `b_use_sap_add_ch == true` and identity
5243    /// chparam_info coefficients (sap_mode = 0 -> a=d=1, b=c=0), the
5244    /// dispatch should emit the partner spectrum on the partner slot
5245    /// and zero on the additional pair slot (since c=0, d=1 means
5246    /// `out_low = 0*P + 1*F = F`; identity passes F through to slot 5/6
5247    /// and P unchanged to partner slot — equivalent to the no-SAP path
5248    /// but with the partner slot also explicitly populated from the
5249    /// shared spectrum).
5250    #[test]
5251    fn dispatch_7x_additional_pair_sap_identity_routes_partner_and_additional() {
5252        let params = CodecParameters::audio(CodecId::new("ac4"));
5253        let mut dec = Ac4Decoder::new(&params);
5254        let n: usize = 1_920;
5255        let ti = crate::asf::AsfTransformInfo {
5256            b_long_frame: true,
5257            transf_length: [0; 2],
5258            transform_length_0: n as u32,
5259            transform_length_1: n as u32,
5260        };
5261        let mk_ramp = |bias: f32| -> Vec<f32> { (0..n).map(|i| bias + 1e-3 * i as f32).collect() };
5262        let add = crate::mch::TwoChannelData {
5263            transform_info: Some(ti),
5264            psy_info: None,
5265            chparam: None,
5266            scaled_spec_per_channel: vec![Some(mk_ramp(0.30)), Some(mk_ramp(0.40))],
5267        };
5268        let partner_d = mk_ramp(0.10);
5269        let partner_e = mk_ramp(0.20);
5270        let chparam = [
5271            crate::asf::ChparamInfo::default(),
5272            crate::asf::ChparamInfo::default(),
5273        ];
5274        let mut pcm: Vec<Option<Vec<i16>>> = vec![None; 5];
5275        dec.dispatch_7x_additional_channel_pair(
5276            &add,
5277            Some([partner_d.as_slice(), partner_e.as_slice()]),
5278            [3, 4],
5279            Some(&chparam),
5280            n,
5281            &mut pcm,
5282        );
5283        assert!(pcm.len() >= 7);
5284        // Partner slots 3/4 should now carry P (from the IMDCT of
5285        // partner_d / partner_e) — non-zero energy.
5286        for slot in [3_usize, 4] {
5287            let v = pcm[slot]
5288                .as_ref()
5289                .unwrap_or_else(|| panic!("partner slot {slot} populated"));
5290            assert_eq!(v.len(), n);
5291        }
5292        // Additional pair slots 5/6 carry F/G via the identity SAP
5293        // (out_low = 0*P + 1*F = F).
5294        for slot in [5_usize, 6] {
5295            let v = pcm[slot]
5296                .as_ref()
5297                .unwrap_or_else(|| panic!("add slot {slot} populated"));
5298            assert_eq!(v.len(), n);
5299            let energy: u64 = v.iter().map(|&s| s.unsigned_abs() as u64).sum();
5300            assert!(energy > 0, "add slot {slot} must carry F/G energy");
5301        }
5302    }
5303
5304    /// Round 43: `five_x_compand_mode_for_slot` resolves the active
5305    /// branch of Pseudocode 121 per output channel. Verify each of
5306    /// the (sync, on, avg) combinations the spec admits.
5307    #[test]
5308    fn five_x_compand_mode_for_slot_resolves_each_branch() {
5309        // None CC -> Off everywhere.
5310        for slot in 0..5 {
5311            assert_eq!(
5312                Ac4Decoder::five_x_compand_mode_for_slot(None, slot),
5313                aspx::CompandingMode::Off
5314            );
5315        }
5316        // Per-channel mix: ch0 on, ch1 off+avg, ch2 off (no avg).
5317        let cc_per = aspx::CompandingControl {
5318            sync_flag: Some(false),
5319            compand_on: vec![true, false, false, true, true],
5320            compand_avg: Some(true),
5321        };
5322        assert_eq!(
5323            Ac4Decoder::five_x_compand_mode_for_slot(Some(&cc_per), 0),
5324            aspx::CompandingMode::PerSlot
5325        );
5326        assert_eq!(
5327            Ac4Decoder::five_x_compand_mode_for_slot(Some(&cc_per), 1),
5328            aspx::CompandingMode::Averaged
5329        );
5330        // Sync per-slot.
5331        let cc_sync_on = aspx::CompandingControl {
5332            sync_flag: Some(true),
5333            compand_on: vec![true],
5334            compand_avg: None,
5335        };
5336        for slot in 0..5 {
5337            assert_eq!(
5338                Ac4Decoder::five_x_compand_mode_for_slot(Some(&cc_sync_on), slot),
5339                aspx::CompandingMode::SyncPerSlot
5340            );
5341        }
5342        // Sync averaged.
5343        let cc_sync_avg = aspx::CompandingControl {
5344            sync_flag: Some(true),
5345            compand_on: vec![false],
5346            compand_avg: Some(true),
5347        };
5348        for slot in 0..5 {
5349            assert_eq!(
5350                Ac4Decoder::five_x_compand_mode_for_slot(Some(&cc_sync_avg), slot),
5351                aspx::CompandingMode::SyncAveraged
5352            );
5353        }
5354    }
5355
5356    /// Round 43: `aspx_extend_pcm` honours the sb0 override — passing
5357    /// a non-default sb0 (the ASPX_ACPL_1 `acpl_qmf_band` rule)
5358    /// produces output that differs from the default `tables.sbx`
5359    /// baseline.
5360    #[test]
5361    fn aspx_extend_pcm_with_sb0_override_changes_output() {
5362        let n_slots = 30usize;
5363        let n = n_slots * 64;
5364        let mut pcm = vec![0.0f32; n];
5365        let f = 1200.0_f32 / 48_000.0_f32;
5366        for (i, s) in pcm.iter_mut().enumerate() {
5367            *s = (2.0 * std::f32::consts::PI * f * i as f32).sin();
5368        }
5369        let cfg = aspx::AspxConfig {
5370            quant_mode_env: aspx::AspxQuantStep::Fine,
5371            start_freq: 0,
5372            stop_freq: 0,
5373            master_freq_scale: aspx::AspxMasterFreqScale::HighRes,
5374            interpolation: false,
5375            preflat: false,
5376            limiter: false,
5377            noise_sbg: 0,
5378            num_env_bits_fixfix: 0,
5379            freq_res_mode: aspx::AspxFreqResMode::Signalled,
5380        };
5381        let tables = aspx::derive_aspx_frequency_tables(&cfg, 0).unwrap();
5382        // Default sb0 (== tables.sbx).
5383        let mut state_a = aspx::AspxChannelExtState::new();
5384        let out_default = Ac4Decoder::aspx_extend_pcm(
5385            &pcm,
5386            &tables,
5387            &cfg,
5388            None,
5389            None,
5390            None,
5391            None,
5392            None,
5393            None,
5394            None,
5395            &mut state_a,
5396            1,
5397            aspx::CompandingMode::PerSlot,
5398            None,
5399        );
5400        // Override sb0 to a different value strictly less than sbx (or
5401        // strictly between sbx and sbz) — it must change the affected
5402        // band and thus the output post-QMF synthesis.
5403        let alt_sb0 = if tables.sbx > 1 {
5404            tables.sbx - 1
5405        } else {
5406            tables.sbx + 1
5407        };
5408        let mut state_b = aspx::AspxChannelExtState::new();
5409        let out_override = Ac4Decoder::aspx_extend_pcm(
5410            &pcm,
5411            &tables,
5412            &cfg,
5413            None,
5414            None,
5415            None,
5416            None,
5417            None,
5418            None,
5419            None,
5420            &mut state_b,
5421            1,
5422            aspx::CompandingMode::PerSlot,
5423            Some(alt_sb0),
5424        );
5425        assert_eq!(out_default.len(), out_override.len());
5426        let start = 1200usize;
5427        let mut diffs = 0usize;
5428        for (a, b) in out_default[start..]
5429            .iter()
5430            .zip(out_override[start..].iter())
5431        {
5432            if (a - b).abs() > 1e-6 {
5433                diffs += 1;
5434            }
5435        }
5436        assert!(
5437            diffs > 0,
5438            "sb0 override must alter the QMF-synthesis output (diffs={diffs})"
5439        );
5440    }
5441
5442    /// Round 43: `aspx_extend_pcm` with `CompandingMode::Averaged`
5443    /// produces output that diverges from the `Off` baseline AND
5444    /// from the `PerSlot` branch — averaging collapses per-slot
5445    /// variation into a constant gain.
5446    #[test]
5447    fn aspx_extend_pcm_averaged_branch_diverges_from_per_slot() {
5448        let n_slots = 30usize;
5449        let n = n_slots * 64;
5450        let mut pcm = vec![0.0f32; n];
5451        // Mix two tones so the per-slot energy actually varies.
5452        let f1 = 600.0_f32 / 48_000.0_f32;
5453        let f2 = 1900.0_f32 / 48_000.0_f32;
5454        for (i, s) in pcm.iter_mut().enumerate() {
5455            *s = (2.0 * std::f32::consts::PI * f1 * i as f32).sin()
5456                + 0.4 * (2.0 * std::f32::consts::PI * f2 * i as f32).sin();
5457        }
5458        let cfg = aspx::AspxConfig {
5459            quant_mode_env: aspx::AspxQuantStep::Fine,
5460            start_freq: 0,
5461            stop_freq: 0,
5462            master_freq_scale: aspx::AspxMasterFreqScale::HighRes,
5463            interpolation: false,
5464            preflat: false,
5465            limiter: false,
5466            noise_sbg: 0,
5467            num_env_bits_fixfix: 0,
5468            freq_res_mode: aspx::AspxFreqResMode::Signalled,
5469        };
5470        let tables = aspx::derive_aspx_frequency_tables(&cfg, 0).unwrap();
5471        let mut state_off = aspx::AspxChannelExtState::new();
5472        let out_off = Ac4Decoder::aspx_extend_pcm(
5473            &pcm,
5474            &tables,
5475            &cfg,
5476            None,
5477            None,
5478            None,
5479            None,
5480            None,
5481            None,
5482            None,
5483            &mut state_off,
5484            1,
5485            aspx::CompandingMode::Off,
5486            None,
5487        );
5488        let mut state_per = aspx::AspxChannelExtState::new();
5489        let out_per = Ac4Decoder::aspx_extend_pcm(
5490            &pcm,
5491            &tables,
5492            &cfg,
5493            None,
5494            None,
5495            None,
5496            None,
5497            None,
5498            None,
5499            None,
5500            &mut state_per,
5501            1,
5502            aspx::CompandingMode::PerSlot,
5503            None,
5504        );
5505        let mut state_avg = aspx::AspxChannelExtState::new();
5506        let out_avg = Ac4Decoder::aspx_extend_pcm(
5507            &pcm,
5508            &tables,
5509            &cfg,
5510            None,
5511            None,
5512            None,
5513            None,
5514            None,
5515            None,
5516            None,
5517            &mut state_avg,
5518            1,
5519            aspx::CompandingMode::Averaged,
5520            None,
5521        );
5522        assert_eq!(out_off.len(), out_avg.len());
5523        assert_eq!(out_per.len(), out_avg.len());
5524        let start = 1200usize;
5525        // Averaged differs from Off (companding actually fired).
5526        let mut diffs_off_avg = 0usize;
5527        for (a, b) in out_off[start..].iter().zip(out_avg[start..].iter()) {
5528            if (a - b).abs() > 1e-6 {
5529                diffs_off_avg += 1;
5530            }
5531        }
5532        assert!(
5533            diffs_off_avg > 0,
5534            "Averaged must diverge from Off baseline (diffs={diffs_off_avg})"
5535        );
5536        // Averaged differs from PerSlot (constant scale vs per-slot scale).
5537        let mut diffs_per_avg = 0usize;
5538        for (a, b) in out_per[start..].iter().zip(out_avg[start..].iter()) {
5539            if (a - b).abs() > 1e-6 {
5540                diffs_per_avg += 1;
5541            }
5542        }
5543        assert!(
5544            diffs_per_avg > 0,
5545            "Averaged must diverge from PerSlot (diffs={diffs_per_avg})"
5546        );
5547    }
5548
5549    /// Round 44: `five_x_synced_mode` returns `Some(SyncPerSlot)` /
5550    /// `Some(SyncAveraged)` only when sync_flag=1 + the appropriate
5551    /// `compand_on[0]` / `compand_avg` flags resolve to one of the
5552    /// active sync sub-branches; returns `None` for all other states
5553    /// (no companding control, sync_flag=0, sync_flag=1+Off).
5554    #[test]
5555    fn five_x_synced_mode_resolves_each_branch() {
5556        // No companding control -> None.
5557        assert!(Ac4Decoder::five_x_synced_mode(None).is_none());
5558        // sync_flag=0 -> None (per-channel path).
5559        let cc_per = aspx::CompandingControl {
5560            sync_flag: Some(false),
5561            compand_on: vec![true, false, true, false, true],
5562            compand_avg: Some(true),
5563        };
5564        assert!(Ac4Decoder::five_x_synced_mode(Some(&cc_per)).is_none());
5565        // sync_flag=None (mono case) -> None.
5566        let cc_mono = aspx::CompandingControl {
5567            sync_flag: None,
5568            compand_on: vec![true],
5569            compand_avg: None,
5570        };
5571        assert!(Ac4Decoder::five_x_synced_mode(Some(&cc_mono)).is_none());
5572        // sync_flag=1, compand_on[0]=true -> SyncPerSlot.
5573        let cc_sync_on = aspx::CompandingControl {
5574            sync_flag: Some(true),
5575            compand_on: vec![true],
5576            compand_avg: None,
5577        };
5578        assert_eq!(
5579            Ac4Decoder::five_x_synced_mode(Some(&cc_sync_on)),
5580            Some(aspx::CompandingMode::SyncPerSlot)
5581        );
5582        // sync_flag=1, compand_on[0]=false, compand_avg=true -> SyncAveraged.
5583        let cc_sync_avg = aspx::CompandingControl {
5584            sync_flag: Some(true),
5585            compand_on: vec![false],
5586            compand_avg: Some(true),
5587        };
5588        assert_eq!(
5589            Ac4Decoder::five_x_synced_mode(Some(&cc_sync_avg)),
5590            Some(aspx::CompandingMode::SyncAveraged)
5591        );
5592        // sync_flag=1, compand_on[0]=false, compand_avg=false -> None
5593        // (companding actually off; per-channel path takes the no-op
5594        // branch).
5595        let cc_sync_off = aspx::CompandingControl {
5596            sync_flag: Some(true),
5597            compand_on: vec![false],
5598            compand_avg: Some(false),
5599        };
5600        assert!(Ac4Decoder::five_x_synced_mode(Some(&cc_sync_off)).is_none());
5601    }
5602
5603    /// Round 45: stereo-CPE M=2 synced companding helper —
5604    /// `extend_stereo_cpe_pair_with_sync_companding` writes the
5605    /// `g_synch(ts) = √(g_0(ts) · g_1(ts))` synced gain into BOTH
5606    /// channels' QMF matrices, then runs inverse QMF synthesis. This
5607    /// produces a different output than the per-channel
5608    /// `aspx_extend_pcm` path with `PerSlot` mode (which writes
5609    /// independent per-channel gains).
5610    ///
5611    /// The test pins:
5612    ///   * Output cardinality + length (one extended PCM per input).
5613    ///   * Output is non-silent (the HF tile copy + 0.5 flat envelope
5614    ///     gain + companding apply produces audible content).
5615    ///   * Synced output differs from per-channel output (proves the
5616    ///     synced gain is actually applied, not silently skipped).
5617    ///   * Synced output differs from `Off` output (proves the helper
5618    ///     applies a non-trivial gain, not just passthrough).
5619    ///
5620    /// The numerical correctness of the geometric-mean formula is
5621    /// already exhaustively covered by
5622    /// `aspx::tests::apply_synchronised_companding_*` against the
5623    /// bare QMF helper; this test just confirms the integration glue
5624    /// (phase-1 + sync apply + phase-2) is wired correctly for the
5625    /// stereo-CPE path that drives 5_X ASPX_ACPL_3's L/R surround
5626    /// pair carriers.
5627    #[test]
5628    fn extend_stereo_cpe_pair_with_sync_companding_diverges_from_per_channel() {
5629        let cfg = aspx::AspxConfig {
5630            quant_mode_env: aspx::AspxQuantStep::Fine,
5631            start_freq: 0,
5632            stop_freq: 0,
5633            master_freq_scale: aspx::AspxMasterFreqScale::HighRes,
5634            interpolation: false,
5635            preflat: false,
5636            limiter: false,
5637            noise_sbg: 0,
5638            num_env_bits_fixfix: 0,
5639            freq_res_mode: aspx::AspxFreqResMode::Signalled,
5640        };
5641        let tables = aspx::derive_aspx_frequency_tables(&cfg, 0).unwrap();
5642        let n_slots = 24usize;
5643        let n = n_slots * 64;
5644        // Asymmetric carrier energies (8x amplitude difference) so
5645        // per-channel companding produces clearly different per-slot
5646        // gains for the two channels — synced gain (geometric mean)
5647        // is the single common scale.
5648        let mut pcm_a = vec![0.0f32; n];
5649        let mut pcm_b = vec![0.0f32; n];
5650        let f1 = 700.0_f32 / 48_000.0_f32;
5651        let f2 = 1100.0_f32 / 48_000.0_f32;
5652        for i in 0..n {
5653            pcm_a[i] = 0.04 * (2.0 * std::f32::consts::PI * f1 * i as f32).sin();
5654            pcm_b[i] = 0.32 * (2.0 * std::f32::consts::PI * f2 * i as f32).sin();
5655        }
5656        let params = CodecParameters::audio(CodecId::new("ac4"));
5657        // Synced run.
5658        let mut dec_sync = Ac4Decoder::new(&params);
5659        let pri_input = StereoCpeChannelInput {
5660            ch_index: 0,
5661            pcm_in: &pcm_a,
5662            framing: None,
5663            sig: None,
5664            noise: None,
5665            qmode: None,
5666            delta_dir: None,
5667            add_harmonic: None,
5668            tna_mode: None,
5669        };
5670        let sec_input = StereoCpeChannelInput {
5671            ch_index: 1,
5672            pcm_in: &pcm_b,
5673            framing: None,
5674            sig: None,
5675            noise: None,
5676            qmode: None,
5677            delta_dir: None,
5678            add_harmonic: None,
5679            tna_mode: None,
5680        };
5681        let (sync_a, sync_b) = dec_sync.extend_stereo_cpe_pair_with_sync_companding(
5682            &pri_input,
5683            &sec_input,
5684            &tables,
5685            &cfg,
5686            1,
5687            aspx::CompandingMode::SyncPerSlot,
5688            None,
5689        );
5690        // Helper-3 returns one PCM per input, both length-matched.
5691        assert_eq!(sync_a.len(), n);
5692        assert_eq!(sync_b.len(), n);
5693        // Per-channel comparison run — same inputs, but each
5694        // channel through its own `aspx_extend_pcm` with PerSlot
5695        // mode (no cross-channel synchronisation).
5696        let mut state_a = aspx::AspxChannelExtState::new();
5697        let per_a = Ac4Decoder::aspx_extend_pcm(
5698            &pcm_a,
5699            &tables,
5700            &cfg,
5701            None,
5702            None,
5703            None,
5704            None,
5705            None,
5706            None,
5707            None,
5708            &mut state_a,
5709            1,
5710            aspx::CompandingMode::PerSlot,
5711            None,
5712        );
5713        let mut state_b = aspx::AspxChannelExtState::new();
5714        let per_b = Ac4Decoder::aspx_extend_pcm(
5715            &pcm_b,
5716            &tables,
5717            &cfg,
5718            None,
5719            None,
5720            None,
5721            None,
5722            None,
5723            None,
5724            None,
5725            &mut state_b,
5726            1,
5727            aspx::CompandingMode::PerSlot,
5728            None,
5729        );
5730        // Companding-Off comparison — same shape but with the
5731        // companding gain bypassed entirely (proves the synced
5732        // helper writes a non-identity gain).
5733        let mut state_off_a = aspx::AspxChannelExtState::new();
5734        let off_a = Ac4Decoder::aspx_extend_pcm(
5735            &pcm_a,
5736            &tables,
5737            &cfg,
5738            None,
5739            None,
5740            None,
5741            None,
5742            None,
5743            None,
5744            None,
5745            &mut state_off_a,
5746            1,
5747            aspx::CompandingMode::Off,
5748            None,
5749        );
5750        let mut state_off_b = aspx::AspxChannelExtState::new();
5751        let off_b = Ac4Decoder::aspx_extend_pcm(
5752            &pcm_b,
5753            &tables,
5754            &cfg,
5755            None,
5756            None,
5757            None,
5758            None,
5759            None,
5760            None,
5761            None,
5762            &mut state_off_b,
5763            1,
5764            aspx::CompandingMode::Off,
5765            None,
5766        );
5767        let energy = |v: &[f32]| -> f64 { v.iter().map(|s| (*s as f64).powi(2)).sum() };
5768        // Outputs are non-silent.
5769        assert!(energy(&sync_a) > 0.0);
5770        assert!(energy(&sync_b) > 0.0);
5771        // Synced output differs from per-channel output —
5772        // proves the synced gain (geometric mean across both
5773        // channels) is genuinely different from the local gain
5774        // each channel would produce on its own. The geometric
5775        // mean of two unequal positive numbers is strictly
5776        // between them, so neither channel's synced output
5777        // matches its own per-channel output.
5778        let diff_a: f64 = sync_a
5779            .iter()
5780            .zip(per_a.iter())
5781            .map(|(s, p)| ((*s - *p) as f64).abs())
5782            .sum();
5783        let diff_b: f64 = sync_b
5784            .iter()
5785            .zip(per_b.iter())
5786            .map(|(s, p)| ((*s - *p) as f64).abs())
5787            .sum();
5788        assert!(
5789            diff_a > 0.0,
5790            "synced channel A must differ from per-channel A (sync gain is geometric mean of g_a, g_b which differ)"
5791        );
5792        assert!(
5793            diff_b > 0.0,
5794            "synced channel B must differ from per-channel B (sync gain is geometric mean of g_a, g_b which differ)"
5795        );
5796        // Synced output also differs from Off output (the synced
5797        // gain is non-trivial — not the identity).
5798        let diff_off_a: f64 = sync_a
5799            .iter()
5800            .zip(off_a.iter())
5801            .map(|(s, o)| ((*s - *o) as f64).abs())
5802            .sum();
5803        let diff_off_b: f64 = sync_b
5804            .iter()
5805            .zip(off_b.iter())
5806            .map(|(s, o)| ((*s - *o) as f64).abs())
5807            .sum();
5808        assert!(
5809            diff_off_a > 0.0,
5810            "synced channel A must differ from companding-Off A"
5811        );
5812        assert!(
5813            diff_off_b > 0.0,
5814            "synced channel B must differ from companding-Off B"
5815        );
5816    }
5817
5818    /// Round 44: `extend_5x_channels_with_sync_companding` returns
5819    /// one output PCM slice per input entry, in input order. The
5820    /// helper is the integration glue between the per-channel
5821    /// `aspx_extend_to_qmf` phase and the cross-channel
5822    /// `apply_synchronised_companding_across_channels` apply — this
5823    /// test pins the output cardinality + slot order. The numerical
5824    /// behaviour (geometric-mean equalisation) is exhaustively
5825    /// covered in `aspx::tests::apply_synchronised_companding_*`
5826    /// against the bare QMF helper.
5827    #[test]
5828    fn extend_5x_channels_with_sync_companding_returns_one_output_per_entry() {
5829        let cfg = aspx::AspxConfig {
5830            quant_mode_env: aspx::AspxQuantStep::Fine,
5831            start_freq: 0,
5832            stop_freq: 0,
5833            master_freq_scale: aspx::AspxMasterFreqScale::HighRes,
5834            interpolation: false,
5835            preflat: false,
5836            limiter: false,
5837            noise_sbg: 0,
5838            num_env_bits_fixfix: 0,
5839            freq_res_mode: aspx::AspxFreqResMode::Signalled,
5840        };
5841        let tables = aspx::derive_aspx_frequency_tables(&cfg, 0).unwrap();
5842        let ch = aspx::FiveXAspxChannelTrailer {
5843            framing: aspx::AspxFraming {
5844                int_class: aspx::AspxIntClass::FixFix,
5845                num_env: 1,
5846                num_noise: 1,
5847                freq_res: vec![true],
5848                var_bord_left: None,
5849                var_bord_right: None,
5850                num_rel_left: 0,
5851                num_rel_right: 0,
5852                rel_bord_left: vec![],
5853                rel_bord_right: vec![],
5854                tsg_ptr: None,
5855            },
5856            qmode_env: aspx::AspxQuantStep::Fine,
5857            data_sig: vec![],
5858            data_noise: vec![],
5859            delta_dir: aspx::AspxDeltaDir {
5860                sig_delta_dir: vec![],
5861                noise_delta_dir: vec![],
5862            },
5863            add_harmonic: None,
5864            tna_mode: None,
5865        };
5866        let trailer = aspx::FiveXAspxTrailer {
5867            xover: 0,
5868            frequency_tables: tables,
5869            primary: ch.clone(),
5870            secondary: Some(ch.clone()),
5871        };
5872        let n_slots = 24usize;
5873        let n = n_slots * 64;
5874        let mut pcm_a = vec![0.0f32; n];
5875        let mut pcm_b = vec![0.0f32; n];
5876        let f1 = 700.0_f32 / 48_000.0_f32;
5877        let f2 = 1100.0_f32 / 48_000.0_f32;
5878        for i in 0..n {
5879            pcm_a[i] = 0.05 * (2.0 * std::f32::consts::PI * f1 * i as f32).sin();
5880            pcm_b[i] = 0.8 * (2.0 * std::f32::consts::PI * f2 * i as f32).sin();
5881        }
5882        let params = CodecParameters::audio(CodecId::new("ac4"));
5883        let mut dec = Ac4Decoder::new(&params);
5884        let entries: Vec<SyncCompandingChannelEntry<'_>> = vec![
5885            (0, &pcm_a, &trailer, &trailer.primary, &cfg, None),
5886            (
5887                3,
5888                &pcm_b,
5889                &trailer,
5890                trailer.secondary.as_ref().unwrap(),
5891                &cfg,
5892                None,
5893            ),
5894        ];
5895        let out = dec.extend_5x_channels_with_sync_companding(
5896            &entries,
5897            1,
5898            aspx::CompandingMode::SyncPerSlot,
5899        );
5900        assert_eq!(out.len(), 2);
5901        // Slot indices preserved in input order.
5902        assert_eq!(out[0].0, 0);
5903        assert_eq!(out[1].0, 3);
5904        // Each PCM matches the input length.
5905        assert_eq!(out[0].1.len(), n);
5906        assert_eq!(out[1].1.len(), n);
5907    }
5908}