Skip to main content

flexaudio_core/
normalizer.rs

1//! Normalize arbitrary device frames (any sample rate/channel count, interleaved f32)
2//! in two stages, then convert them back to the requested output format.
3//!
4//! ```text
5//! Input (any sample rate/channel count)
6//!   │  Stage 1 (internal normalization; fixed)
7//!   │   - Channel mix (→stereo)
8//!   │   - Sample-rate conversion (rubato, →48000)
9//!   ▼
10//! Internal canonical form: f32 / 48000 Hz / stereo / 20ms = 960 frames
11//!   │  Stage 2 (output; per tap)
12//!   │   - Channel conversion (stereo→mono average / mono→stereo duplicate / unchanged)
13//!   │   - Sample-rate conversion (rubato, 48000→output.sample_rate; passthrough if equal)
14//!   ▼
15//! Output: f32 / output.sample_rate / output.channels / fixed 20ms duration
16//!        (48k=960 / 16k=320 / 8k=160 frames)
17//! ```
18//!
19//! With the default output `{48000, 2}`, stage 2 is entirely passthrough (the internal
20//! canonical form is emitted as-is). Stage 1 sample-rate conversion is bypassed when
21//! `in_sample_rate == 48000`; stage 2 conversion is bypassed when `output.sample_rate == 48000`.
22//!
23//! Both rubato resamplers use `FixedAsync::Input` (fixed input chunks). Variable-length output
24//! is collected in an internal accumulator and split at 20ms boundaries. The resampler and
25//! accumulator carry any remainder forward.
26//!
27//! PTS is assigned by tracking the input-to-output sample-offset ratio from `device_pts`
28//! corresponding to the first sample of each output chunk. The stream layer assigns `seq`.
29
30use rubato::audioadapter_buffers::direct::InterleavedSlice;
31use rubato::{
32    Async, FixedAsync, Indexing, Resampler, SincInterpolationParameters, SincInterpolationType,
33    WindowFunction,
34};
35
36use crate::types::{Error, OutputFormat, Result, CHANNELS, SAMPLE_RATE};
37
38/// Frames per internal canonical-form chunk (20ms @ 48kHz). Stage 1 split boundary.
39pub const CHUNK_FRAMES: usize = 960;
40
41/// Channel count in the internal canonical form (stereo).
42const INNER_CH: usize = CHANNELS as usize; // 2
43
44/// A processor applied to the internal normalized form (48kHz / stereo /
45/// interleaved f32) *before* it is split into the output taps.
46///
47/// Kept behind a trait so `flexaudio-core` stays independent of any concrete
48/// DSP implementation (e.g. noise suppression). The facade injects an
49/// implementation; the core only sees this contract. Because the same
50/// processed samples feed every output tap, a single processor affects both the
51/// primary and secondary outputs.
52///
53/// [`process`](Self::process) runs on each batch of normalized samples in
54/// place. [`flush`](Self::flush) is called once at stop to recover any tail the
55/// processor is holding back (e.g. a fixed delay line); it returns the trailing
56/// 48kHz / stereo interleaved samples (empty if none).
57pub trait InnerProcessor: Send {
58    /// Process one batch of 48kHz / stereo interleaved samples in place. The
59    /// slice length is always a multiple of two (stereo).
60    fn process(&mut self, samples: &mut [f32]);
61    /// Flush and return any buffered tail (48kHz / stereo interleaved). Called
62    /// once when the stream stops. Returns an empty vector when nothing is held.
63    fn flush(&mut self) -> Vec<f32>;
64}
65
66/// Stateful two-stage converter that normalizes input device frames to the internal canonical
67/// form (48k/stereo/960 frames), then converts them to one or more output taps (primary and
68/// optional secondary).
69///
70/// Stage 1 (internal normalization) runs once and feeds the resulting canonical form to stage 2
71/// of both taps. An optional [`InnerProcessor`] is applied once to the canonical form before it
72/// is split for stage 2, so both taps receive the same processed audio.
73///
74/// Accumulate interleaved samples with `push`, then retrieve completed output chunks one at a
75/// time with `pop_chunk` (primary) and `pop_secondary` (secondary).
76pub struct Normalizer {
77    in_sample_rate: u32,
78    in_channels: usize,
79
80    // --- Stage 1 (internal normalization: → 48k/stereo, shared by all output taps) ---
81    /// Passthrough when the input is 48000 Hz (no resampler).
82    stage1_resampler: Option<ResamplerState>,
83    /// Temporary buffer for the internal canonical form (48k/stereo interleaved) generated by
84    /// this push. Apply [`InnerProcessor`] before distributing it to each output tap. Reuse capacity.
85    inner_scratch: Vec<f32>,
86    /// Total internal 48k frames generated by stage 1 so far (for PTS anchor calculation).
87    total_inner_frames: u64,
88
89    /// Optional processor (e.g. noise suppression) applied once to the internal canonical form
90    /// before the stage 2 split.
91    inner_processor: Option<Box<dyn InnerProcessor>>,
92
93    // --- Output taps (each has its own stage 2, output buffer, and PTS state) ---
94    /// Primary output tap (its [`OutputFormat`] is `output`).
95    primary: OutputTap,
96    /// Secondary output tap (when configured). Has stage 2 and PTS state independent of primary.
97    secondary: Option<OutputTap>,
98}
99
100/// One output tap. Receives the shared internal canonical form (48k/stereo), converts channels
101/// and sample rate to its [`OutputFormat`], then emits fixed 20ms chunks. Each tap has its own
102/// PTS anchor and output buffer (which can cause tens of milliseconds of PTS offset between taps).
103struct OutputTap {
104    output: OutputFormat,
105    /// Output stage. `None` (full passthrough) when `output == {48000, 2}`, matching the canonical form.
106    stage2: Option<OutputStage>,
107    /// Output awaiting completion (interleaved with `output.channels`). `pop` drains from here.
108    out_buf: Vec<f32>,
109    /// Frames in one output chunk (`output.chunk_frames()`).
110    out_chunk_frames: usize,
111    /// Output channel count.
112    out_channels: usize,
113    /// Output frame index for the first sample in `out_buf` (the oldest sample not yet popped).
114    out_frame_origin: u64,
115    /// PTS anchor: associates an output frame index with `device_pts` (ns).
116    pts_anchor: Option<PtsAnchor>,
117}
118
119#[derive(Clone, Copy)]
120struct PtsAnchor {
121    /// Output frame index, based on the output sample rate.
122    out_frame: u64,
123    /// `device_pts` (ns) corresponding to that output frame.
124    pts_ns: i64,
125}
126
127/// Sample-rate converter wrapping rubato `Async` (`FixedAsync::Input`) for one stage.
128///
129/// Calls `process` for each fixed input chunk of `chunk_in_frames` frames and appends variable-length
130/// output to `out_buf` (the caller's accumulator). `channels` varies by stage (always stereo=2 in
131/// stage 1; the output channel count in stage 2).
132struct ResamplerState {
133    inner: Async<f32>,
134    channels: usize,
135    /// Input frames required per rubato call (fixed by `FixedAsync::Input`).
136    chunk_in_frames: usize,
137    /// Maximum output frames one `process` call can produce.
138    max_out_frames: usize,
139    /// Unprocessed input (interleaved, `channels` channels).
140    in_accum: Vec<f32>,
141    /// Output scratch buffer for rubato (reused to avoid allocations).
142    out_scratch: Vec<f32>,
143}
144
145/// Stage 2 (output). Receives 960-frame chunks in the internal 48k/stereo canonical form and
146/// converts channels and sample rate to produce interleaved output in the requested format.
147struct OutputStage {
148    out_channels: usize,
149    /// Resampler from 48000 to `output.sample_rate`. `None` (sample-rate passthrough) when
150    /// `output.sample_rate == 48000`. Applied to samples after channel conversion.
151    resampler: Option<ResamplerState>,
152    /// Scratch buffer after channel conversion and before sample-rate conversion (48k / `out_channels` interleaved).
153    ch_scratch: Vec<f32>,
154}
155
156impl Normalizer {
157    /// Create a normalizer for the input sample rate, input channel count, and output format.
158    ///
159    /// Stage 1 normalizes input to 48k/stereo (`in_sample_rate == 48000` bypasses sample-rate
160    /// conversion; `in_channels` of 1 duplicates mono to stereo, 2 is unchanged, and 3 or more
161    /// uses the front two channels). Stage 2 converts the canonical form to `output` (passthrough
162    /// when `output == {48000, 2}`).
163    ///
164    /// Expects the caller to have run [`OutputFormat::validate`] on `output` (this method does not
165    /// clamp it to a valid range).
166    ///
167    /// rubato resampler construction can fail, for example with extreme rate ratios. Panicking
168    /// would silently stop the non-RT capture thread, so failures return [`Error::Backend`] and
169    /// propagate to the caller.
170    pub fn new(in_sample_rate: u32, in_channels: u16, output: OutputFormat) -> Result<Self> {
171        let in_channels = in_channels.max(1) as usize;
172
173        // Stage 1 resampler (→48000). Runs once for all output taps.
174        let stage1_resampler = if in_sample_rate == SAMPLE_RATE {
175            None
176        } else {
177            Some(ResamplerState::new(in_sample_rate, SAMPLE_RATE, INNER_CH)?)
178        };
179
180        Ok(Self {
181            in_sample_rate,
182            in_channels,
183            stage1_resampler,
184            inner_scratch: Vec::with_capacity(CHUNK_FRAMES * INNER_CH * 4),
185            total_inner_frames: 0,
186            inner_processor: None,
187            primary: OutputTap::new(output)?,
188            secondary: None,
189        })
190    }
191
192    /// Add a secondary output tap (expects [`OutputFormat::validate`] to have been called).
193    ///
194    /// The internal canonical form (48k/stereo) is generated once and fed to stage 2 of both taps.
195    /// The secondary tap has independent stage 2 and PTS state, and emits its own fixed 20ms chunks.
196    /// rubato construction failures return [`Error::Backend`].
197    pub fn with_secondary(mut self, secondary: OutputFormat) -> Result<Self> {
198        self.secondary = Some(OutputTap::new(secondary)?);
199        Ok(self)
200    }
201
202    /// Inject a processor to apply once to the internal canonical form before the stage 2 split.
203    pub fn with_inner_processor(mut self, processor: Box<dyn InnerProcessor>) -> Self {
204        self.inner_processor = Some(processor);
205        self
206    }
207
208    /// Input sample rate (Hz).
209    pub fn in_sample_rate(&self) -> u32 {
210        self.in_sample_rate
211    }
212
213    /// Primary output format.
214    pub fn output(&self) -> OutputFormat {
215        self.primary.output
216    }
217
218    /// Secondary output format (only when a secondary tap is configured).
219    pub fn secondary_output(&self) -> Option<OutputFormat> {
220        self.secondary.as_ref().map(|t| t.output)
221    }
222
223    /// Whether a secondary tap is enabled.
224    pub fn has_secondary(&self) -> bool {
225        self.secondary.is_some()
226    }
227
228    /// Whether stage 1 sample-rate conversion is bypassed (`in == 48000`).
229    pub fn is_passthrough(&self) -> bool {
230        self.stage1_resampler.is_none()
231    }
232
233    /// Whether stage 2 for the primary output is fully bypassed (`output == {48000, 2}`).
234    pub fn is_output_passthrough(&self) -> bool {
235        self.primary.stage2.is_none()
236    }
237
238    /// Accumulate interleaved input samples.
239    ///
240    /// `interleaved` must have a length divisible by `in_channels`. `device_pts_ns` is the
241    /// device-provided PTS for the first frame in this push.
242    ///
243    /// Returns [`Error::Backend`] if rubato's `process` fails. This avoids silently stopping the
244    /// capture thread with a panic and lets the caller stop the stream explicitly.
245    pub fn push(&mut self, interleaved: &[f32], device_pts_ns: i64) -> Result<()> {
246        if interleaved.is_empty() {
247            return Ok(());
248        }
249        let in_frames = interleaved.len() / self.in_channels;
250        if in_frames == 0 {
251            return Ok(());
252        }
253
254        // Approximate the future output frame position for the start of this push by the sample-rate
255        // ratio and update each tap's PTS anchor. This is approximate because the resampler retains
256        // a remainder internally. Set anchors independently for primary and secondary.
257        self.primary
258            .update_pts_anchor(self.total_inner_frames, device_pts_ns);
259        if let Some(sec) = self.secondary.as_mut() {
260            sec.update_pts_anchor(self.total_inner_frames, device_pts_ns);
261        }
262
263        // Stage 1: mix channels → stereo interleaved → normalize to 48k. Collect what this push
264        // generates in inner_scratch.
265        self.inner_scratch.clear();
266        let mut stereo = Vec::with_capacity(in_frames * INNER_CH);
267        Self::mix_to_stereo(interleaved, self.in_channels, in_frames, &mut stereo);
268        match &mut self.stage1_resampler {
269            None => {
270                // Sample-rate passthrough. Use directly as the internal canonical form.
271                self.total_inner_frames += in_frames as u64;
272                self.inner_scratch.extend_from_slice(&stereo);
273            }
274            Some(rs) => {
275                rs.in_accum.extend_from_slice(&stereo);
276                let produced = rs.drain_into(&mut self.inner_scratch)?;
277                self.total_inner_frames += produced;
278            }
279        }
280
281        // Apply a processor (e.g. denoise) once to the internal canonical form before the stage 2 split.
282        if let Some(proc) = self.inner_processor.as_mut() {
283            proc.process(&mut self.inner_scratch);
284        }
285
286        // Distribute the processed canonical form to stage 2 of both taps.
287        self.distribute_inner()
288    }
289
290    /// Retrieve one completed primary output chunk.
291    ///
292    /// Returns (`out_chunk_frames` interleaved frames at `output.channels`, PTS in ns for the
293    /// first sample). Returns `None` if a full chunk is not buffered.
294    pub fn pop_chunk(&mut self) -> Option<(Vec<f32>, i64)> {
295        self.primary.pop()
296    }
297
298    /// Retrieve one completed secondary output chunk (`None` if no secondary tap is configured).
299    pub fn pop_secondary(&mut self) -> Option<(Vec<f32>, i64)> {
300        self.secondary.as_mut().and_then(OutputTap::pop)
301    }
302
303    /// Flush on stop. Feed the processor's trailing tail (e.g. denoise), drain each tap's stage 2
304    /// resampler remainder, and pad a final partial chunk with silence to align it to the fixed
305    /// 20ms boundary so it can be retrieved with `pop_chunk` / `pop_secondary`.
306    ///
307    /// Continue on a best-effort basis if resampler flushing fails, so the stop path does not fail
308    /// (at most the final few milliseconds are lost).
309    pub fn flush(&mut self) {
310        // 1. Feed the processor's trailing tail (e.g. denoise delay line) as the internal canonical form.
311        if let Some(proc) = self.inner_processor.as_mut() {
312            let tail = proc.flush();
313            if !tail.is_empty() {
314                self.inner_scratch.clear();
315                self.inner_scratch.extend_from_slice(&tail);
316                let _ = self.distribute_inner();
317            }
318        }
319        // 2. Drain each tap's stage 2 resampler remainder and pad partial chunks with silence.
320        self.primary.flush();
321        if let Some(sec) = self.secondary.as_mut() {
322            sec.flush();
323        }
324    }
325
326    /// Number of output frames buffered but not yet retrieved from `out_buf` (primary tap).
327    pub fn buffered_out_frames(&self) -> usize {
328        self.primary.buffered_out_frames()
329    }
330
331    // --- Internal helpers ---
332
333    /// Distribute the canonical form in `inner_scratch` to stage 2 of both taps. Temporarily take
334    /// the buffer to avoid borrow conflicts, then restore its capacity.
335    fn distribute_inner(&mut self) -> Result<()> {
336        if self.inner_scratch.is_empty() {
337            return Ok(());
338        }
339        let inner = std::mem::take(&mut self.inner_scratch);
340        let r_primary = self.primary.feed_inner(&inner);
341        let r_secondary = self
342            .secondary
343            .as_mut()
344            .map(|sec| sec.feed_inner(&inner))
345            .unwrap_or(Ok(()));
346        // Restore the buffer so its capacity can be reused.
347        self.inner_scratch = inner;
348        self.inner_scratch.clear();
349        r_primary.and(r_secondary)
350    }
351
352    /// Mix interleaved audio with any channel count to stereo interleaved and push it to `dst`.
353    fn mix_to_stereo(src: &[f32], in_ch: usize, in_frames: usize, dst: &mut Vec<f32>) {
354        match in_ch {
355            1 => {
356                // mono → stereo (duplicate L=R)
357                for &s in &src[..in_frames] {
358                    dst.push(s);
359                    dst.push(s);
360                }
361            }
362            2 => {
363                // Keep 2 channels unchanged (only the required samples).
364                dst.extend_from_slice(&src[..in_frames * 2]);
365            }
366            _ => {
367                // For now, use the front two channels for inputs with more than 2 channels.
368                // TODO(ITU-R BS.775): Apply proper downmix coefficients for 5.1 and other layouts.
369                for f in 0..in_frames {
370                    let base = f * in_ch;
371                    dst.push(src[base]);
372                    dst.push(src[base + 1]);
373                }
374            }
375        }
376    }
377}
378
379impl OutputTap {
380    /// Create an output tap from an output format (expects `output` to be validated).
381    fn new(output: OutputFormat) -> Result<Self> {
382        let out_channels = (output.channels.max(1)) as usize;
383        let out_chunk_frames = output.chunk_frames().max(1);
384
385        // Skip stage 2 (passthrough) when output exactly matches the internal canonical form.
386        let stage2 = if output.sample_rate == SAMPLE_RATE && out_channels == INNER_CH {
387            None
388        } else {
389            Some(OutputStage::new(output.sample_rate, out_channels)?)
390        };
391
392        Ok(Self {
393            output,
394            stage2,
395            out_buf: Vec::with_capacity(out_chunk_frames * out_channels * 4),
396            out_chunk_frames,
397            out_channels,
398            out_frame_origin: 0,
399            pts_anchor: None,
400        })
401    }
402
403    /// Pass the processed internal canonical form (48k/stereo interleaved, any length) through
404    /// stage 2 and append the resulting output frames to `out_buf`.
405    fn feed_inner(&mut self, inner_stereo: &[f32]) -> Result<()> {
406        if inner_stereo.is_empty() {
407            return Ok(());
408        }
409        match &mut self.stage2 {
410            None => {
411                // Stage 2 passthrough (`output == {48000, 2}`). Append unchanged.
412                self.out_buf.extend_from_slice(inner_stereo);
413            }
414            Some(stage) => stage.process_inner(inner_stereo, &mut self.out_buf)?,
415        }
416        Ok(())
417    }
418
419    /// Retrieve one completed output chunk. Returns `None` if a full chunk is not buffered.
420    fn pop(&mut self) -> Option<(Vec<f32>, i64)> {
421        let need = self.out_chunk_frames * self.out_channels;
422        if self.out_buf.len() < need {
423            return None;
424        }
425        let pts = self.pts_for_out_frame(self.out_frame_origin);
426        let chunk: Vec<f32> = self.out_buf.drain(..need).collect();
427        self.out_frame_origin += self.out_chunk_frames as u64;
428        Some((chunk, pts))
429    }
430
431    /// Flush on stop. Drain the stage 2 resampler remainder and pad a final partial chunk with
432    /// silence to align it to the fixed 20ms boundary for retrieval with `pop`.
433    fn flush(&mut self) {
434        if let Some(stage) = self.stage2.as_mut() {
435            // Ignore resampler flush errors on a best-effort basis (only the final few ms are lost).
436            let _ = stage.flush_into(&mut self.out_buf);
437        }
438        let need = self.out_chunk_frames * self.out_channels;
439        let rem = self.out_buf.len() % need;
440        if rem != 0 {
441            let pad = need - rem;
442            self.out_buf.resize(self.out_buf.len() + pad, 0.0);
443        }
444    }
445
446    /// Number of output frames buffered but not yet retrieved from `out_buf`.
447    fn buffered_out_frames(&self) -> usize {
448        self.out_buf.len() / self.out_channels
449    }
450
451    /// Set the PTS anchor for the output frame position corresponding to the start of this push.
452    ///
453    /// The output frame position is an approximation mapped from the total internal frame count
454    /// to the output rate (not exact because the resampler retains a remainder). `in_sample_rate`
455    /// cancels out, so only the output and internal rates are needed.
456    fn update_pts_anchor(&mut self, total_inner_frames: u64, device_pts_ns: i64) {
457        let projected_out_frame = (total_inner_frames as f64 * self.output.sample_rate as f64
458            / SAMPLE_RATE as f64) as u64;
459        self.pts_anchor = Some(PtsAnchor {
460            out_frame: projected_out_frame,
461            pts_ns: device_pts_ns,
462        });
463    }
464
465    /// Extrapolate `device_pts` (ns) for output frame index `out_frame` from the anchor using the
466    /// output sample rate.
467    fn pts_for_out_frame(&self, out_frame: u64) -> i64 {
468        match self.pts_anchor {
469            None => crate::clock::monotonic_now_ns(),
470            Some(anchor) => {
471                let frame_delta = out_frame as i64 - anchor.out_frame as i64;
472                let ns_per_out_frame = 1_000_000_000_i64 / self.output.sample_rate as i64;
473                anchor.pts_ns + frame_delta * ns_per_out_frame
474            }
475        }
476    }
477}
478
479impl ResamplerState {
480    /// Create a fixed-ratio resampler with `channels` channels from `in_sr` to `out_sr`.
481    ///
482    /// Returns [`Error::Backend`] if rubato construction fails (avoids a panic silently stopping
483    /// the thread).
484    fn new(in_sr: u32, out_sr: u32, channels: usize) -> Result<Self> {
485        let ratio = out_sr as f64 / in_sr as f64;
486        // Fixed input chunk of about 20ms worth of input frames (rubato retains any remainder internally).
487        let chunk_in_frames = (in_sr as usize / 50).max(64);
488
489        let params = SincInterpolationParameters {
490            sinc_len: 128,
491            f_cutoff: 0.95,
492            interpolation: SincInterpolationType::Linear,
493            oversampling_factor: 128,
494            window: WindowFunction::BlackmanHarris2,
495        };
496
497        let inner = Async::<f32>::new_sinc(
498            ratio,
499            1.0, // Fixed ratio (no variable resampling needed)
500            &params,
501            chunk_in_frames,
502            channels,
503            FixedAsync::Input,
504        )
505        .map_err(|e| Error::Backend(format!("rubato sinc resampler construction failed: {e}")))?;
506
507        let max_out_frames = inner.output_frames_max();
508
509        Ok(Self {
510            inner,
511            channels,
512            chunk_in_frames,
513            max_out_frames,
514            in_accum: Vec::with_capacity(chunk_in_frames * channels * 4),
515            out_scratch: vec![0.0; max_out_frames * channels],
516        })
517    }
518
519    /// Resample as much of `in_accum` as possible in `chunk_in_frames` units and append the
520    /// resulting interleaved samples to `out_buf`. Returns the number of output frames generated.
521    ///
522    /// Returns [`Error::Backend`] if constructing a rubato adapter or calling `process_into_buffer`
523    /// fails (avoids a panic silently stopping the capture thread).
524    fn drain_into(&mut self, out_buf: &mut Vec<f32>) -> Result<u64> {
525        let step = self.chunk_in_frames * self.channels;
526        let mut produced = 0u64;
527
528        while self.in_accum.len() >= step {
529            let in_adapter =
530                InterleavedSlice::new(&self.in_accum[..step], self.channels, self.chunk_in_frames)
531                    .map_err(|e| {
532                        Error::Backend(format!("rubato interleaved input adapter failed: {e}"))
533                    })?;
534
535            let mut out_adapter = InterleavedSlice::new_mut(
536                &mut self.out_scratch[..],
537                self.channels,
538                self.max_out_frames,
539            )
540            .map_err(|e| {
541                Error::Backend(format!("rubato interleaved output adapter failed: {e}"))
542            })?;
543
544            let indexing = Indexing {
545                input_offset: 0,
546                output_offset: 0,
547                partial_len: None,
548                active_channels_mask: None,
549            };
550
551            let (_in_used, out_written) = self
552                .inner
553                .process_into_buffer(&in_adapter, &mut out_adapter, Some(&indexing))
554                .map_err(|e| Error::Backend(format!("rubato process_into_buffer failed: {e}")))?;
555
556            let n_samples = out_written * self.channels;
557            out_buf.extend_from_slice(&self.out_scratch[..n_samples]);
558            produced += out_written as u64;
559
560            // Remove consumed input (`FixedAsync::Input` always consumes `chunk_in_frames`).
561            self.in_accum.drain(..step);
562        }
563        Ok(produced)
564    }
565
566    /// On stop, process the less-than-one-chunk remainder in `in_accum` using `partial_len`, append
567    /// the resulting interleaved samples to `out_buf`, and return the number of output frames.
568    ///
569    /// This drains the remaining input (just under 20ms at most), leaving `in_accum` empty. It does
570    /// not flush the resampler's internal filter group delay (a few ms).
571    fn flush_into(&mut self, out_buf: &mut Vec<f32>) -> Result<u64> {
572        let remaining = self.in_accum.len() / self.channels;
573        if remaining == 0 {
574            return Ok(0);
575        }
576        // Pad input with silence to one full chunk and pass only the valid length via `partial_len`.
577        self.in_accum
578            .resize(self.chunk_in_frames * self.channels, 0.0);
579
580        let in_adapter = InterleavedSlice::new(
581            &self.in_accum[..self.chunk_in_frames * self.channels],
582            self.channels,
583            self.chunk_in_frames,
584        )
585        .map_err(|e| Error::Backend(format!("rubato interleaved input adapter failed: {e}")))?;
586
587        let mut out_adapter = InterleavedSlice::new_mut(
588            &mut self.out_scratch[..],
589            self.channels,
590            self.max_out_frames,
591        )
592        .map_err(|e| Error::Backend(format!("rubato interleaved output adapter failed: {e}")))?;
593
594        let indexing = Indexing {
595            input_offset: 0,
596            output_offset: 0,
597            partial_len: Some(remaining),
598            active_channels_mask: None,
599        };
600
601        let (_in_used, out_written) = self
602            .inner
603            .process_into_buffer(&in_adapter, &mut out_adapter, Some(&indexing))
604            .map_err(|e| Error::Backend(format!("rubato flush process_into_buffer failed: {e}")))?;
605
606        let n_samples = out_written * self.channels;
607        out_buf.extend_from_slice(&self.out_scratch[..n_samples]);
608        self.in_accum.clear();
609        Ok(out_written as u64)
610    }
611}
612
613impl OutputStage {
614    /// Create the output stage for the output sample rate and channel count.
615    ///
616    /// If `out_sample_rate == 48000`, sample-rate conversion is bypassed (only channel conversion
617    /// is performed). rubato construction failures propagate as [`Error::Backend`].
618    fn new(out_sample_rate: u32, out_channels: usize) -> Result<Self> {
619        let resampler = if out_sample_rate == SAMPLE_RATE {
620            None
621        } else {
622            // Convert from the 48000-Hz internal canonical form to `out_sample_rate` with `out_channels` channels.
623            Some(ResamplerState::new(
624                SAMPLE_RATE,
625                out_sample_rate,
626                out_channels,
627            )?)
628        };
629        Ok(Self {
630            out_channels,
631            resampler,
632            ch_scratch: Vec::with_capacity(CHUNK_FRAMES * out_channels),
633        })
634    }
635
636    /// Process the internal canonical form (48k/stereo interleaved, any length) and append
637    /// interleaved samples in the output format to `out_buf`. Length must be a multiple of
638    /// `INNER_CH` (stereo).
639    fn process_inner(&mut self, inner_stereo: &[f32], out_buf: &mut Vec<f32>) -> Result<()> {
640        let frames = inner_stereo.len() / INNER_CH;
641        if frames == 0 {
642            return Ok(());
643        }
644
645        // Channel conversion: stereo → out_channels.
646        self.ch_scratch.clear();
647        match self.out_channels {
648            1 => {
649                // stereo → mono (L/R average).
650                for f in 0..frames {
651                    let l = inner_stereo[f * 2];
652                    let r = inner_stereo[f * 2 + 1];
653                    self.ch_scratch.push((l + r) * 0.5);
654                }
655            }
656            2 => {
657                self.ch_scratch
658                    .extend_from_slice(&inner_stereo[..frames * 2]);
659            }
660            _ => {
661                // Validation should limit this to 1 or 2. If another count reaches here, duplicate L as a fallback.
662                for f in 0..frames {
663                    let l = inner_stereo[f * 2];
664                    for _ in 0..self.out_channels {
665                        self.ch_scratch.push(l);
666                    }
667                }
668            }
669        }
670
671        // Sample-rate conversion: 48000 → out_sample_rate. On passthrough, copy ch_scratch directly to output.
672        match &mut self.resampler {
673            None => {
674                out_buf.extend_from_slice(&self.ch_scratch);
675            }
676            Some(rs) => {
677                rs.in_accum.extend_from_slice(&self.ch_scratch);
678                rs.drain_into(out_buf)?;
679            }
680        }
681        Ok(())
682    }
683
684    /// On stop, drain any sample-rate resampler remainder into `out_buf` (a passthrough stage has
685    /// no remainder, so this is a no-op).
686    fn flush_into(&mut self, out_buf: &mut Vec<f32>) -> Result<()> {
687        if let Some(rs) = self.resampler.as_mut() {
688            rs.flush_into(out_buf)?;
689        }
690        Ok(())
691    }
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697    use std::f32::consts::PI;
698
699    /// Helper for the default output ({48000, 2}).
700    fn default_out() -> OutputFormat {
701        OutputFormat::default()
702    }
703
704    #[test]
705    fn mono_48k_to_stereo_duplicates_channels() {
706        let mut n = Normalizer::new(48_000, 1, default_out()).expect("normalizer");
707        assert!(n.is_passthrough());
708        assert!(n.is_output_passthrough());
709        // 960 frames of mono input (exactly one chunk because of passthrough).
710        let mono: Vec<f32> = (0..CHUNK_FRAMES).map(|i| (i as f32) * 0.001).collect();
711        n.push(&mono, 0).expect("push");
712        let (chunk, _pts) = n.pop_chunk().expect("one chunk");
713        assert_eq!(chunk.len(), CHUNK_FRAMES * 2);
714        // L == R holds for every frame.
715        for f in 0..CHUNK_FRAMES {
716            assert_eq!(chunk[f * 2], chunk[f * 2 + 1], "L==R at frame {f}");
717            assert_eq!(chunk[f * 2], mono[f]);
718        }
719    }
720
721    #[test]
722    fn passthrough_preserves_frame_count() {
723        let mut n = Normalizer::new(48_000, 2, default_out()).expect("normalizer");
724        assert!(n.is_passthrough());
725        assert!(n.is_output_passthrough());
726        // Two chunks plus a remainder.
727        let frames = CHUNK_FRAMES * 2 + 100;
728        let stereo: Vec<f32> = (0..frames * 2).map(|i| (i as f32) * 1e-4).collect();
729        n.push(&stereo, 0).expect("push");
730
731        let mut got_frames = 0usize;
732        while let Some((c, _)) = n.pop_chunk() {
733            assert_eq!(c.len(), CHUNK_FRAMES * 2);
734            got_frames += CHUNK_FRAMES;
735        }
736        // Exactly two chunks can be retrieved; 100 remainder frames are left.
737        assert_eq!(got_frames, CHUNK_FRAMES * 2);
738        assert_eq!(n.buffered_out_frames(), 100);
739    }
740
741    #[test]
742    fn stereo_44100_to_48000_yields_about_50_chunks_per_second() {
743        let mut n = Normalizer::new(44_100, 2, default_out()).expect("normalizer");
744        assert!(!n.is_passthrough());
745
746        // One second of 44100Hz stereo sine wave.
747        let in_frames = 44_100;
748        let freq = 440.0_f32;
749        let mut interleaved = Vec::with_capacity(in_frames * 2);
750        for i in 0..in_frames {
751            let s = (2.0 * PI * freq * (i as f32) / 44_100.0).sin() * 0.5;
752            interleaved.push(s); // L
753            interleaved.push(s); // R
754        }
755
756        // Must not panic even with small, separate pushes (simulating small buffers from real hardware).
757        let mut pts = 0i64;
758        for block in interleaved.chunks(441 * 2) {
759            n.push(block, pts).expect("push");
760            pts += (block.len() as i64 / 2) * 1_000_000_000 / 44_100;
761        }
762
763        let mut chunks = 0usize;
764        while let Some((c, _pts)) = n.pop_chunk() {
765            assert_eq!(c.len(), CHUNK_FRAMES * 2);
766            chunks += 1;
767        }
768        assert!(
769            (47..=50).contains(&chunks),
770            "expected ~50 chunks, got {chunks}"
771        );
772    }
773
774    #[test]
775    fn pts_increases_monotonically_across_chunks() {
776        let mut n = Normalizer::new(48_000, 2, default_out()).expect("normalizer");
777        let frames = CHUNK_FRAMES * 3;
778        let stereo = vec![0.0f32; frames * 2];
779        n.push(&stereo, 100_000_000).expect("push");
780
781        let mut last = i64::MIN;
782        let mut count = 0;
783        while let Some((_, pts)) = n.pop_chunk() {
784            assert!(pts >= last, "pts must be non-decreasing");
785            last = pts;
786            count += 1;
787        }
788        assert_eq!(count, 3);
789    }
790
791    // --- Stage 2 (output) tests ---
792
793    /// 48k/stereo input + {16000, 1} output → 320-frame mono chunks.
794    #[test]
795    fn output_16k_mono_yields_320_frame_mono_chunks() {
796        let out = OutputFormat {
797            sample_rate: 16_000,
798            channels: 1,
799        };
800        let mut n = Normalizer::new(48_000, 2, out).expect("normalizer");
801        assert!(n.is_passthrough()); // Stage 1 sample-rate passthrough (48k input).
802        assert!(!n.is_output_passthrough()); // Stage 2 is active.
803
804        // One second of 48k stereo sine wave (pushed in small blocks).
805        let in_frames = 48_000;
806        let freq = 440.0_f32;
807        let mut pts = 0i64;
808        for blk in 0..(in_frames / 480) {
809            let mut block = Vec::with_capacity(480 * 2);
810            for j in 0..480 {
811                let i = blk * 480 + j;
812                let s = (2.0 * PI * freq * (i as f32) / 48_000.0).sin() * 0.5;
813                block.push(s);
814                block.push(s);
815            }
816            n.push(&block, pts).expect("push");
817            pts += 480 * 1_000_000_000 / 48_000;
818        }
819
820        let mut chunks = 0usize;
821        while let Some((c, _)) = n.pop_chunk() {
822            assert_eq!(c.len(), 320, "16k mono 20ms = 320 sample (mono)");
823            chunks += 1;
824        }
825        // 16000/320 = 50 chunks/second; approximately 50 after resampler latency.
826        assert!(
827            (47..=50).contains(&chunks),
828            "expected ~50 chunks, got {chunks}"
829        );
830    }
831
832    /// Output {16000, 2} → 320 frames / 640 samples (stereo).
833    #[test]
834    fn output_16k_stereo_yields_320_frame_640_sample_chunks() {
835        let out = OutputFormat {
836            sample_rate: 16_000,
837            channels: 2,
838        };
839        let mut n = Normalizer::new(48_000, 2, out).expect("normalizer");
840        let in_frames = 48_000;
841        let stereo: Vec<f32> = (0..in_frames * 2)
842            .map(|i| ((i / 2) as f32 * 0.0001).sin() * 0.3)
843            .collect();
844        for block in stereo.chunks(480 * 2) {
845            n.push(block, 0).expect("push");
846        }
847        let mut chunks = 0usize;
848        while let Some((c, _)) = n.pop_chunk() {
849            assert_eq!(c.len(), 640, "16k stereo 20ms = 320 frame * 2 = 640 sample");
850            chunks += 1;
851        }
852        assert!(
853            (47..=50).contains(&chunks),
854            "expected ~50 chunks, got {chunks}"
855        );
856    }
857
858    /// Output {8000, 2} → 160 frames / 320 samples.
859    #[test]
860    fn output_8k_stereo_yields_160_frame_chunks() {
861        let out = OutputFormat {
862            sample_rate: 8_000,
863            channels: 2,
864        };
865        let mut n = Normalizer::new(48_000, 2, out).expect("normalizer");
866        let stereo: Vec<f32> = (0..48_000 * 2)
867            .map(|i| (i as f32 * 1e-5).sin() * 0.2)
868            .collect();
869        for block in stereo.chunks(480 * 2) {
870            n.push(block, 0).expect("push");
871        }
872        let mut chunks = 0usize;
873        while let Some((c, _)) = n.pop_chunk() {
874            assert_eq!(c.len(), 320, "8k stereo 20ms = 160 frame * 2 = 320 sample");
875            chunks += 1;
876        }
877        assert!(
878            (47..=50).contains(&chunks),
879            "expected ~50 chunks, got {chunks}"
880        );
881    }
882
883    /// stereo→mono averages L/R (opposite phase, L=+a and R=-a, approaches 0).
884    #[test]
885    fn stereo_to_mono_is_lr_average() {
886        // Use 48000/mono output to test sample-rate passthrough and channel conversion only.
887        let out = OutputFormat {
888            sample_rate: 48_000,
889            channels: 1,
890        };
891        let mut n = Normalizer::new(48_000, 2, out).expect("normalizer");
892        // Perfectly opposite phase (L=+0.5, R=-0.5) → average 0.
893        let mut stereo = Vec::with_capacity(CHUNK_FRAMES * 2);
894        for _ in 0..CHUNK_FRAMES {
895            stereo.push(0.5);
896            stereo.push(-0.5);
897        }
898        n.push(&stereo, 0).expect("push");
899        let (chunk, _) = n.pop_chunk().expect("one mono chunk");
900        assert_eq!(chunk.len(), CHUNK_FRAMES); // 960 mono samples.
901        for &s in &chunk {
902            assert!(
903                s.abs() < 1e-6,
904                "opposite-phase average should be near 0: {s}"
905            );
906        }
907    }
908
909    // --- Value-check helpers (verify amplitude and frequency preservation) ---
910
911    /// RMS of a sample sequence (linear). For a sine wave, this is A/√2 for amplitude A.
912    fn rms(samples: &[f32]) -> f32 {
913        if samples.is_empty() {
914            return 0.0;
915        }
916        let sum_sq: f64 = samples.iter().map(|&x| (x as f64) * (x as f64)).sum();
917        (sum_sq / samples.len() as f64).sqrt() as f32
918    }
919
920    /// Count zero crossings from positive to negative or negative to positive. There are two
921    /// crossings per cycle, so estimated frequency = (crossings / 2) / seconds. Pass the middle
922    /// section to avoid transients at the start and end.
923    fn zero_crossings(samples: &[f32]) -> usize {
924        let mut crossings = 0;
925        for w in samples.windows(2) {
926            // Count strict sign changes only (ignore exact zero).
927            if (w[0] < 0.0 && w[1] >= 0.0) || (w[0] >= 0.0 && w[1] < 0.0) {
928                crossings += 1;
929            }
930        }
931        crossings
932    }
933
934    /// Resampling a 44.1kHz/mono 440Hz sine wave to 48kHz/stereo preserves its amplitude (RMS)
935    /// and frequency (estimated by zero crossings). Process one second and measure only the
936    /// middle chunks to avoid resampler ringing.
937    #[test]
938    fn resample_44100_to_48000_preserves_amplitude_and_frequency() {
939        let mut n = Normalizer::new(44_100, 1, default_out()).expect("normalizer");
940        let freq = 440.0_f32;
941        let amp = 0.5_f32;
942        let in_rate = 44_100usize;
943        // Process two seconds to get enough chunks and leave room to discard transients.
944        let total_frames = in_rate * 2;
945        let mut pts = 0i64;
946        for blk in 0..(total_frames / 441) {
947            let mut block = Vec::with_capacity(441);
948            for j in 0..441 {
949                let i = blk * 441 + j;
950                block.push((2.0 * PI * freq * (i as f32) / in_rate as f32).sin() * amp);
951            }
952            n.push(&block, pts).expect("push");
953            pts += 441 * 1_000_000_000 / in_rate as i64;
954        }
955
956        // Concatenate all chunks (48k/stereo/960 frames output).
957        let mut left: Vec<f32> = Vec::new();
958        while let Some((c, _)) = n.pop_chunk() {
959            assert_eq!(c.len(), CHUNK_FRAMES * 2);
960            // Keep only the L channel (mono→stereo duplication means L==R).
961            for f in 0..CHUNK_FRAMES {
962                assert_eq!(c[f * 2], c[f * 2 + 1], "L==R for mono input");
963                left.push(c[f * 2]);
964            }
965        }
966        assert!(
967            left.len() >= 48_000,
968            "need at least 1 second of output: {}",
969            left.len()
970        );
971
972        // Discard 0.25 seconds (12000 samples) of transients at each end and measure the middle second.
973        let start = 12_000;
974        let mid = &left[start..start + 48_000];
975
976        // Amplitude: sine-wave RMS is amp/√2 ≈ 0.3536. Allow ±5% through the resampler.
977        let got_rms = rms(mid);
978        let expect_rms = amp / std::f32::consts::SQRT_2;
979        let rms_err = ((got_rms - expect_rms) / expect_rms).abs();
980        assert!(
981            rms_err < 0.05,
982            "RMS preservation error too large: got={got_rms} expect={expect_rms} err={rms_err}"
983        );
984
985        // Frequency: expect ≈ 2*440 = 880 crossings in the middle second (48000 samples), within ±2%.
986        let crossings = zero_crossings(mid);
987        let est_freq = crossings as f32 / 2.0; // One second, so crossings/2 = Hz.
988        let freq_err = ((est_freq - freq) / freq).abs();
989        assert!(
990            freq_err < 0.02,
991            "Frequency preservation error too large: crossings={crossings} estimate={est_freq}Hz err={freq_err}"
992        );
993    }
994
995    /// Verify the channel count and sample values of 16k/mono output: converting 48k/stereo
996    /// 440Hz input to 16k/mono preserves amplitude and frequency in 1-channel, 320-sample chunks.
997    #[test]
998    fn output_16k_mono_preserves_values() {
999        let out = OutputFormat {
1000            sample_rate: 16_000,
1001            channels: 1,
1002        };
1003        let mut n = Normalizer::new(48_000, 2, out).expect("normalizer");
1004        let freq = 440.0_f32;
1005        let amp = 0.5_f32;
1006        let in_rate = 48_000usize;
1007        let total_frames = in_rate * 2;
1008        let mut pts = 0i64;
1009        for blk in 0..(total_frames / 480) {
1010            let mut block = Vec::with_capacity(480 * 2);
1011            for j in 0..480 {
1012                let i = blk * 480 + j;
1013                let s = (2.0 * PI * freq * (i as f32) / in_rate as f32).sin() * amp;
1014                block.push(s); // L
1015                block.push(s); // R
1016            }
1017            n.push(&block, pts).expect("push");
1018            pts += 480 * 1_000_000_000 / in_rate as i64;
1019        }
1020
1021        let mut mono: Vec<f32> = Vec::new();
1022        while let Some((c, _)) = n.pop_chunk() {
1023            assert_eq!(c.len(), 320, "16k/mono 20ms = 320 samples (1ch)");
1024            mono.extend_from_slice(&c);
1025        }
1026        assert!(
1027            mono.len() >= 16_000,
1028            "need at least 1 second: {}",
1029            mono.len()
1030        );
1031
1032        // Discard transients and measure the middle second (16000 samples).
1033        let start = 4_000;
1034        let mid = &mono[start..start + 16_000];
1035
1036        // Averaging in-phase L==R leaves the level unchanged → RMS ≈ amp/√2.
1037        let got_rms = rms(mid);
1038        let expect_rms = amp / std::f32::consts::SQRT_2;
1039        let rms_err = ((got_rms - expect_rms) / expect_rms).abs();
1040        assert!(
1041            rms_err < 0.05,
1042            "16k/mono RMS preservation error: got={got_rms} expect={expect_rms} err={rms_err}"
1043        );
1044
1045        // Frequency: expect ≈ 880 crossings in the middle second (16000 samples), within ±2%.
1046        let est_freq = zero_crossings(mid) as f32 / 2.0;
1047        let freq_err = ((est_freq - freq) / freq).abs();
1048        assert!(
1049            freq_err < 0.02,
1050            "16k/mono frequency preservation error: estimate={est_freq}Hz err={freq_err}"
1051        );
1052    }
1053
1054    /// PTS increases monotonically, and the delta between adjacent chunks is about 20ms
1055    /// (1e7 ns, within tolerance). Check measured values to verify PTS anchor extrapolation on
1056    /// the 48k passthrough path.
1057    #[test]
1058    fn pts_delta_is_about_20ms_between_chunks() {
1059        let mut n = Normalizer::new(48_000, 2, default_out()).expect("normalizer");
1060        // Push 480 frames (10ms) at a time with PTS (simulating small buffers from real hardware).
1061        let mut device_pts = 1_000_000_000i64; // Arbitrary origin.
1062        let block_frames = 480usize;
1063        for _ in 0..20 {
1064            let stereo = vec![0.1f32; block_frames * 2];
1065            n.push(&stereo, device_pts).expect("push");
1066            device_pts += block_frames as i64 * 1_000_000_000 / 48_000;
1067        }
1068
1069        let mut pts_list = Vec::new();
1070        while let Some((_, pts)) = n.pop_chunk() {
1071            pts_list.push(pts);
1072        }
1073        assert!(
1074            pts_list.len() >= 5,
1075            "need enough chunks: {}",
1076            pts_list.len()
1077        );
1078
1079        // 20ms = 20_000_000 ns. Allow ±5% (1e6 ns).
1080        for w in pts_list.windows(2) {
1081            let delta = w[1] - w[0];
1082            assert!(
1083                delta > 0,
1084                "PTS must increase strictly: {} -> {}",
1085                w[0],
1086                w[1]
1087            );
1088            assert!(
1089                (delta - 20_000_000).abs() <= 1_000_000,
1090                "adjacent PTS delta is not about 20ms: {delta} ns"
1091            );
1092        }
1093    }
1094
1095    /// Empty or partial input (less than a multiple of `in_channels`) returns `Ok` without
1096    /// panicking and produces no chunks (boundary defense).
1097    #[test]
1098    fn push_empty_and_subframe_are_noops() {
1099        let mut n = Normalizer::new(48_000, 2, default_out()).expect("normalizer");
1100        // Empty input.
1101        n.push(&[], 0).expect("empty push ok");
1102        // Only one sample for stereo (2ch) → `in_frames=0`, so return early.
1103        n.push(&[0.5], 0).expect("subframe push ok");
1104        assert!(
1105            n.pop_chunk().is_none(),
1106            "partial input alone must not produce a chunk"
1107        );
1108        assert_eq!(n.buffered_out_frames(), 0);
1109    }
1110
1111    /// Zero-frequency (silent DC) input produces all-zero output (verifies the peak/RMS-zero path).
1112    #[test]
1113    fn silence_input_yields_zero_output() {
1114        let mut n = Normalizer::new(48_000, 2, default_out()).expect("normalizer");
1115        let stereo = vec![0.0f32; CHUNK_FRAMES * 2];
1116        n.push(&stereo, 0).expect("push");
1117        let (chunk, _) = n.pop_chunk().expect("one chunk");
1118        assert!(
1119            chunk.iter().all(|&s| s == 0.0),
1120            "silent input must produce silent output"
1121        );
1122    }
1123
1124    // --- Secondary tap (dual output) tests ---
1125
1126    /// Without a secondary tap, `pop_secondary` always returns `None` and `has_secondary` is false.
1127    #[test]
1128    fn no_secondary_tap_by_default() {
1129        let mut n = Normalizer::new(48_000, 2, default_out()).expect("normalizer");
1130        assert!(!n.has_secondary());
1131        assert_eq!(n.secondary_output(), None);
1132        let stereo = vec![0.1f32; CHUNK_FRAMES * 2];
1133        n.push(&stereo, 0).expect("push");
1134        assert!(n.pop_secondary().is_none(), "no secondary tap means None");
1135    }
1136
1137    /// Generate primary 48k/stereo and secondary 16k/mono from a single stage 1 pass. Primary
1138    /// emits 960-frame stereo chunks and secondary emits 320-frame mono chunks, both at about
1139    /// 50 chunks per second.
1140    #[test]
1141    fn dual_output_primary_and_secondary_shapes() {
1142        let secondary = OutputFormat {
1143            sample_rate: 16_000,
1144            channels: 1,
1145        };
1146        let mut n = Normalizer::new(48_000, 2, default_out())
1147            .expect("normalizer")
1148            .with_secondary(secondary)
1149            .expect("secondary");
1150        assert!(n.has_secondary());
1151        assert_eq!(n.secondary_output(), Some(secondary));
1152
1153        // Push one second of 48k/stereo in 480-frame blocks.
1154        let mut pts = 0i64;
1155        for _ in 0..100 {
1156            let block = vec![0.2f32; 480 * 2];
1157            n.push(&block, pts).expect("push");
1158            pts += 480 * 1_000_000_000 / 48_000;
1159        }
1160
1161        let mut primary_chunks = 0usize;
1162        while let Some((c, _)) = n.pop_chunk() {
1163            assert_eq!(
1164                c.len(),
1165                CHUNK_FRAMES * 2,
1166                "primary is 48k/stereo = 1920 samples"
1167            );
1168            primary_chunks += 1;
1169        }
1170        let mut secondary_chunks = 0usize;
1171        while let Some((c, _)) = n.pop_secondary() {
1172            assert_eq!(c.len(), 320, "secondary is 16k/mono = 320 samples");
1173            secondary_chunks += 1;
1174        }
1175        assert!(
1176            (47..=50).contains(&primary_chunks),
1177            "primary ~50 chunks: {primary_chunks}"
1178        );
1179        assert!(
1180            (47..=50).contains(&secondary_chunks),
1181            "secondary ~50 chunks: {secondary_chunks}"
1182        );
1183    }
1184
1185    /// Both taps share the PTS axis from the same pushes and increase monotonically with 20ms
1186    /// between adjacent chunks (each tap has an independent PTS anchor).
1187    #[test]
1188    fn dual_output_taps_share_pts_axis() {
1189        let secondary = OutputFormat {
1190            sample_rate: 16_000,
1191            channels: 1,
1192        };
1193        let mut n = Normalizer::new(48_000, 2, default_out())
1194            .expect("normalizer")
1195            .with_secondary(secondary)
1196            .expect("secondary");
1197
1198        let mut device_pts = 1_000_000_000i64;
1199        for _ in 0..40 {
1200            let block = vec![0.1f32; 480 * 2];
1201            n.push(&block, device_pts).expect("push");
1202            device_pts += 480 * 1_000_000_000 / 48_000;
1203        }
1204
1205        let mut primary_pts = Vec::new();
1206        while let Some((_, p)) = n.pop_chunk() {
1207            primary_pts.push(p);
1208        }
1209        let mut secondary_pts = Vec::new();
1210        while let Some((_, p)) = n.pop_secondary() {
1211            secondary_pts.push(p);
1212        }
1213        assert!(primary_pts.len() >= 5 && secondary_pts.len() >= 5);
1214        for w in primary_pts.windows(2) {
1215            assert!((w[1] - w[0] - 20_000_000).abs() <= 1_000_000);
1216        }
1217        for w in secondary_pts.windows(2) {
1218            assert!((w[1] - w[0] - 20_000_000).abs() <= 1_000_000);
1219        }
1220        // The first PTS for both taps starts near the same push origin (within tens of ms).
1221        assert!(
1222            (primary_pts[0] - secondary_pts[0]).abs() < 100_000_000,
1223            "primary and secondary start PTS should be close: {} vs {}",
1224            primary_pts[0],
1225            secondary_pts[0]
1226        );
1227    }
1228
1229    // --- InnerProcessor (denoise hook equivalent) and stop flush ---
1230
1231    /// Test processor: doubles every sample (has no trailing tail).
1232    struct DoubleProcessor;
1233    impl InnerProcessor for DoubleProcessor {
1234        fn process(&mut self, s: &mut [f32]) {
1235            for x in s.iter_mut() {
1236                *x *= 2.0;
1237            }
1238        }
1239        fn flush(&mut self) -> Vec<f32> {
1240            Vec::new()
1241        }
1242    }
1243
1244    /// Test processor: fixed delay line holding `hold` samples (simulates a denoise delay line).
1245    /// Output is delayed by `hold` samples (the first `hold` are silent). Returns the final
1246    /// `hold` samples from `flush`.
1247    struct DelayProcessor {
1248        held: Vec<f32>,
1249    }
1250    impl DelayProcessor {
1251        fn new(hold: usize) -> Self {
1252            Self {
1253                held: vec![0.0; hold],
1254            }
1255        }
1256    }
1257    impl InnerProcessor for DelayProcessor {
1258        fn process(&mut self, s: &mut [f32]) {
1259            self.held.extend_from_slice(s);
1260            let n = s.len();
1261            s.copy_from_slice(&self.held[..n]);
1262            self.held.drain(..n);
1263        }
1264        fn flush(&mut self) -> Vec<f32> {
1265            std::mem::take(&mut self.held)
1266        }
1267    }
1268
1269    /// InnerProcessor is applied to the internal canonical form before the stage 2 split (output
1270    /// doubles directly on the primary 48k/stereo passthrough path).
1271    #[test]
1272    fn inner_processor_applies_before_stage2() {
1273        let mut n = Normalizer::new(48_000, 2, default_out())
1274            .expect("normalizer")
1275            .with_inner_processor(Box::new(DoubleProcessor));
1276        let stereo: Vec<f32> = (0..CHUNK_FRAMES * 2).map(|i| (i as f32) * 1e-4).collect();
1277        n.push(&stereo, 0).expect("push");
1278        let (chunk, _) = n.pop_chunk().expect("one chunk");
1279        for (i, &s) in chunk.iter().enumerate() {
1280            assert!(
1281                (s - stereo[i] * 2.0).abs() < 1e-6,
1282                "sample {i} should be doubled"
1283            );
1284        }
1285    }
1286
1287    /// InnerProcessor affects both primary and secondary taps (secondary 16k/mono is also doubled).
1288    #[test]
1289    fn inner_processor_affects_both_taps() {
1290        let secondary = OutputFormat {
1291            sample_rate: 48_000,
1292            channels: 2,
1293        };
1294        // Set secondary to 48k/stereo (passthrough) so the doubling can be observed directly.
1295        let mut n = Normalizer::new(48_000, 2, default_out())
1296            .expect("normalizer")
1297            .with_secondary(secondary)
1298            .expect("secondary")
1299            .with_inner_processor(Box::new(DoubleProcessor));
1300        let stereo = vec![0.25f32; CHUNK_FRAMES * 2];
1301        n.push(&stereo, 0).expect("push");
1302        let (p, _) = n.pop_chunk().expect("primary chunk");
1303        let (s, _) = n.pop_secondary().expect("secondary chunk");
1304        assert!(
1305            p.iter().all(|&x| (x - 0.5).abs() < 1e-6),
1306            "primary is doubled"
1307        );
1308        assert!(
1309            s.iter().all(|&x| (x - 0.5).abs() < 1e-6),
1310            "secondary is doubled"
1311        );
1312    }
1313
1314    /// Stop flush feeds the processor's trailing tail so it can be retrieved as the final chunk.
1315    /// The held portion of the delay-line processor appears in an additional chunk after flush.
1316    #[test]
1317    fn stop_flush_emits_processor_tail() {
1318        // Delay line holding 4 samples (2 stereo frames).
1319        let mut n = Normalizer::new(48_000, 2, default_out())
1320            .expect("normalizer")
1321            .with_inner_processor(Box::new(DelayProcessor::new(4)));
1322        // Push one chunk of identifiable, non-zero input.
1323        let stereo: Vec<f32> = (0..CHUNK_FRAMES * 2)
1324            .map(|i| (i as f32 + 1.0) * 1e-4)
1325            .collect();
1326        n.push(&stereo, 0).expect("push");
1327
1328        // Before flush: one chunk (the first 4 samples are silent due to the delay).
1329        let (c0, _) = n.pop_chunk().expect("first chunk");
1330        assert_eq!(c0.len(), CHUNK_FRAMES * 2);
1331        assert!(
1332            c0[..4].iter().all(|&x| x == 0.0),
1333            "first 4 samples should be silent due to the delay"
1334        );
1335        assert!(
1336            n.pop_chunk().is_none(),
1337            "only one chunk should be available before flush"
1338        );
1339
1340        // Flush emits the delay line's final 4 samples in an additional chunk (padded with silence).
1341        n.flush();
1342        let (c1, _) = n.pop_chunk().expect("flushed tail chunk");
1343        assert_eq!(c1.len(), CHUNK_FRAMES * 2, "final chunk is padded to 20ms");
1344        // The final 4 samples are the last 4 input samples.
1345        let last4 = &stereo[stereo.len() - 4..];
1346        for (i, &x) in c1[..4].iter().enumerate() {
1347            assert!(
1348                (x - last4[i]).abs() < 1e-6,
1349                "flush tail should match the end of the input"
1350            );
1351        }
1352    }
1353
1354    /// Stop flush also drains the secondary tap's stage 2 resampler remainder (the tail reaches
1355    /// 16k/mono output too).
1356    #[test]
1357    fn stop_flush_drains_secondary_resampler() {
1358        let secondary = OutputFormat {
1359            sample_rate: 16_000,
1360            channels: 1,
1361        };
1362        let mut n = Normalizer::new(48_000, 2, default_out())
1363            .expect("normalizer")
1364            .with_secondary(secondary)
1365            .expect("secondary");
1366        // Push an amount just under one chunk (leaving a remainder in the resampler).
1367        let stereo = vec![0.3f32; CHUNK_FRAMES * 2];
1368        n.push(&stereo, 0).expect("push");
1369
1370        // Count secondary chunks available before flush.
1371        let mut before = 0usize;
1372        while n.pop_secondary().is_some() {
1373            before += 1;
1374        }
1375        n.flush();
1376        // Flush drains the remainder, adding at least one final chunk.
1377        let mut after = 0usize;
1378        while let Some((c, _)) = n.pop_secondary() {
1379            assert_eq!(c.len(), 320, "secondary aligns to the fixed 20ms boundary");
1380            after += 1;
1381        }
1382        assert!(after >= 1, "flush should emit the secondary tap's tail");
1383        let _ = before;
1384    }
1385}