Skip to main content

flowcat_core/audio/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! Audio intelligence + call recording (PROCESSOR-DESIGN §6.1).
4//!
5//! This module is the home for the **audio-intelligence** processors that sit on
6//! the hot media path inside the cascaded pipeline — VAD, end-of-turn, noise
7//! filters, and mixers — alongside the existing call **recorder**. The analyzer
8//! *traits* (`VadAnalyzer`/`TurnAnalyzer`) are frozen in [`crate::service`]; the
9//! ONNX (`ort`) impls compile only under the `vad-ort` feature so flowcat-core
10//! stays dependency-light by default.
11//!
12//! Module map:
13//!
14//! - [`vad`] — `VadAnalyzer` impls (Silero behind `vad-ort`, a mock always).
15//! - [`turn`] — `TurnAnalyzer` impls (Smart-Turn behind `vad-ort`, a mock always).
16//! - [`strategy`] — turn start/stop/mute + interruption strategies (pure logic).
17//! - [`filter`] — RNNoise/Koala audio-filter processors (Krisp deferred).
18//! - [`mixer`] — audio mixers.
19//!
20//! ## Call recording (existing)
21//!
22//! Tap both legs (caller + bot), mono-mix, render to WAV bytes.
23//!
24//! The recorder accumulates audio from each leg and produces a single mono WAV
25//! (via `hound`) suitable for upload to the object store (see DESIGN.md "Audio path").
26//!
27//! The two legs typically arrive at different sample rates (caller ≈ 8 kHz μ-law
28//! decoded, bot ≈ 24 kHz from Gemini Live), so each leg is buffered with its own
29//! rate and resampled to the recorder's common `sample_rate` only at render time.
30//! Mixing is a plain per-sample sum clamped back into `i16`.
31
32pub mod filter;
33pub mod mixer;
34pub mod strategy;
35pub mod turn;
36pub mod vad;
37
38// ---- Audio-intelligence re-exports (ergonomic flat names) ----
39#[cfg(feature = "filter-rnnoise")]
40pub use filter::RnnoiseFilter;
41pub use filter::{AudioFilter, AudioFilterProcessor, IdentityFilter};
42pub use mixer::{AudioMixer, MixerProcessor, SilenceMixer, SoundfileMixer};
43pub use strategy::{
44    AlwaysInterrupt, AlwaysMute, ExternalTurnStart, FirstSpeechMute, FunctionCallMute,
45    InterruptionStrategy, MinWordsInterrupt, MinWordsTurnStart, MuteStrategy,
46    MuteUntilFirstBotComplete, SpeechTimeoutStop, TurnAnalyzerStop, TurnStartDecision,
47    TurnStartStrategy, TurnStopDecision, TurnStopStrategy, VadTurnStart,
48};
49#[cfg(feature = "vad-ort")]
50pub use turn::SmartTurn;
51pub use turn::{MockTurn, TurnProcessor, TurnSilenceTracker};
52#[cfg(feature = "vad-ort")]
53pub use vad::SileroVad;
54pub use vad::{window_volume, MockVad, VadProcessor, VadStateMachine};
55
56use std::collections::HashMap;
57use std::io::Cursor;
58use std::time::{Duration, Instant};
59
60use hound::{SampleFormat, WavSpec, WavWriter};
61
62use crate::codec::Resampler;
63use crate::error::FlowcatError;
64use crate::types::AudioChunk;
65
66/// A chunk placed on the call's wall-clock timeline (`offset_s` = seconds from
67/// call start). The placement is already advanced past the leg's prior audio so
68/// same-leg chunks never overlap; render silence-pads the gap up to it.
69struct PlacedChunk {
70    offset_s: f64,
71    chunk: AudioChunk,
72}
73
74/// Accumulates inbound (caller) and outbound (bot) audio on a shared wall-clock
75/// timeline and mixes them down to a single mono WAV byte buffer.
76///
77/// Each leg records only its *active* chunks, but every chunk is stamped with its
78/// arrival time relative to call start, so render silence-pads the gaps and the
79/// two legs stay aligned — a caller turn at t=2s and a bot turn at t=5s land where
80/// they actually happened instead of both starting at sample 0 (which made the
81/// two voices play over each other in the recording).
82pub struct AudioRecorder {
83    /// Output WAV sample rate in Hz.
84    pub sample_rate: u32,
85    /// Buffered caller-leg chunks (kept at their source rate; resampled at render).
86    inbound: Vec<PlacedChunk>,
87    /// Buffered bot-leg chunks (kept at their source rate; resampled at render).
88    outbound: Vec<PlacedChunk>,
89    /// Call-start reference for the live [`push_inbound`](Self::push_inbound) /
90    /// [`push_outbound`](Self::push_outbound) taps; set lazily on the first push.
91    start: Option<Instant>,
92    /// Running end-of-audio per leg (seconds): keeps same-leg chunks contiguous
93    /// and makes [`duration_seconds`](Self::duration_seconds) O(1).
94    inbound_end_s: f64,
95    outbound_end_s: f64,
96}
97
98impl AudioRecorder {
99    /// Create a recorder that renders mixed audio at `sample_rate` Hz.
100    pub fn new(sample_rate: u32) -> Self {
101        Self {
102            sample_rate,
103            inbound: Vec::new(),
104            outbound: Vec::new(),
105            start: None,
106            inbound_end_s: 0.0,
107            outbound_end_s: 0.0,
108        }
109    }
110
111    /// Elapsed time since the first push (call start), set lazily on first use.
112    fn tick(&mut self) -> Duration {
113        match self.start {
114            Some(s) => s.elapsed(),
115            None => {
116                self.start = Some(Instant::now());
117                Duration::ZERO
118            }
119        }
120    }
121
122    /// Append a chunk from the inbound (caller) leg, stamped at the live wall clock.
123    ///
124    /// Stored at its own rate; resample + silence-pad happen in
125    /// [`render_wav`](Self::render_wav). Empty chunks are dropped.
126    pub fn push_inbound(&mut self, chunk: &AudioChunk) {
127        let at = self.tick();
128        self.push_inbound_at(at, chunk);
129    }
130
131    /// Append a chunk from the outbound (bot) leg, stamped at the live wall clock.
132    pub fn push_outbound(&mut self, chunk: &AudioChunk) {
133        let at = self.tick();
134        self.push_outbound_at(at, chunk);
135    }
136
137    /// Append an inbound chunk at an explicit timeline offset from call start.
138    ///
139    /// Deterministic entry point (no wall clock): the live taps go through
140    /// [`push_inbound`](Self::push_inbound); tests place chunks at known offsets.
141    pub fn push_inbound_at(&mut self, offset: Duration, chunk: &AudioChunk) {
142        if let Some(p) = place(offset, chunk, self.inbound_end_s) {
143            self.inbound_end_s = p.offset_s + chunk_duration_s(chunk);
144            self.inbound.push(p);
145        }
146    }
147
148    /// Append an outbound chunk at an explicit timeline offset from call start.
149    pub fn push_outbound_at(&mut self, offset: Duration, chunk: &AudioChunk) {
150        if let Some(p) = place(offset, chunk, self.outbound_end_s) {
151            self.outbound_end_s = p.offset_s + chunk_duration_s(chunk);
152            self.outbound.push(p);
153        }
154    }
155
156    /// Wall-clock call duration in seconds — the end of the later leg's last audio.
157    /// Reported back to the control plane as `usage_metrics.duration_seconds`.
158    pub fn duration_seconds(&self) -> f64 {
159        self.inbound_end_s.max(self.outbound_end_s)
160    }
161
162    /// Mix both legs to mono and render a complete WAV file as bytes.
163    ///
164    /// Each leg is resampled to `self.sample_rate` and laid out on the shared
165    /// timeline (silence-padding the gaps between turns); the two streams are
166    /// summed sample-by-sample (zero-padding the shorter leg to the longer), each
167    /// sum is clamped into `i16`, and the result is written as a mono 16-bit PCM
168    /// WAV via `hound`.
169    pub fn render_wav(&self) -> Result<Vec<u8>, FlowcatError> {
170        let caller = render_leg(&self.inbound, self.sample_rate)?;
171        let bot = render_leg(&self.outbound, self.sample_rate)?;
172
173        let n = caller.len().max(bot.len());
174        let mut mixed = Vec::with_capacity(n);
175        for i in 0..n {
176            let a = *caller.get(i).unwrap_or(&0) as i32;
177            let b = *bot.get(i).unwrap_or(&0) as i32;
178            mixed.push((a + b).clamp(i16::MIN as i32, i16::MAX as i32) as i16);
179        }
180
181        let spec = WavSpec {
182            channels: 1,
183            sample_rate: self.sample_rate,
184            bits_per_sample: 16,
185            sample_format: SampleFormat::Int,
186        };
187
188        // Write into an owned byte buffer. `Cursor<&mut Vec<u8>>` is `Write +
189        // Seek` (which hound requires), and crucially leaves us owning `buf`
190        // after `finalize()` consumes the writer.
191        let mut buf: Vec<u8> = Vec::new();
192        {
193            let mut writer = WavWriter::new(Cursor::new(&mut buf), spec)
194                .map_err(|e| FlowcatError::Codec(format!("wav header: {e}")))?;
195            for &s in &mixed {
196                writer
197                    .write_sample(s)
198                    .map_err(|e| FlowcatError::Codec(format!("wav sample: {e}")))?;
199            }
200            writer
201                .finalize()
202                .map_err(|e| FlowcatError::Codec(format!("wav finalize: {e}")))?;
203        }
204        Ok(buf)
205    }
206}
207
208/// Real-time duration of a chunk in seconds (rate-independent: samples ÷ rate).
209fn chunk_duration_s(chunk: &AudioChunk) -> f64 {
210    if chunk.sample_rate == 0 {
211        0.0
212    } else {
213        chunk.pcm.len() as f64 / chunk.sample_rate as f64
214    }
215}
216
217/// Resolve a chunk's timeline placement: never before the leg's prior audio (so
218/// same-leg chunks stay contiguous and never self-overlap), else at its arrival
219/// offset. Returns `None` for empty chunks (dropped, as the recorder always has).
220fn place(offset: Duration, chunk: &AudioChunk, leg_end_s: f64) -> Option<PlacedChunk> {
221    if chunk.is_empty() {
222        return None;
223    }
224    Some(PlacedChunk {
225        offset_s: offset.as_secs_f64().max(leg_end_s),
226        chunk: chunk.clone(),
227    })
228}
229
230/// Resample one leg to `target_rate` and lay each chunk on the timeline, padding
231/// silence up to each chunk's offset so the gaps between turns are preserved.
232///
233/// Reuses one [`Resampler`] per distinct source rate (so a steady single-rate leg
234/// builds exactly one resampler and preserves its filter state across chunks);
235/// flushes each resampler's tail at the end so no samples are dropped. Chunks
236/// already at `target_rate` are copied straight through.
237fn render_leg(chunks: &[PlacedChunk], target_rate: u32) -> Result<Vec<i16>, FlowcatError> {
238    let mut out: Vec<i16> = Vec::new();
239    let mut resamplers: HashMap<u32, Resampler> = HashMap::new();
240
241    for pc in chunks {
242        // Silence-pad the output up to this chunk's wall-clock placement.
243        let target = (pc.offset_s * target_rate as f64).round() as usize;
244        if out.len() < target {
245            out.resize(target, 0);
246        }
247
248        if pc.chunk.sample_rate == target_rate {
249            out.extend_from_slice(&pc.chunk.pcm);
250            continue;
251        }
252        let rs = match resamplers.get_mut(&pc.chunk.sample_rate) {
253            Some(rs) => rs,
254            None => {
255                let rs = Resampler::new(pc.chunk.sample_rate, target_rate)?;
256                resamplers.entry(pc.chunk.sample_rate).or_insert(rs)
257            }
258        };
259        let res = rs.process(&pc.chunk)?;
260        out.extend_from_slice(&res.pcm);
261    }
262
263    // Drain each resampler's buffered remainder.
264    for rs in resamplers.values_mut() {
265        let tail = rs.flush()?;
266        out.extend_from_slice(&tail.pcm);
267    }
268
269    Ok(out)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use hound::WavReader;
276
277    fn tone(rate: u32, n: usize, freq: f32, amp: f32) -> AudioChunk {
278        let pcm = (0..n)
279            .map(|i| {
280                let t = i as f32 / rate as f32;
281                (amp * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16
282            })
283            .collect();
284        AudioChunk::new(pcm, rate)
285    }
286
287    /// Parse WAV bytes back with hound and return (spec, samples).
288    fn parse_wav(bytes: &[u8]) -> (WavSpec, Vec<i16>) {
289        let mut reader = WavReader::new(Cursor::new(bytes)).expect("valid WAV header");
290        let spec = reader.spec();
291        let samples: Vec<i16> = reader
292            .samples::<i16>()
293            .map(|s| s.expect("readable sample"))
294            .collect();
295        (spec, samples)
296    }
297
298    #[test]
299    fn renders_valid_wav_header_at_recorder_rate() {
300        let mut rec = AudioRecorder::new(8000);
301        // Both legs already at the recorder rate -> no resampling, exact counts.
302        rec.push_inbound_at(Duration::ZERO, &tone(8000, 800, 440.0, 8000.0));
303        rec.push_outbound_at(Duration::ZERO, &tone(8000, 800, 660.0, 8000.0));
304
305        let bytes = rec.render_wav().unwrap();
306        assert!(!bytes.is_empty());
307        assert_eq!(&bytes[0..4], b"RIFF");
308        assert_eq!(&bytes[8..12], b"WAVE");
309
310        let (spec, samples) = parse_wav(&bytes);
311        assert_eq!(spec.channels, 1, "mono");
312        assert_eq!(spec.sample_rate, 8000);
313        assert_eq!(spec.bits_per_sample, 16);
314        assert_eq!(spec.sample_format, SampleFormat::Int);
315        assert_eq!(samples.len(), 800, "same-rate legs mix 1:1");
316    }
317
318    #[test]
319    fn mixes_two_legs_by_summation() {
320        let mut rec = AudioRecorder::new(8000);
321        // Two constant DC-ish offsets so the mix is predictable: sum then clamp.
322        rec.push_inbound_at(Duration::ZERO, &AudioChunk::new(vec![1000i16; 100], 8000));
323        rec.push_outbound_at(Duration::ZERO, &AudioChunk::new(vec![2000i16; 100], 8000));
324
325        let (_, samples) = parse_wav(&rec.render_wav().unwrap());
326        assert_eq!(samples.len(), 100);
327        assert!(samples.iter().all(|&s| s == 3000), "1000 + 2000 = 3000");
328    }
329
330    #[test]
331    fn mix_clamps_instead_of_wrapping() {
332        let mut rec = AudioRecorder::new(8000);
333        rec.push_inbound_at(Duration::ZERO, &AudioChunk::new(vec![30000i16; 10], 8000));
334        rec.push_outbound_at(Duration::ZERO, &AudioChunk::new(vec![30000i16; 10], 8000));
335
336        let (_, samples) = parse_wav(&rec.render_wav().unwrap());
337        // 60000 must saturate to i16::MAX, never wrap to a negative value.
338        assert!(samples.iter().all(|&s| s == i16::MAX));
339    }
340
341    #[test]
342    fn longer_leg_determines_length_shorter_zero_padded() {
343        let mut rec = AudioRecorder::new(8000);
344        rec.push_inbound_at(Duration::ZERO, &AudioChunk::new(vec![500i16; 200], 8000));
345        rec.push_outbound_at(Duration::ZERO, &AudioChunk::new(vec![500i16; 50], 8000));
346
347        let (_, samples) = parse_wav(&rec.render_wav().unwrap());
348        assert_eq!(samples.len(), 200, "length = longer leg");
349        // Overlap region summed; tail is caller-only (bot zero-padded).
350        assert_eq!(samples[0], 1000);
351        assert_eq!(samples[199], 500);
352    }
353
354    #[test]
355    fn resamples_legs_at_different_rates_to_common_rate() {
356        // Caller at 8 kHz, bot at 24 kHz, recorder at 16 kHz: both legs are
357        // resampled before mixing and the WAV is still valid & mono @ 16 kHz.
358        let mut rec = AudioRecorder::new(16000);
359        rec.push_inbound_at(Duration::ZERO, &tone(8000, 1600, 440.0, 8000.0)); // 200 ms
360        rec.push_outbound_at(Duration::ZERO, &tone(24000, 4800, 660.0, 8000.0)); // 200 ms
361
362        let bytes = rec.render_wav().unwrap();
363        let (spec, samples) = parse_wav(&bytes);
364        assert_eq!(spec.sample_rate, 16000);
365        assert_eq!(spec.channels, 1);
366        // ~200 ms @ 16 kHz ≈ 3200 samples, plus up to ~1 resampler block of
367        // flush-tail / edge delay: each leg drains its resampler at end-of-stream
368        // by zero-padding the final partial block, so the recorded length runs a
369        // little long (the per-block ratio itself is exact — see codec tests).
370        assert!(
371            (3000..=3700).contains(&samples.len()),
372            "expected ~3200 samples @16k, got {}",
373            samples.len()
374        );
375    }
376
377    #[test]
378    fn empty_recorder_renders_valid_empty_wav() {
379        let rec = AudioRecorder::new(8000);
380        let bytes = rec.render_wav().unwrap();
381        let (spec, samples) = parse_wav(&bytes);
382        assert_eq!(spec.sample_rate, 8000);
383        assert_eq!(samples.len(), 0);
384    }
385
386    #[test]
387    fn empty_chunks_are_ignored() {
388        let mut rec = AudioRecorder::new(8000);
389        rec.push_inbound_at(Duration::ZERO, &AudioChunk::new(vec![], 8000));
390        rec.push_outbound_at(Duration::ZERO, &AudioChunk::new(vec![], 8000));
391        let (_, samples) = parse_wav(&rec.render_wav().unwrap());
392        assert_eq!(samples.len(), 0);
393    }
394
395    #[test]
396    fn sequential_turns_do_not_overlap_on_the_timeline() {
397        // Regression: legs used to be summed from sample 0, so a user turn and a
398        // later bot turn played simultaneously. With timeline placement the bot
399        // turn lands at its real offset and the two never sum together.
400        let mut rec = AudioRecorder::new(8000);
401        rec.push_inbound_at(Duration::ZERO, &AudioChunk::new(vec![1000i16; 100], 8000));
402        rec.push_outbound_at(
403            Duration::from_millis(500),
404            &AudioChunk::new(vec![2000i16; 100], 8000),
405        );
406
407        let (_, samples) = parse_wav(&rec.render_wav().unwrap());
408        // Bot lands 0.5 s in (4000 samples @ 8 kHz) and runs 100 samples.
409        assert_eq!(samples.len(), 4100, "timeline spans to the bot turn");
410        assert_eq!(samples[50], 1000, "user turn, bot silent");
411        assert_eq!(samples[4050], 2000, "bot turn, user silent");
412        assert!(
413            !samples.contains(&3000),
414            "sequential turns must not overlap (no summed 1000+2000)"
415        );
416    }
417
418    #[test]
419    fn duration_seconds_spans_to_last_audio() {
420        let mut rec = AudioRecorder::new(8000);
421        // Caller: 1.0 s at t=0.  Bot: 0.5 s starting at t=2.0 s → ends at 2.5 s.
422        rec.push_inbound_at(Duration::ZERO, &AudioChunk::new(vec![1i16; 8000], 8000));
423        rec.push_outbound_at(
424            Duration::from_secs(2),
425            &AudioChunk::new(vec![1i16; 4000], 8000),
426        );
427        assert!(
428            (rec.duration_seconds() - 2.5).abs() < 1e-6,
429            "duration spans to the later leg's last audio, got {}",
430            rec.duration_seconds()
431        );
432    }
433}