Skip to main content

flexaudio_core/
startup.rs

1//! Sample-based microphone startup confirmation, shared by facade capture paths.
2use crate::{AudioLoss, Error, Event, Result};
3use std::borrow::Cow;
4use std::num::NonZeroU64;
5use std::time::Duration;
6
7/// Account valid startup PCM that terminal failure prevents from being delivered.
8/// Counts retain the native interleaved format; empty input produces no notice.
9pub fn discarded_startup_loss(
10    samples: &[f32],
11    rate: u32,
12    channels: u16,
13) -> Result<Option<AudioLoss>> {
14    if rate == 0 || channels == 0 || samples.len() % usize::from(channels) != 0 {
15        return Err(Error::UnsupportedFormat("startup loss requires a nonzero native rate and whole interleaved frames; recreate the stream with a valid input format".into()));
16    }
17    let count = u64::try_from(samples.len()).map_err(|_| {
18        Error::InvalidState("startup loss count exhausted; reopen the capture stream".into())
19    })?;
20    NonZeroU64::new(count)
21        .map(|count| AudioLoss::generation_ended(Some(count), rate, channels))
22        .transpose()
23}
24
25/// Why leading microphone frames were suppressed.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum StartupDropReason {
28    /// At least one channel was non-finite or outside [-1, 1].
29    OutOfRange,
30}
31impl StartupDropReason {
32    /// Stable binding discriminant.
33    pub fn as_str(self) -> &'static str {
34        "outOfRange"
35    }
36}
37/// One generation's leading microphone validation, before DSP or gain.
38/// Confirm 10 ms of valid native frames and replay them from the beginning.
39pub struct StartupSamples {
40    rate: u32,
41    channels: u16,
42    finished: bool,
43    seen: u64,
44    dropped: u64,
45    pending: Vec<f32>,
46    confirmation_frames: usize,
47}
48/// Accepted input and its original native-frame position.
49pub struct StartupOutput<'a> {
50    /// Includes held confirmation samples, never an invalid leading frame.
51    pub samples: Cow<'a, [f32]>,
52    /// Native frame offset at the start of accepted samples.
53    pub first_frame: u64,
54    /// Emitted once when a dropped generation becomes valid.
55    pub event: Option<Event>,
56}
57impl StartupSamples {
58    /// Create state for a validated nonzero mono/stereo native format.
59    pub fn new(rate: u32, channels: u16) -> Self {
60        Self {
61            rate,
62            channels,
63            finished: false,
64            seen: 0,
65            dropped: 0,
66            pending: Vec::new(),
67            confirmation_frames: (u64::from(rate).div_ceil(100)) as usize,
68        }
69    }
70    /// Whether this generation is still validating its leading samples.
71    pub fn is_pending(&self) -> bool {
72        !self.finished
73    }
74    /// Number of suppressed native frames, including interrupted confirmation windows.
75    pub fn dropped_frames(&self) -> u64 {
76        self.dropped
77    }
78    /// The single report for this generation, also usable on timeout/early shutdown.
79    pub fn dropped_event(&self) -> Option<Event> {
80        (self.dropped > 0).then_some(Event::StartupSamplesDropped {
81            frames: self.dropped,
82            reason: StartupDropReason::OutOfRange,
83        })
84    }
85    /// Reject persistent invalid startup at the wall-clock deadline, even without more input.
86    pub fn check_deadline(&self, elapsed: Duration) -> Result<()> {
87        if !self.finished && self.dropped > 0 && elapsed >= Duration::from_secs(2) {
88            return Err(Self::timeout());
89        }
90        Ok(())
91    }
92    fn timeout() -> Error {
93        Error::Backend("microphone input keeps returning out-of-range or non-finite startup samples; valid input was not confirmed within 2 seconds. Reconnect the input, check its driver and recording settings, then reopen the stream".into())
94    }
95    /// Finish a generation. A wholly valid short input is released unchanged;
96    /// an unconfirmed candidate following invalid input is suppressed and counted.
97    pub fn finish(&mut self) -> StartupOutput<'static> {
98        if self.finished {
99            return StartupOutput {
100                samples: Cow::Owned(Vec::new()),
101                first_frame: self.seen,
102                event: None,
103            };
104        }
105        self.finished = true;
106        if self.dropped == 0 {
107            return StartupOutput {
108                samples: Cow::Owned(std::mem::take(&mut self.pending)),
109                first_frame: 0,
110                event: None,
111            };
112        }
113        self.dropped = self
114            .dropped
115            .saturating_add((self.pending.len() / usize::from(self.channels.max(1))) as u64);
116        self.pending.clear();
117        StartupOutput {
118            samples: Cow::Owned(Vec::new()),
119            first_frame: self.seen,
120            event: self.dropped_event(),
121        }
122    }
123    /// Feed whole native frames off callback. Mid-stream samples are never modified.
124    pub fn push<'a>(&mut self, samples: &'a [f32], elapsed: Duration) -> Result<StartupOutput<'a>> {
125        let channels = usize::from(self.channels);
126        if self.rate == 0 || channels == 0 || samples.len() % channels != 0 {
127            return Err(Error::UnsupportedFormat("microphone startup validation requires a nonzero native rate and whole interleaved frames; recreate the stream with a valid input format".into()));
128        }
129        self.check_deadline(elapsed)?;
130        let first_frame = self.seen;
131        if self.finished {
132            self.seen = self.seen.saturating_add((samples.len() / channels) as u64);
133            return Ok(StartupOutput {
134                samples: Cow::Borrowed(samples),
135                first_frame,
136                event: None,
137            });
138        }
139        for (index, frame) in samples.chunks_exact(channels).enumerate() {
140            self.seen = self.seen.saturating_add(1);
141            let valid = frame
142                .iter()
143                .all(|sample| sample.is_finite() && sample.abs() <= 1.0);
144            if !valid {
145                self.dropped = self
146                    .dropped
147                    .saturating_add(1 + (self.pending.len() / channels) as u64);
148                self.pending.clear();
149            } else {
150                self.pending.extend_from_slice(frame);
151                if self.pending.len() / channels >= self.confirmation_frames {
152                    self.finished = true;
153                    self.pending
154                        .extend_from_slice(&samples[(index + 1) * channels..]);
155                    self.seen = first_frame.saturating_add((samples.len() / channels) as u64);
156                    return Ok(StartupOutput {
157                        samples: Cow::Owned(std::mem::take(&mut self.pending)),
158                        first_frame: self.dropped,
159                        event: self.dropped_event(),
160                    });
161                }
162            }
163            if self.seen >= u64::from(self.rate) * 2 {
164                return Err(Self::timeout());
165            }
166        }
167        Ok(StartupOutput {
168            samples: Cow::Borrowed(&[]),
169            first_frame: self.seen,
170            event: None,
171        })
172    }
173}
174#[cfg(test)]
175mod tests {
176    use super::*;
177    #[test]
178    fn startup_invalid_prefix_is_dropped_and_confirmation_is_replayed() -> Result<()> {
179        let mut guard = StartupSamples::new(48_000, 1);
180        assert!(guard
181            .push(&[3.058; 480], Duration::ZERO)?
182            .samples
183            .is_empty());
184        assert!(guard
185            .push(&[0.0; 240], Duration::from_millis(10))?
186            .samples
187            .is_empty());
188        let result = guard.push(&[0.25; 240], Duration::from_millis(15))?;
189        assert_eq!(result.first_frame, 480);
190        assert_eq!(&result.samples[..240], &[0.0; 240]);
191        assert_eq!(&result.samples[240..], &[0.25; 240]);
192        assert!(result.event.is_some());
193        assert!(guard
194            .push(&[3.0], Duration::from_millis(20))?
195            .event
196            .is_none());
197        Ok(())
198    }
199    #[test]
200    fn startup_valid_zero_prefix_is_unchanged_without_drops() -> Result<()> {
201        let mut guard = StartupSamples::new(48_000, 2);
202        let samples: Vec<f32> = [0.0, -0.0, 0.25, -0.25].repeat(240);
203        let result = guard.push(&samples, Duration::ZERO)?;
204        assert_eq!(&*result.samples, &samples);
205        assert_eq!(result.first_frame, 0);
206        assert!(result.event.is_none());
207        Ok(())
208    }
209    #[test]
210    fn startup_invalid_input_fails_at_two_seconds() -> Result<()> {
211        let mut guard = StartupSamples::new(48_000, 1);
212        let _ = guard.push(&[f32::NAN; 480], Duration::ZERO)?;
213        let result = guard.push(&[3.0; 480], Duration::from_secs(2));
214        assert!(
215            result.is_err(),
216            "invalid input must fail rather than be delivered"
217        );
218        Ok(())
219    }
220}
221
222#[cfg(test)]
223mod boundary_tests {
224    use super::*;
225    #[test]
226    fn interrupted_confirmation_discards_whole_stereo_frames_and_reports_once() -> Result<()> {
227        let mut guard = StartupSamples::new(44_100, 2);
228        let _ = guard.push(&[0.5, f32::INFINITY], Duration::ZERO)?;
229        let _ = guard.push(&[0.0; 440], Duration::from_millis(1))?;
230        let _ = guard.push(&[f32::NAN, 0.5], Duration::from_millis(5))?;
231        let valid = vec![0.25; 882];
232        let result = guard.push(&valid, Duration::from_millis(6))?;
233        assert_eq!(result.first_frame, 222);
234        assert_eq!(&*result.samples, &valid);
235        assert_eq!(
236            result.event,
237            Some(Event::StartupSamplesDropped {
238                frames: 222,
239                reason: StartupDropReason::OutOfRange
240            })
241        );
242        let next = guard.push(&[2.0, f32::NAN], Duration::from_secs(3))?;
243        assert_eq!(next.samples[0], 2.0);
244        assert!(next.samples[1].is_nan());
245        assert!(next.event.is_none());
246        Ok(())
247    }
248    #[test]
249    fn confirmed_window_disables_filter_for_midstream_out_of_range() -> Result<()> {
250        let mut guard = StartupSamples::new(48_000, 1);
251        let _ = guard.push(&[0.0; 480], Duration::ZERO)?;
252        let samples = [0.0, 3.058, f32::NAN];
253        let output = guard.push(&samples, Duration::ZERO)?;
254        assert_eq!(output.samples[0], 0.0);
255        assert_eq!(output.samples[1], 3.058);
256        assert!(output.samples[2].is_nan());
257        assert!(output.event.is_none());
258        assert_eq!(guard.dropped_frames(), 0);
259        Ok(())
260    }
261    #[test]
262    fn invalid_input_deadline_does_not_require_another_callback() -> Result<()> {
263        let mut guard = StartupSamples::new(48_000, 1);
264        let _ = guard.push(&[2.0], Duration::ZERO)?;
265        assert!(guard.check_deadline(Duration::from_millis(1999)).is_ok());
266        let error = guard
267            .check_deadline(Duration::from_secs(2))
268            .expect_err("deadline must fail");
269        assert!(error.to_string().contains("out-of-range"));
270        assert!(error.to_string().contains("reopen"));
271        Ok(())
272    }
273}
274
275#[cfg(test)]
276mod leading_window_tests {
277    use super::*;
278    #[test]
279    fn valid_first_sample_does_not_release_an_invalid_startup_window() -> Result<()> {
280        let mut guard = StartupSamples::new(48_000, 1);
281        let mut samples = vec![0.0, 3.058];
282        samples.extend([0.25; 480]);
283        let output = guard.push(&samples, Duration::ZERO)?;
284        assert_eq!(&*output.samples, &[0.25; 480]);
285        assert_eq!(output.first_frame, 2);
286        assert_eq!(
287            output.event,
288            Some(Event::StartupSamplesDropped {
289                frames: 2,
290                reason: StartupDropReason::OutOfRange
291            })
292        );
293        Ok(())
294    }
295}
296
297#[cfg(test)]
298mod finish_tests {
299    use super::*;
300    #[test]
301    fn short_valid_zero_capture_is_delivered_on_finish_without_a_drop() -> Result<()> {
302        let mut guard = StartupSamples::new(48_000, 2);
303        let zeros = [0.0; 26];
304        assert!(guard.push(&zeros, Duration::ZERO)?.samples.is_empty());
305        let tail = guard.finish();
306        assert_eq!(&*tail.samples, &zeros);
307        assert!(tail.event.is_none());
308        assert!(guard.finish().samples.is_empty());
309        Ok(())
310    }
311    #[test]
312    fn discarded_startup_loss_retains_native_scalar_count_and_validates_frames() -> Result<()> {
313        let loss = discarded_startup_loss(&[0.25; 26], 44_100, 2)?
314            .expect("positive discarded startup prefix");
315        assert_eq!(loss.samples().map(NonZeroU64::get), Some(26));
316        assert_eq!((loss.sample_rate(), loss.channels()), (44_100, 2));
317        assert_eq!(loss.reason(), crate::LossReason::GenerationEnded);
318        assert!(discarded_startup_loss(&[], 48_000, 2)?.is_none());
319        for (samples, rate, channels) in [
320            (&[0.25][..], 48_000, 2),
321            (&[][..], 0, 2),
322            (&[][..], 48_000, 0),
323        ] {
324            assert!(matches!(
325                discarded_startup_loss(samples, rate, channels),
326                Err(Error::UnsupportedFormat(_))
327            ));
328        }
329        Ok(())
330    }
331}