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}