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 projected from the supplied timestamp using input-to-output sample offsets.
28//! The facade supplies ingest arrival times as `device_pts`, not device timestamps,
29//! and assigns `seq`.
30
31use rubato::audioadapter_buffers::direct::InterleavedSlice;
32use rubato::{
33 Async, FixedAsync, Indexing, Resampler, SincInterpolationParameters, SincInterpolationType,
34 WindowFunction,
35};
36
37use crate::types::{
38 ChunkFlags, Error, ErrorContext, ErrorGroup, Operation, OutputFormat, Result, CHANNELS,
39 SAMPLE_RATE,
40};
41
42/// A library-constructed chunk retaining padding provenance.
43#[derive(Debug, Clone, PartialEq)]
44#[non_exhaustive]
45pub struct NormalizedChunk {
46 /// Interleaved output PCM.
47 pub samples: Vec<f32>,
48 /// Presentation timestamp of the first frame.
49 pub pts_ns: i64,
50 /// Integrity flags; padding alone is not a discontinuity.
51 pub flags: ChunkFlags,
52}
53
54/// Frames per internal canonical-form chunk (20ms @ 48kHz). Stage 1 split boundary.
55pub const CHUNK_FRAMES: usize = 960;
56
57/// Channel count in the internal canonical form (stereo).
58const INNER_CH: usize = CHANNELS as usize; // 2
59
60/// A processor applied to the internal normalized form (48kHz / stereo /
61/// interleaved f32) *before* it is split into the output taps.
62///
63/// Kept behind a trait so `flexaudio-core` stays independent of any concrete
64/// DSP implementation (e.g. noise suppression). The facade injects an
65/// implementation; the core only sees this contract. Because the same
66/// processed samples feed every output tap, a single processor affects both the
67/// primary and secondary outputs.
68///
69/// [`process`](Self::process) runs on each batch of normalized samples in
70/// place. [`flush`](Self::flush) is called once at stop to recover any tail the
71/// processor is holding back (e.g. a fixed delay line); it returns the trailing
72/// 48kHz / stereo interleaved samples (empty if none).
73pub trait InnerProcessor: Send {
74 /// Process one batch of 48kHz / stereo interleaved samples in place. The
75 /// slice length is always a multiple of two (stereo).
76 fn process(&mut self, samples: &mut [f32]);
77 /// Flush and return any buffered tail (48kHz / stereo interleaved). Called
78 /// once when the stream stops. Returns an empty vector when nothing is held.
79 fn flush(&mut self) -> Vec<f32>;
80}
81
82/// Stateful two-stage converter that normalizes input device frames to the internal canonical
83/// form (48k/stereo/960 frames), then converts them to one or more output taps (primary and
84/// optional secondary).
85///
86/// Stage 1 (internal normalization) runs once and feeds the resulting canonical form to stage 2
87/// of both taps. An optional [`InnerProcessor`] is applied once to the canonical form before it
88/// is split for stage 2, so both taps receive the same processed audio.
89///
90/// Accumulate interleaved samples with `push`, then retrieve completed output chunks one at a
91/// time with `pop_chunk` (primary) and `pop_secondary` (secondary).
92pub struct Normalizer {
93 flush_result: Option<Result<()>>,
94 in_sample_rate: u32,
95 in_channels: usize,
96 /// Interleaved input awaiting a complete native frame.
97 pending_input: Vec<f32>,
98 /// Timestamp of the first sample retained in `pending_input`.
99 pending_input_pts: Option<i64>,
100
101 // --- Stage 1 (internal normalization: → 48k/stereo, shared by all output taps) ---
102 /// Passthrough when the input is 48000 Hz (no resampler).
103 stage1_resampler: Option<ResamplerState>,
104 /// Temporary buffer for the internal canonical form (48k/stereo interleaved) generated by
105 /// this push. Apply [`InnerProcessor`] before distributing it to each output tap. Reuse capacity.
106 inner_scratch: Vec<f32>,
107 /// Total internal 48k frames generated by stage 1 so far (for PTS anchor calculation).
108 total_inner_frames: u64,
109
110 /// Optional processor (e.g. noise suppression) applied once to the internal canonical form
111 /// before the stage 2 split.
112 inner_processor: Option<Box<dyn InnerProcessor>>,
113
114 // --- Output taps (each has its own stage 2, output buffer, and PTS state) ---
115 /// Primary output tap (its [`OutputFormat`] is `output`).
116 primary: OutputTap,
117 capture: Option<OutputTap>,
118 /// Secondary output tap (when configured). Has stage 2 and PTS state independent of primary.
119 secondary: Option<OutputTap>,
120}
121
122/// One output tap. Receives the shared internal canonical form (48k/stereo), converts channels
123/// and sample rate to its [`OutputFormat`], then emits fixed 20ms chunks. Each tap has its own
124/// PTS anchor and output buffer (which can cause tens of milliseconds of PTS offset between taps).
125struct OutputTap {
126 output: OutputFormat,
127 /// Output stage. `None` (full passthrough) when `output == {48000, 2}`, matching the canonical form.
128 stage2: Option<OutputStage>,
129 /// Output awaiting completion (interleaved with `output.channels`). `pop` drains from here.
130 out_buf: Vec<f32>,
131 /// Frames in one output chunk (`output.chunk_frames()`).
132 out_chunk_frames: usize,
133 /// Output channel count.
134 out_channels: usize,
135 /// Output frame index for the first sample in `out_buf` (the oldest sample not yet popped).
136 out_frame_origin: u64,
137 /// PTS anchor: associates an output frame index with `device_pts` (ns).
138 pts_anchor: Option<PtsAnchor>,
139 /// Exact integer projection for capture anchors; output taps retain their legacy projection.
140 canonical_clock: bool,
141 padding: std::collections::VecDeque<std::ops::Range<u64>>,
142}
143
144#[derive(Clone, Copy)]
145struct PtsAnchor {
146 /// Output frame index, based on the output sample rate.
147 out_frame: u64,
148 /// `device_pts` (ns) corresponding to that output frame.
149 pts_ns: i64,
150}
151
152/// Sample-rate converter wrapping rubato `Async` (`FixedAsync::Input`) for one stage.
153///
154/// Calls `process` for each fixed input chunk of `chunk_in_frames` frames and appends variable-length
155/// output to `out_buf` (the caller's accumulator). `channels` varies by stage (always stereo=2 in
156/// stage 1; the output channel count in stage 2).
157struct ResamplerState {
158 inner: Async<f32>,
159 channels: usize,
160 /// Input frames required per rubato call (fixed by `FixedAsync::Input`).
161 chunk_in_frames: usize,
162 /// Maximum output frames one `process` call can produce.
163 max_out_frames: usize,
164 /// Unprocessed input (interleaved, `channels` channels).
165 in_accum: Vec<f32>,
166 /// Output scratch buffer for rubato (reused to avoid allocations).
167 out_scratch: Vec<f32>,
168}
169
170/// Stage 2 (output). Receives 960-frame chunks in the internal 48k/stereo canonical form and
171/// converts channels and sample rate to produce interleaved output in the requested format.
172struct OutputStage {
173 out_channels: usize,
174 /// Resampler from 48000 to `output.sample_rate`. `None` (sample-rate passthrough) when
175 /// `output.sample_rate == 48000`. Applied to samples after channel conversion.
176 resampler: Option<ResamplerState>,
177 /// Scratch buffer after channel conversion and before sample-rate conversion (48k / `out_channels` interleaved).
178 ch_scratch: Vec<f32>,
179}
180
181impl Normalizer {
182 /// Create a normalizer for the input sample rate, input channel count, and output format.
183 ///
184 /// Stage 1 normalizes input to 48k/stereo (`in_sample_rate == 48000` bypasses sample-rate
185 /// conversion; `in_channels` of 1 duplicates mono to stereo and 2 is unchanged.
186 /// Input above two channels is rejected with UnsupportedFormat). Stage 2 converts the canonical form to `output` (passthrough
187 /// when `output == {48000, 2}`).
188 ///
189 /// Expects the caller to have run [`OutputFormat::validate`] on `output` (this method does not
190 /// clamp it to a valid range).
191 ///
192 /// rubato resampler construction can fail, for example with extreme rate ratios. Panicking
193 /// would silently stop the non-RT capture thread, so failures return [`Error::Backend`] and
194 /// propagate to the caller.
195 pub fn new(in_sample_rate: u32, in_channels: u16, output: OutputFormat) -> Result<Self> {
196 if in_sample_rate == 0 || in_channels == 0 {
197 return Err(Error::InvalidArg(
198 "native rate and channels must be positive".into(),
199 ));
200 }
201 if in_channels > 2 {
202 return Err(Error::UnsupportedFormat(
203 "native input supports only mono or stereo".into(),
204 ));
205 }
206 output.validate()?;
207 let in_channels = usize::from(in_channels);
208
209 // Stage 1 resampler (→48000). Runs once for all output taps.
210 let stage1_resampler = if in_sample_rate == SAMPLE_RATE {
211 None
212 } else {
213 Some(ResamplerState::new(in_sample_rate, SAMPLE_RATE, INNER_CH)?)
214 };
215
216 Ok(Self {
217 flush_result: None,
218 in_sample_rate,
219 in_channels,
220 pending_input: Vec::new(),
221 pending_input_pts: None,
222 stage1_resampler,
223 inner_scratch: Vec::with_capacity(CHUNK_FRAMES * INNER_CH * 4),
224 total_inner_frames: 0,
225 inner_processor: None,
226 primary: OutputTap::new(output)?,
227 capture: None,
228 secondary: None,
229 })
230 }
231
232 /// Add a secondary output tap (expects [`OutputFormat::validate`] to have been called).
233 ///
234 /// The internal canonical form (48k/stereo) is generated once and fed to stage 2 of both taps.
235 /// The secondary tap has independent stage 2 and PTS state, and emits its own fixed 20ms chunks.
236 /// rubato construction failures return [`Error::Backend`].
237 pub fn with_secondary(mut self, secondary: OutputFormat) -> Result<Self> {
238 self.secondary = Some(OutputTap::new(secondary)?);
239 Ok(self)
240 }
241
242 /// Enable a pre-output-conversion canonical branch for off-callback consumers.
243 /// Its stop tail contains valid frames only, never transport padding.
244 pub fn with_capture_tap(mut self) -> Result<Self> {
245 let mut capture = OutputTap::new(OutputFormat::default())?;
246 capture.canonical_clock = true;
247 self.capture = Some(capture);
248 Ok(self)
249 }
250
251 /// Canonical frames produced but not emitted, discarded when this generation is rebuilt.
252 pub fn buffered_capture_frames(&self) -> usize {
253 self.capture
254 .as_ref()
255 .map_or(0, OutputTap::buffered_out_frames)
256 }
257
258 /// Retrieve valid canonical stereo frames; allow a short final tail only at stop.
259 pub fn pop_capture(&mut self, stopping: bool) -> Option<(Vec<f32>, i64)> {
260 self.pop_capture_with_metadata(stopping)
261 .map(|chunk| (chunk.samples, chunk.pts_ns))
262 }
263
264 /// Retrieve valid canonical frames with no transport padding.
265 pub fn pop_capture_with_metadata(&mut self, stopping: bool) -> Option<NormalizedChunk> {
266 let tap = self.capture.as_mut()?;
267 let frames = tap.buffered_out_frames();
268 if frames == 0 || (frames < CHUNK_FRAMES && !stopping) {
269 return None;
270 }
271 let take = frames.min(CHUNK_FRAMES);
272 let pts = tap.pts_for_out_frame(tap.out_frame_origin);
273 let data = tap.out_buf.drain(..take * INNER_CH).collect();
274 tap.out_frame_origin += take as u64;
275 Some(NormalizedChunk {
276 samples: data,
277 pts_ns: pts,
278 flags: ChunkFlags::empty(),
279 })
280 }
281
282 /// Inject a processor to apply once to the internal canonical form before the stage 2 split.
283 pub fn with_inner_processor(mut self, processor: Box<dyn InnerProcessor>) -> Self {
284 self.inner_processor = Some(processor);
285 self
286 }
287
288 /// Input sample rate (Hz).
289 pub fn in_sample_rate(&self) -> u32 {
290 self.in_sample_rate
291 }
292
293 /// Primary output format.
294 pub fn output(&self) -> OutputFormat {
295 self.primary.output
296 }
297
298 /// Secondary output format (only when a secondary tap is configured).
299 pub fn secondary_output(&self) -> Option<OutputFormat> {
300 self.secondary.as_ref().map(|t| t.output)
301 }
302
303 /// Whether a secondary tap is enabled.
304 pub fn has_secondary(&self) -> bool {
305 self.secondary.is_some()
306 }
307
308 /// Whether stage 1 sample-rate conversion is bypassed (`in == 48000`).
309 pub fn is_passthrough(&self) -> bool {
310 self.stage1_resampler.is_none()
311 }
312
313 /// Whether stage 2 for the primary output is fully bypassed (`output == {48000, 2}`).
314 pub fn is_output_passthrough(&self) -> bool {
315 self.primary.stage2.is_none()
316 }
317
318 /// Accumulate interleaved input samples.
319 ///
320 /// Incomplete trailing frames are retained until a later push completes them.
321 /// `device_pts_ns` labels the first frame in this push; the facade supplies an ingest
322 /// arrival time, not a device timestamp.
323 ///
324 /// Returns [`Error::Backend`] if rubato's `process` fails. This avoids silently stopping the
325 /// capture thread with a panic and lets the caller stop the stream explicitly.
326 pub fn push(&mut self, interleaved: &[f32], device_pts_ns: i64) -> Result<()> {
327 if interleaved.is_empty() {
328 return Ok(());
329 }
330 if let Some(Err(error)) = &self.flush_result {
331 return Err(error.clone());
332 }
333 self.flush_result = None;
334 if self.pending_input.is_empty() {
335 self.pending_input_pts = Some(device_pts_ns);
336 }
337 self.pending_input.extend_from_slice(interleaved);
338 let in_frames = self.pending_input.len() / self.in_channels;
339 if in_frames == 0 {
340 return Ok(());
341 }
342 let device_pts_ns = self.pending_input_pts.unwrap_or(device_pts_ns);
343
344 // Approximate the future output frame position for the start of this push by the sample-rate
345 // ratio and update each tap's PTS anchor. This is approximate because the resampler retains
346 // a remainder internally. Set anchors independently for primary and secondary.
347 self.primary
348 .update_pts_anchor(self.total_inner_frames, device_pts_ns)?;
349 if let Some(sec) = self.secondary.as_mut() {
350 sec.update_pts_anchor(self.total_inner_frames, device_pts_ns)?;
351 }
352
353 if let Some(capture) = self.capture.as_mut() {
354 // One rational PTS anchor per source generation. Arrival-time jitter must
355 // not reanchor old samples or create a VAD epoch on every push.
356 if capture.pts_anchor.is_none() {
357 capture.update_pts_anchor(self.total_inner_frames, device_pts_ns)?;
358 }
359 }
360
361 // Stage 1: mix channels → stereo interleaved → normalize to 48k. Collect what this push
362 // generates in inner_scratch.
363 self.inner_scratch.clear();
364 let mut stereo = Vec::with_capacity(in_frames * INNER_CH);
365 Self::mix_to_stereo(
366 &self.pending_input,
367 self.in_channels,
368 in_frames,
369 &mut stereo,
370 );
371 self.pending_input.drain(..in_frames * self.in_channels);
372 self.pending_input_pts = if self.pending_input.is_empty() {
373 None
374 } else {
375 Some(device_pts_ns.saturating_add(
376 (in_frames as i64).saturating_mul(1_000_000_000) / self.in_sample_rate as i64,
377 ))
378 };
379 match &mut self.stage1_resampler {
380 None => {
381 // Sample-rate passthrough. Use directly as the internal canonical form.
382 self.total_inner_frames += in_frames as u64;
383 self.inner_scratch.extend_from_slice(&stereo);
384 }
385 Some(rs) => {
386 rs.in_accum.extend_from_slice(&stereo);
387 let produced = rs.drain_into(&mut self.inner_scratch)?;
388 self.total_inner_frames += produced;
389 }
390 }
391
392 // Apply a processor (e.g. denoise) once to the internal canonical form before the stage 2 split.
393 if let Some(proc) = self.inner_processor.as_mut() {
394 proc.process(&mut self.inner_scratch);
395 }
396
397 // Distribute the processed canonical form to stage 2 of both taps.
398 self.distribute_inner()
399 }
400
401 /// Retrieve one completed primary output chunk.
402 ///
403 /// Returns (`out_chunk_frames` interleaved frames at `output.channels`, PTS in ns for the
404 /// first sample). Returns `None` if a full chunk is not buffered.
405 pub fn pop_chunk(&mut self) -> Option<(Vec<f32>, i64)> {
406 self.pop_chunk_with_metadata()
407 .map(|chunk| (chunk.samples, chunk.pts_ns))
408 }
409
410 /// Retrieve one completed secondary output chunk (`None` if no secondary tap is configured).
411 pub fn pop_secondary(&mut self) -> Option<(Vec<f32>, i64)> {
412 self.pop_secondary_with_metadata()
413 .map(|chunk| (chunk.samples, chunk.pts_ns))
414 }
415
416 /// Retrieve primary PCM with its integrity flags.
417 pub fn pop_chunk_with_metadata(&mut self) -> Option<NormalizedChunk> {
418 self.primary.pop()
419 }
420
421 /// Retrieve secondary PCM with its integrity flags.
422 pub fn pop_secondary_with_metadata(&mut self) -> Option<NormalizedChunk> {
423 self.secondary.as_mut().and_then(OutputTap::pop)
424 }
425
426 /// Drain stage 1, processor tail and all output stages, retaining independent failures.
427 /// Repeated flush returns the same result without generating duplicate PCM.
428 /// Valid output remains drainable on cleanup-only failure.
429 pub fn flush(&mut self) -> Result<()> {
430 if let Some(result) = &self.flush_result {
431 return result.clone();
432 }
433 let mut errors = Vec::new();
434 if !self.pending_input.is_empty() {
435 errors.push(Error::Backend("incomplete native frame at flush".into()));
436 self.pending_input.clear();
437 self.pending_input_pts = None;
438 }
439 self.inner_scratch.clear();
440 if let Some(stage) = self.stage1_resampler.as_mut() {
441 match stage.flush_into(&mut self.inner_scratch) {
442 Ok(produced) => self.total_inner_frames += produced,
443 Err(error) => errors.push(error),
444 }
445 if let Some(processor) = self.inner_processor.as_mut() {
446 processor.process(&mut self.inner_scratch);
447 }
448 if let Err(error) = self.distribute_inner() {
449 errors.push(error);
450 }
451 }
452 if let Some(processor) = self.inner_processor.as_mut() {
453 self.inner_scratch = processor.flush();
454 // Keep the canonical capture epoch empty for a primed silent processor.
455 let empty_capture = if self.total_inner_frames == 0 {
456 self.capture.take()
457 } else {
458 None
459 };
460 if let Err(error) = self.distribute_inner() {
461 errors.push(error);
462 }
463 if let Some(capture) = empty_capture {
464 self.capture = Some(capture);
465 }
466 }
467 if let Err(error) = self.primary.flush() {
468 errors.push(error);
469 }
470 if let Some(secondary) = self.secondary.as_mut() {
471 if let Err(error) = secondary.flush() {
472 errors.push(error);
473 }
474 }
475 if let Some(capture) = self.capture.as_mut() {
476 // Preserve canonical valid-frame tails without transport padding.
477 if let Some(stage) = capture.stage2.as_mut() {
478 if let Err(error) = stage.flush_into(&mut capture.out_buf) {
479 errors.push(error);
480 }
481 }
482 }
483 let result = combine_failures(errors)
484 .map_err(|error| error.with_context(ErrorContext::new(Operation::Flush)));
485 self.flush_result = Some(result.clone());
486 result
487 }
488
489 /// Number of output frames buffered but not yet retrieved from `out_buf` (primary tap).
490 pub fn buffered_out_frames(&self) -> usize {
491 self.primary.buffered_out_frames()
492 }
493
494 // --- Internal helpers ---
495
496 /// Distribute the canonical form in `inner_scratch` to stage 2 of both taps. Temporarily take
497 /// the buffer to avoid borrow conflicts, then restore its capacity.
498 fn distribute_inner(&mut self) -> Result<()> {
499 if self.inner_scratch.is_empty() {
500 return Ok(());
501 }
502 let inner = std::mem::take(&mut self.inner_scratch);
503 let r_primary = self.primary.feed_inner(&inner);
504 let r_capture = self
505 .capture
506 .as_mut()
507 .map(|capture| capture.feed_inner(&inner))
508 .unwrap_or(Ok(()));
509 let r_secondary = self
510 .secondary
511 .as_mut()
512 .map(|sec| sec.feed_inner(&inner))
513 .unwrap_or(Ok(()));
514 // Restore the buffer so its capacity can be reused.
515 self.inner_scratch = inner;
516 self.inner_scratch.clear();
517 combine_failures(
518 [r_primary, r_capture, r_secondary]
519 .into_iter()
520 .filter_map(Result::err)
521 .collect(),
522 )
523 }
524
525 /// Convert validated mono/stereo input to stereo interleaved and push it to `dst`.
526 fn mix_to_stereo(src: &[f32], in_ch: usize, in_frames: usize, dst: &mut Vec<f32>) {
527 match in_ch {
528 1 => {
529 // mono → stereo (duplicate L=R)
530 for &s in &src[..in_frames] {
531 dst.push(s);
532 dst.push(s);
533 }
534 }
535 2 => {
536 // Keep 2 channels unchanged (only the required samples).
537 dst.extend_from_slice(&src[..in_frames * 2]);
538 }
539 _ => unreachable!("native channel count validated by constructor"),
540 }
541 }
542}
543
544#[cfg(test)]
545mod tests;
546
547#[cfg(test)]
548mod repro_tests;
549
550mod output;
551mod resampler;
552
553fn combine_failures(errors: Vec<Error>) -> Result<()> {
554 let mut errors = errors.into_iter();
555 match (errors.next(), errors.next()) {
556 (None, _) => Ok(()),
557 (Some(error), None) => Err(error),
558 (Some(primary), Some(first)) => Err(Error::Multiple(ErrorGroup::new(
559 primary,
560 first,
561 errors.collect(),
562 ))),
563 }
564}