Skip to main content

forge_capture/
stream.rs

1//! Opening a device and reading its audio as it arrives.
2
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::time::Duration;
6
7use cpal::traits::{DeviceTrait, StreamTrait};
8
9use crate::Error;
10use crate::SAMPLE_RATE;
11use crate::convert::InputConverter;
12use crate::device::{Device, InputFormat, describe, input_config, open_device};
13
14/// One block of the device's audio, as it arrived.
15pub struct Block<'a> {
16    /// The samples, downmixed to mono at [`SAMPLE_RATE`]. A block is
17    /// whatever the device delivered (or what the resampler produced for
18    /// it), so a caller that needs a fixed frame size gathers its own.
19    pub samples: &'a [f32],
20    /// The loudest raw sample of the block, before any downmix, so a
21    /// meter can show a channel clipping that the mono mix halves.
22    pub peak: f32,
23}
24
25/// A live capture: the device is open and `on_block` is being fed.
26///
27/// The stream itself lives on the reader thread, because `cpal::Stream` is
28/// neither `Send` nor `Sync` and dropping it is what releases the device.
29pub struct Capture {
30    stop: Arc<AtomicBool>,
31    reader: Option<std::thread::JoinHandle<()>>,
32    /// The input this capture is recording from, as [`open`] resolved it:
33    /// the system default when the caller named none.
34    device: Device,
35}
36
37impl Capture {
38    /// The input this capture opened.
39    ///
40    /// The one place a host learns what the system default IS: naming no
41    /// device is resolved here, and nothing upstream sees the choice.
42    pub fn device(&self) -> &Device {
43        &self.device
44    }
45
46    /// Release the device and wait for the reader to finish.
47    pub fn stop(mut self) {
48        self.signal();
49        self.join();
50    }
51
52    fn signal(&self) {
53        self.stop.store(true, Ordering::Relaxed);
54    }
55
56    fn join(&mut self) {
57        if let Some(reader) = self.reader.take() {
58            let _ = reader.join();
59        }
60    }
61}
62
63impl Drop for Capture {
64    /// A capture dropped without [`Capture::stop`] still releases the
65    /// device: the handle owns no other way out.
66    fn drop(&mut self) {
67        self.signal();
68        self.join();
69    }
70}
71
72/// The scale a full-scale `i16` sample maps to, in the f32 the pipeline reads.
73const I16_SCALE: f32 = 32_768.0;
74
75/// One device sample as the f32 the pipeline reads.
76///
77/// Pulled out of the callback so the scale is a thing a test can pin: an
78/// operator flipped in place would compile and ship scaled nonsense silently.
79fn i16_sample(sample: i16) -> f32 {
80    f32::from(sample) / I16_SCALE
81}
82
83std::thread_local! {
84    /// The backtrace a panic hook took on this thread.
85    static PANIC_SITE: std::cell::RefCell<Option<std::backtrace::Backtrace>> =
86        const { std::cell::RefCell::new(None) };
87}
88
89/// Install the hook that records a panic's OWN backtrace.
90///
91/// **Taken at the catch, a backtrace names the catch site, not the raise.**
92/// The hook runs where the panic is raised, so a guarded block clears its
93/// slot first and reads what was recorded; an installed hook is chained
94/// rather than replaced.
95fn record_panic_sites() {
96    static HOOK: std::sync::Once = std::sync::Once::new();
97    HOOK.call_once(|| {
98        let previous = std::panic::take_hook();
99        std::panic::set_hook(Box::new(move |info| {
100            PANIC_SITE.with(|slot| {
101                if let Ok(mut slot) = slot.try_borrow_mut() {
102                    *slot = Some(std::backtrace::Backtrace::force_capture());
103                }
104            });
105            previous(info);
106        }));
107    });
108}
109
110/// The backtrace this thread's panic hook took, if it took one.
111fn take_panic_site() -> Option<std::backtrace::Backtrace> {
112    PANIC_SITE.with(|slot| slot.try_borrow_mut().ok().and_then(|mut slot| slot.take()))
113}
114
115/// Run one audio-callback block with its panic caught.
116///
117/// **cpal calls this body from its own frame**, so an unwind out of it crosses
118/// the FFI boundary and aborts the process as `rust_foreign_exception` - the
119/// signature in #2426's report. This is the one guard that can stop that
120/// crossing; after it the capture delivers nothing more, and the log names the
121/// loss once, with the input and the backtrace taken at the raise.
122fn guard_block(panicked: &AtomicBool, input: &str, body: impl FnOnce()) {
123    if panicked.load(Ordering::Acquire) {
124        return;
125    }
126    // Cleared first: a cpal thread is reused, and the slot must hold THIS
127    // block's panic rather than an earlier one's.
128    take_panic_site();
129    if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) {
130        panicked.store(true, Ordering::Release);
131        let why = crate::panic_message(panic.as_ref());
132        let backtrace = take_panic_site().unwrap_or_else(std::backtrace::Backtrace::force_capture);
133        tracing::warn!(
134            event_name = "input_callback_panicked",
135            input = %input,
136            why = %why,
137            backtrace = %backtrace,
138            "a panic in the audio callback was caught before it could cross the FFI boundary; \
139             this capture delivers nothing more"
140        );
141    }
142}
143
144/// Open the named input (or the system default) and feed `on_block` its
145/// audio, already mono at [`SAMPLE_RATE`], until the capture is stopped.
146///
147/// The device is opened and the stream started before this returns, so a
148/// device that will not open is an error here rather than silence later.
149pub fn open(
150    device: Option<&str>,
151    mut on_block: impl FnMut(Block<'_>) + Send + 'static,
152) -> Result<Capture, Error> {
153    let device = open_device(device)?;
154    let held = describe(&device);
155    let plan = input_config(&device)?;
156    // What a panic line has to name for a reader to act: the input, its
157    // format, and the rate the samples were converted from where they were
158    // not already the model rate.
159    let input = format!(
160        "{} ({}) {:?}{}",
161        held.name,
162        held.id,
163        plan.sample_format,
164        plan.resample_from.map(|rate| format!(", resampled from {rate} Hz")).unwrap_or_default()
165    );
166    record_panic_sites();
167    // Channel counts are single digits; the cast cannot lose anything.
168    let channels = plan.config.channels as usize;
169    let mut converter = InputConverter::new(channels, plan.resample_from)?;
170    tracing::debug!(
171        event_name = "input_stream_opened",
172        channels,
173        rate = SAMPLE_RATE,
174        format = ?plan.sample_format,
175        resample_from = ?plan.resample_from,
176        "input open"
177    );
178
179    // One flag for both formats' callbacks: a panic is logged once, and every
180    // later block returns without touching the pipeline.
181    let panicked = Arc::new(AtomicBool::new(false));
182    let stop = Arc::new(AtomicBool::new(false));
183    let reader_stop = Arc::clone(&stop);
184    let (ready, started) = std::sync::mpsc::channel::<Result<(), Error>>();
185    let reader = std::thread::Builder::new()
186        .name("forge-capture".into())
187        .spawn(move || {
188            // **A panic on this thread is answered, not lost.** Without the
189            // guard a panic before the first send leaves the receiver with a
190            // dropped sender, which reads as "the thread did not start" even
191            // though it did; the guard sends the real reason and logs it
192            // (#2362's review).
193            take_panic_site();
194            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(
195                || -> Result<(), Error> {
196                    // The stream is built in the format the device negotiated:
197                    // I16 is converted on the way in, because everything
198                    // downstream reads f32.
199                    let stream = match plan.sample_format {
200                        InputFormat::F32 => {
201                            let panicked = Arc::clone(&panicked);
202                            let input = input.clone();
203                            device.build_input_stream(
204                                plan.config,
205                                move |block: &[f32], _: &cpal::InputCallbackInfo| {
206                                    guard_block(&panicked, &input, || {
207                                        converter.push(block, &mut |samples, peak| {
208                                            on_block(Block { samples, peak });
209                                        });
210                                    });
211                                },
212                                |error| {
213                                    tracing::warn!(event_name = "input_stream_error", %error, "input stream error");
214                                },
215                                None,
216                            )
217                        }
218                        InputFormat::I16 => {
219                            let panicked = Arc::clone(&panicked);
220                            let input = input.clone();
221                            let mut scaled: Vec<f32> = Vec::new();
222                            device.build_input_stream(
223                                plan.config,
224                                move |block: &[i16], _: &cpal::InputCallbackInfo| {
225                                    guard_block(&panicked, &input, || {
226                                        scaled.clear();
227                                        scaled.extend(
228                                            block.iter().map(|sample| i16_sample(*sample)),
229                                        );
230                                        converter.push(&scaled, &mut |samples, peak| {
231                                            on_block(Block { samples, peak });
232                                        });
233                                    });
234                                },
235                                |error| {
236                                    tracing::warn!(event_name = "input_stream_error", %error, "input stream error");
237                                },
238                                None,
239                            )
240                        }
241                    };
242                    let stream = stream
243                        .map_err(|error| Error::Capture { message: error.to_string() })?;
244                    stream
245                        .play()
246                        .map_err(|error| Error::Capture { message: error.to_string() })?;
247                    let _ = ready.send(Ok(()));
248                    while !reader_stop.load(Ordering::Relaxed) {
249                        std::thread::sleep(Duration::from_millis(20));
250                    }
251                    drop(stream);
252                    Ok(())
253                },
254            ));
255            match outcome {
256                Ok(Ok(())) => {}
257                Ok(Err(error)) => {
258                    let _ = ready.send(Err(error));
259                }
260                Err(panic) => {
261                    let why = crate::panic_message(panic.as_ref());
262                    let backtrace =
263                        take_panic_site().unwrap_or_else(std::backtrace::Backtrace::force_capture);
264                    tracing::warn!(
265                        event_name = "capture_thread_panicked",
266                        input = %input,
267                        why = %why,
268                        backtrace = %backtrace,
269                        "the capture thread panicked"
270                    );
271                    let _ = ready.send(Err(Error::Capture {
272                        message: format!("the capture thread panicked: {why}"),
273                    }));
274                }
275            }
276        })
277        .map_err(|error| Error::Capture { message: error.to_string() })?;
278
279    let capture = Capture { stop, reader: Some(reader), device: held };
280    match started.recv() {
281        Ok(Ok(())) => Ok(capture),
282        // The stream never played: the reader is on its way out, and its
283        // reason for giving up is the answer.
284        Ok(Err(error)) => {
285            capture.stop();
286            Err(error)
287        }
288        Err(_) => {
289            capture.stop();
290            Err(Error::Capture { message: "the capture thread did not start".into() })
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests_open {
297    use super::*;
298    use crate::device::devices;
299
300    /// **A panicking block is caught once, and the capture stops.** The
301    /// callback body is Rust called from cpal's frame; an unwind out of it
302    /// crosses the FFI boundary and aborts, so the guard is the only thing
303    /// standing between a bug in the pipeline and the process. The log cannot
304    /// be read from here, but the flag and the dropped later block can.
305    #[test]
306    fn a_panicking_callback_block_is_caught_and_later_blocks_are_dropped() {
307        let panicked = AtomicBool::new(false);
308        let ran = std::cell::Cell::new(0_u32);
309        guard_block(&panicked, "a stand-in input", || {
310            ran.set(ran.get() + 1);
311            panic!("a callback panic");
312        });
313        assert!(panicked.load(Ordering::Acquire), "the first panic stops the capture");
314        guard_block(&panicked, "a stand-in input", || ran.set(ran.get() + 1));
315        assert_eq!(ran.get(), 1, "a block after the panic returns without touching the pipeline");
316    }
317
318    /// Full scale maps to 1.0 and the operator is a division: the reverse
319    /// compiles, and would ship every I16 device's audio as nonsense.
320    #[test]
321    fn an_i16_sample_is_scaled_to_full_scale_one() {
322        assert_eq!(i16_sample(0), 0.0, "silence stays silence");
323        assert_eq!(i16_sample(-32_768), -1.0, "the negative rail is -1");
324        assert!(
325            (i16_sample(32_767) - 0.999_969_5).abs() < 1e-6,
326            "the positive rail lands just under 1, as cpal's own conversion does"
327        );
328    }
329
330    #[test]
331    #[ignore = "opens the real default input; run with --run-ignored all on a machine with audio hardware"]
332    fn a_capture_reports_the_device_it_opened() {
333        // No audio stack in CI, so an unopenable device proves nothing here;
334        // what must hold is that the device a capture reports back is the one
335        // the host calls the default, which is the fact a panel draws.
336        let Ok(all) = devices() else { return };
337        let Some(default) = all.iter().find(|device| device.is_default) else { return };
338        let Ok(capture) = open(None, |_| {}) else { return };
339        assert_eq!(
340            capture.device().id,
341            default.id,
342            "naming no device must report the system default back to the caller"
343        );
344        assert!(capture.device().is_default, "and report it as the default");
345        capture.stop();
346    }
347}