Skip to main content

aurum_core/
pcm.rs

1//! In-memory PCM buffer for mic / streaming hosts (no files, no ffmpeg).
2//!
3//! Fixed-capacity ring buffer with O(1) rolling (JOE-1601). Invalid floating-point
4//! samples are rejected before they reach DSP / native code.
5//!
6//! Thread-safety: `PcmBuffer` is **not** synchronized. Hosts that share a buffer
7//! across threads must provide their own mutex.
8
9use crate::audio::{AudioInput, WHISPER_SAMPLE_RATE};
10use crate::error::{Result, UserError};
11use std::sync::Arc;
12
13/// Default max buffer for interactive dictation (~60 s @ 16 kHz), matching
14/// common hold-to-talk products. Longer content should use file transcription.
15pub const DEFAULT_DICTATION_MAX_SECS: f64 = 60.0;
16
17/// Rolling / accumulating mono PCM buffer at [`WHISPER_SAMPLE_RATE`].
18///
19/// Storage is a fixed-capacity ring. Capacity is never exceeded, including when a
20/// single push is larger than capacity (only the newest tail is retained).
21#[derive(Debug, Clone)]
22pub struct PcmBuffer {
23    /// Fixed storage of length `max_samples`.
24    storage: Vec<f32>,
25    /// Index of the oldest sample when `len == max_samples` (and always the
26    /// logical start of the ring contents).
27    head: usize,
28    /// Number of valid samples currently stored (≤ max_samples).
29    len: usize,
30    max_samples: usize,
31    /// When true, overflow drops oldest samples (rolling window).
32    /// When false, [`push`](Self::push) errors on overflow.
33    rolling: bool,
34    /// Count of out-of-range samples clamped into [-1, 1] this session.
35    clamped_count: u64,
36}
37
38impl PcmBuffer {
39    /// Create a buffer capped at `max_secs` of audio (rolling by default).
40    pub fn with_max_secs(max_secs: f64) -> Self {
41        let max_samples = (max_secs.max(0.0) * f64::from(WHISPER_SAMPLE_RATE)).round() as usize;
42        let max_samples = max_samples.max(1);
43        Self {
44            storage: vec![0.0; max_samples],
45            head: 0,
46            len: 0,
47            max_samples,
48            rolling: true,
49            clamped_count: 0,
50        }
51    }
52
53    /// Dictation-oriented default: ~60 s rolling window.
54    pub fn dictation() -> Self {
55        Self::with_max_secs(DEFAULT_DICTATION_MAX_SECS)
56    }
57
58    /// Grow until `max_secs`, then error instead of dropping audio.
59    pub fn bounded(max_secs: f64) -> Self {
60        let mut b = Self::with_max_secs(max_secs);
61        b.rolling = false;
62        b
63    }
64
65    pub fn clear(&mut self) {
66        self.head = 0;
67        self.len = 0;
68        // Leave storage contents undefined for performance; `len` is the authority.
69    }
70
71    pub fn len(&self) -> usize {
72        self.len
73    }
74
75    pub fn is_empty(&self) -> bool {
76        self.len == 0
77    }
78
79    pub fn max_samples(&self) -> usize {
80        self.max_samples
81    }
82
83    pub fn is_rolling(&self) -> bool {
84        self.rolling
85    }
86
87    pub fn duration_secs(&self) -> f64 {
88        self.len as f64 / f64::from(WHISPER_SAMPLE_RATE)
89    }
90
91    /// How many samples were amplitude-clamped into [-1, 1] since construction.
92    pub fn clamped_count(&self) -> u64 {
93        self.clamped_count
94    }
95
96    /// Contiguous view of logical samples.
97    ///
98    /// When the ring is not wrapped this is zero-copy. When wrapped, samples are
99    /// linearized into a temporary `Vec` (only on this call).
100    pub fn samples(&self) -> ContiguousSamples<'_> {
101        if self.len == 0 {
102            return ContiguousSamples::Borrowed(&[]);
103        }
104        let cap = self.max_samples;
105        let first = self.head % cap;
106        if first + self.len <= cap {
107            ContiguousSamples::Borrowed(&self.storage[first..first + self.len])
108        } else {
109            let mut out = Vec::with_capacity(self.len);
110            let n1 = cap - first;
111            out.extend_from_slice(&self.storage[first..]);
112            out.extend_from_slice(&self.storage[..self.len - n1]);
113            ContiguousSamples::Owned(out)
114        }
115    }
116
117    /// Copy logical samples into `dst` (reused by hosts to avoid allocation).
118    pub fn copy_samples_into(&self, dst: &mut Vec<f32>) {
119        dst.clear();
120        if self.len == 0 {
121            return;
122        }
123        dst.reserve(self.len);
124        let cap = self.max_samples;
125        let first = self.head % cap;
126        if first + self.len <= cap {
127            dst.extend_from_slice(&self.storage[first..first + self.len]);
128        } else {
129            let n1 = cap - first;
130            dst.extend_from_slice(&self.storage[first..]);
131            dst.extend_from_slice(&self.storage[..self.len - n1]);
132        }
133    }
134
135    /// Append mono f32 samples already at 16 kHz.
136    ///
137    /// Rejects NaN/Inf. Samples outside [-1, 1] are clamped and counted.
138    /// Oversized chunks in rolling mode keep only the newest `max_samples` without
139    /// ever allocating beyond capacity.
140    pub fn push(&mut self, chunk: &[f32]) -> Result<()> {
141        if chunk.is_empty() {
142            return Ok(());
143        }
144        validate_finite(chunk)?;
145
146        if !self.rolling {
147            if self.len.saturating_add(chunk.len()) > self.max_samples {
148                return Err(UserError::AudioTooLong {
149                    duration_secs: (self.len + chunk.len()) as f64 / f64::from(WHISPER_SAMPLE_RATE),
150                    max_secs: self.max_samples as f64 / f64::from(WHISPER_SAMPLE_RATE),
151                }
152                .into());
153            }
154            for &s in chunk {
155                self.push_one(s);
156            }
157            return Ok(());
158        }
159
160        // Rolling: if the chunk alone exceeds capacity, keep only its newest tail.
161        let src = if chunk.len() > self.max_samples {
162            &chunk[chunk.len() - self.max_samples..]
163        } else {
164            chunk
165        };
166        for &s in src {
167            self.push_one(s);
168        }
169        Ok(())
170    }
171
172    fn push_one(&mut self, sample: f32) {
173        let s = if sample.is_finite() {
174            if sample > 1.0 {
175                self.clamped_count = self.clamped_count.saturating_add(1);
176                1.0
177            } else if sample < -1.0 {
178                self.clamped_count = self.clamped_count.saturating_add(1);
179                -1.0
180            } else {
181                sample
182            }
183        } else {
184            // validate_finite already rejected NaN/Inf on the public path.
185            0.0
186        };
187
188        let cap = self.max_samples;
189        if self.len < cap {
190            let idx = (self.head + self.len) % cap;
191            self.storage[idx] = s;
192            self.len += 1;
193        } else {
194            // Overwrite oldest.
195            self.storage[self.head] = s;
196            self.head = (self.head + 1) % cap;
197        }
198    }
199
200    /// RMS energy using `f64` accumulation (numerically stable for long windows).
201    pub fn rms(&self) -> f32 {
202        if self.len == 0 {
203            return 0.0;
204        }
205        let mut sum = 0.0f64;
206        let cap = self.max_samples;
207        for i in 0..self.len {
208            let s = self.storage[(self.head + i) % cap] as f64;
209            sum += s * s;
210        }
211        (sum / self.len as f64).sqrt() as f32
212    }
213
214    /// Snapshot as [`AudioInput`] without clearing the buffer.
215    pub fn to_audio_input(&self) -> Result<AudioInput> {
216        if self.len == 0 {
217            return Err(UserError::InvalidAudio {
218                reason: "PCM buffer is empty".into(),
219            }
220            .into());
221        }
222        let mut v = Vec::with_capacity(self.len);
223        self.copy_samples_into(&mut v);
224        let samples: Arc<[f32]> = v.into();
225        AudioInput::from_pcm(samples, WHISPER_SAMPLE_RATE)
226    }
227
228    /// Take ownership of samples and clear the buffer.
229    pub fn take_audio_input(&mut self) -> Result<AudioInput> {
230        if self.len == 0 {
231            return Err(UserError::InvalidAudio {
232                reason: "PCM buffer is empty".into(),
233            }
234            .into());
235        }
236        let mut v = Vec::with_capacity(self.len);
237        self.copy_samples_into(&mut v);
238        self.clear();
239        let samples: Arc<[f32]> = v.into();
240        AudioInput::from_pcm(samples, WHISPER_SAMPLE_RATE)
241    }
242}
243
244/// Contiguous sample view that may own a linearized copy when the ring is wrapped.
245#[derive(Debug)]
246pub enum ContiguousSamples<'a> {
247    Borrowed(&'a [f32]),
248    Owned(Vec<f32>),
249}
250
251impl ContiguousSamples<'_> {
252    pub fn as_slice(&self) -> &[f32] {
253        match self {
254            Self::Borrowed(s) => s,
255            Self::Owned(v) => v.as_slice(),
256        }
257    }
258}
259
260impl std::ops::Deref for ContiguousSamples<'_> {
261    type Target = [f32];
262    fn deref(&self) -> &Self::Target {
263        self.as_slice()
264    }
265}
266
267fn validate_finite(chunk: &[f32]) -> Result<()> {
268    for (i, s) in chunk.iter().enumerate() {
269        if !s.is_finite() {
270            return Err(UserError::InvalidAudio {
271                reason: format!("PCM contains non-finite sample at index {i} ({s})"),
272            }
273            .into());
274        }
275    }
276    Ok(())
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn rolling_drops_oldest() {
285        let mut buf = PcmBuffer::with_max_secs(0.01); // 160 samples
286        assert_eq!(buf.max_samples(), 160);
287        buf.push(&vec![0.1; 100]).unwrap();
288        buf.push(&vec![0.2; 100]).unwrap();
289        assert_eq!(buf.len(), 160);
290        let s = buf.samples();
291        assert_eq!(s[0], 0.1);
292        assert_eq!(s[59], 0.1);
293        assert_eq!(s[60], 0.2);
294    }
295
296    #[test]
297    fn oversized_chunk_keeps_tail_only() {
298        let mut buf = PcmBuffer::with_max_secs(0.01); // 160
299                                                      // Encode index in a sub-unit range so finite/clamp checks pass.
300        let big: Vec<f32> = (0..500).map(|i| (i as f32) * 0.001).collect();
301        buf.push(&big).unwrap();
302        assert_eq!(buf.len(), 160);
303        let s = buf.samples();
304        // Newest 160 values: indices 340..499
305        assert!((s[0] - 0.340).abs() < 1e-6);
306        assert!((s[159] - 0.499).abs() < 1e-6);
307        assert_eq!(buf.storage.len(), 160);
308    }
309
310    #[test]
311    fn bounded_errors() {
312        let mut buf = PcmBuffer::bounded(0.01);
313        buf.push(&vec![0.1; 160]).unwrap();
314        let err = buf.push(&[0.1]);
315        assert!(err.is_err());
316    }
317
318    #[test]
319    fn rejects_nan() {
320        let mut buf = PcmBuffer::dictation();
321        let err = buf.push(&[0.0, f32::NAN, 0.1]);
322        assert!(err.is_err());
323        assert!(buf.is_empty());
324    }
325
326    #[test]
327    fn clamps_amplitude() {
328        let mut buf = PcmBuffer::dictation();
329        buf.push(&[2.0, -3.0, 0.5]).unwrap();
330        assert_eq!(buf.clamped_count(), 2);
331        let s = buf.samples();
332        assert_eq!(s[0], 1.0);
333        assert_eq!(s[1], -1.0);
334        assert_eq!(s[2], 0.5);
335    }
336
337    #[test]
338    fn wraparound_contiguous() {
339        let mut buf = PcmBuffer::with_max_secs(0.001); // 16 samples
340        buf.push(&[0.1; 12]).unwrap();
341        buf.push(&[0.2; 12]).unwrap();
342        assert_eq!(buf.len(), 16);
343        let s = buf.samples();
344        // 4 remaining 0.1 + 12 of 0.2
345        assert_eq!(&s[..4], &[0.1; 4]);
346        assert_eq!(&s[4..], &[0.2; 12]);
347    }
348
349    #[test]
350    fn to_audio_input() {
351        let mut buf = PcmBuffer::dictation();
352        buf.push(&vec![0.0; 1600]).unwrap();
353        let audio = buf.to_audio_input().unwrap();
354        assert_eq!(audio.sample_rate(), 16_000);
355        assert_eq!(audio.len(), 1600);
356        assert!(!buf.is_empty());
357        let taken = buf.take_audio_input().unwrap();
358        assert_eq!(taken.len(), 1600);
359        assert!(buf.is_empty());
360    }
361
362    #[test]
363    fn rms_f64() {
364        let mut buf = PcmBuffer::dictation();
365        buf.push(&[0.5; 100]).unwrap();
366        let r = buf.rms();
367        assert!((r - 0.5).abs() < 1e-5);
368    }
369}