Skip to main content

forge_dictate/
capture.rs

1//! The take's own sample buffer: what a host's pushed frames grow, and
2//! what the segmenter cuts windows off.
3
4use std::sync::Mutex;
5use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
6use std::time::Duration;
7
8use crate::audio::SAMPLE_RATE;
9
10/// What the recording thread and the handle share.
11pub(crate) struct Recording {
12    samples: Mutex<Vec<f32>>,
13    /// Loudest absolute sample since the last read, as `f32` bits.
14    /// Atomic so a caller drawing a level meter never blocks the
15    /// pusher.
16    peak_bits: AtomicU32,
17    stop: AtomicBool,
18    /// Set when the take stopped itself at the cap rather than
19    /// because it was asked to.
20    truncated: AtomicBool,
21}
22
23impl Recording {
24    /// `capacity` is the cap in samples. Reserved up front so the buffer
25    /// never reallocates as a take grows.
26    pub(crate) fn new(capacity: usize) -> Self {
27        let recording = Self {
28            samples: Mutex::new(Vec::with_capacity(capacity)),
29            peak_bits: AtomicU32::new(0.0f32.to_bits()),
30            stop: AtomicBool::new(false),
31            truncated: AtomicBool::new(false),
32        };
33        // `Mutex::lock` allocates on its FIRST acquisition on macOS;
34        // taking it once here keeps that 64-byte malloc out of the first
35        // `push`, which is documented as allocation-free.
36        drop(recording.samples.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
37        recording
38    }
39
40    /// Fold one block of audio in, keeping the running peak since the
41    /// last read.
42    ///
43    /// Mono at the model rate: a device's channels are downmixed and
44    /// resampled before they reach this buffer. MUST NOT ALLOCATE: the
45    /// samples extend the preallocated buffer rather than an
46    /// intermediate.
47    pub(crate) fn push(&self, block: &[f32], limit: usize) {
48        self.observe_peak(block);
49
50        let mut samples = self.samples.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
51        if samples.len() >= limit {
52            // The cap is reached: stop growing and flag the take
53            // stopped, so its host submits what it has rather than
54            // feeding a take that can hold nothing more.
55            self.truncated.store(true, Ordering::Relaxed);
56            self.stop.store(true, Ordering::Relaxed);
57            return;
58        }
59
60        let room = limit - samples.len();
61        if block.len() > room {
62            // The block that FIRST overruns is the one that loses its
63            // tail, so the flag has to be set here. Setting it only on
64            // the next callback leaves a `finish` in between reporting a
65            // recording that really was cut as complete.
66            self.truncated.store(true, Ordering::Relaxed);
67            self.stop.store(true, Ordering::Relaxed);
68        }
69
70        samples.extend_from_slice(&block[..block.len().min(room)]);
71    }
72
73    /// Fold the loudest absolute sample of `block` into the meter's
74    /// accumulator. Peak over the RAW block, not any downmix: a meter
75    /// should show a channel clipping even when the mean of the
76    /// channels does not.
77    fn observe_peak(&self, block: &[f32]) {
78        let mut loudest = 0.0f32;
79        for sample in block {
80            loudest = loudest.max(sample.abs());
81        }
82        let mut current = self.peak_bits.load(Ordering::Relaxed);
83        loop {
84            if f32::from_bits(current) >= loudest {
85                break;
86            }
87            match self.peak_bits.compare_exchange_weak(
88                current,
89                loudest.to_bits(),
90                Ordering::Relaxed,
91                Ordering::Relaxed,
92            ) {
93                Ok(_) => break,
94                Err(seen) => current = seen,
95            }
96        }
97    }
98
99    /// Loudest input since the last read, in dBFS. Take-and-reset: the
100    /// read clears the accumulator, so a poller gets one window per ask.
101    pub(crate) fn peak_dbfs(&self) -> f32 {
102        let peak = f32::from_bits(self.peak_bits.swap(0.0f32.to_bits(), Ordering::Relaxed));
103        if peak <= 0.0 { f32::NEG_INFINITY } else { 20.0 * peak.log10() }
104    }
105
106    pub(crate) fn stop(&self) {
107        self.stop.store(true, Ordering::Relaxed);
108    }
109
110    /// The take's cap was reached by WALL CLOCK rather than by samples.
111    ///
112    /// The sample cap fires only while audio is arriving, and a take fed by
113    /// frames has no callback loop to notice it either: a stream that stops
114    /// mid-take would hold the seat until teardown, so its host counts the
115    /// clock and flags it here.
116    pub(crate) fn cap_reached(&self) {
117        self.truncated.store(true, Ordering::Relaxed);
118        self.stop.store(true, Ordering::Relaxed);
119    }
120
121    /// Whether the recording has been asked to stop - the cap reached
122    /// itself, or the host ended the take. What a feeder polls before
123    /// appending: a stream take's frame sink, which has no thread to poll
124    /// it for it.
125    pub(crate) fn stopped(&self) -> bool {
126        self.stop.load(Ordering::Relaxed)
127    }
128
129    /// Samples currently held. The append-only buffer keeps written
130    /// ranges stable, so a segmenter can cut by offset while the
131    /// callback keeps appending past them.
132    pub(crate) fn sample_len(&self) -> usize {
133        self.samples.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len()
134    }
135
136    /// Copy `[from..to)` out without draining. The recording keeps the
137    /// whole take so finish can hand diagnostics the full capture, and
138    /// so an offset that raced a stop is a short copy, not a panic.
139    /// The lock is held for the memcpy only - long enough to be felt in
140    /// a callback budget if it happened every block, which it does not:
141    /// this runs once per window boundary, every 30 to 60 seconds.
142    pub(crate) fn copy_region(&self, from: usize, to: usize) -> Vec<f32> {
143        let samples = self.samples.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
144        let to = to.min(samples.len());
145        let from = from.min(to);
146        samples[from..to].to_vec()
147    }
148
149    pub(crate) fn take(&self) -> Vec<f32> {
150        let mut samples = self.samples.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
151        std::mem::take(&mut *samples)
152    }
153
154    pub(crate) fn was_truncated(&self) -> bool {
155        self.truncated.load(Ordering::Relaxed)
156    }
157}
158
159/// The cap in samples. Integer maths so it cannot be a truncating float
160/// cast.
161pub(crate) fn sample_cap(max_capture: Duration) -> usize {
162    let wanted =
163        usize::try_from(max_capture.as_millis().saturating_mul(u128::from(SAMPLE_RATE)) / 1000)
164            .unwrap_or(CAP_CEILING);
165    // Clamped because the whole cap is reserved eagerly, per capture: at
166    // 4 bytes a sample an hour is 219 MiB and `Duration::MAX` aborts the
167    // process inside `Vec::with_capacity`.
168    wanted.min(CAP_CEILING)
169}
170
171/// One hour of audio, the point past which reserving the cap costs more
172/// than any dictation could use.
173const CAP_CEILING: usize = SAMPLE_RATE as usize * 3600;
174
175#[cfg(test)]
176mod tests_recording {
177    use super::*;
178
179    /// One second at 16 kHz, so the cap is reached well inside the test.
180    const LIMIT: usize = SAMPLE_RATE as usize;
181
182    #[test]
183    fn the_cap_truncates_rather_than_growing_without_bound() {
184        let recording = Recording::new(LIMIT);
185        // Three seconds of audio pushed into a one second cap.
186        for _ in 0..3 {
187            recording.push(&vec![0.5; LIMIT], LIMIT);
188        }
189        assert_eq!(
190            recording.take().len(),
191            LIMIT,
192            "audio past the cap must be dropped, not accumulated"
193        );
194        assert!(
195            recording.was_truncated(),
196            "reaching the cap must be recorded, or a short transcript looks like a short utterance"
197        );
198    }
199
200    #[test]
201    fn the_block_that_overruns_flags_it_without_waiting_for_the_next_one() {
202        let recording = Recording::new(LIMIT);
203        // A single block that does not fit. Nothing follows it, which is
204        // the point: a `finish` landing here must not see the recording
205        // as complete when its tail was dropped.
206        recording.push(&vec![0.5; LIMIT + 1], LIMIT);
207        assert!(
208            recording.was_truncated(),
209            "the block losing its tail must flag it, or a finish between callbacks reports a cut recording as whole"
210        );
211        assert!(
212            recording.stop.load(Ordering::Relaxed),
213            "a capture nobody stopped must free the microphone itself, not hold it open"
214        );
215    }
216
217    /// The read is take-and-reset: each read answers "loudest since the
218    /// last read" and then clears, which is what a windowed level meter
219    /// polls. A read that held the all-time peak would freeze a meter on
220    /// the first syllable; the deliberate change from the old peak-hold
221    /// behaviour is covered by the two quieter-push assertions below.
222    #[test]
223    fn the_level_read_is_take_and_reset() {
224        let recording = Recording::new(LIMIT);
225        let silent = recording.peak_dbfs();
226        assert!(
227            silent.is_infinite() && silent.is_sign_negative(),
228            "an untouched recording must read as no signal, got {silent}"
229        );
230
231        recording.push(&[0.1, -0.5, 0.25], LIMIT);
232        let loud = recording.peak_dbfs();
233        // -0.5 full scale is about -6 dBFS.
234        assert!((loud + 6.02).abs() < 0.1, "peak of 0.5 must read near -6 dBFS, got {loud}");
235
236        // A quieter window after the read reads its own peak, not the
237        // all-time high.
238        recording.push(&[0.01], LIMIT);
239        let quiet = recording.peak_dbfs();
240        // 0.01 full scale is about -40 dBFS.
241        assert!((quiet + 40.0).abs() < 0.1, "a quieter window must read its own peak, got {quiet}");
242
243        // And having consumed it, a read with nothing new is no signal.
244        let empty = recording.peak_dbfs();
245        assert!(
246            empty.is_infinite() && empty.is_sign_negative(),
247            "a read past the last one must be empty, got {empty}"
248        );
249    }
250}