// The microphone on Linux (and other systems without macOS's recorder): cpal reads the default // input, mixes it to mono, and writes a 16-bit WAV. The same two ways as voice.rs's macOS // scripts: record until told to stop, or listen for one spoken turn (stops after a pause, gives // up when nobody speaks). use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; /// Listening rules: speech louder than `speech_db`; `quiet_ticks` × 0.1 s of quiet after speech /// ends the turn; nothing heard for `wait_ticks` × 0.1 s gives up. #[derive(Clone, Copy)] pub struct Listen { pub speech_db: f64, pub quiet_ticks: u32, pub wait_ticks: u32, } pub struct Recorder { stop: Arc, handle: Option>, } /// Start recording into `file` (WAV). The thread answers like the macOS script: "ok", /// "nothing" (no speech heard), "fail" (no microphone). pub fn start(file: PathBuf, listen: Option, max_secs: u64) -> Recorder { let stop = Arc::new(AtomicBool::new(false)); let s = stop.clone(); let handle = std::thread::spawn(move || run(file, listen, max_secs, s)); Recorder { stop, handle: Some(handle) } } impl Recorder { pub fn done(&self) -> bool { self.handle.as_ref().map(|h| h.is_finished()).unwrap_or(true) } /// Stop and wait for the file (at most 4 s). pub fn finish(&mut self) -> Option<&'static str> { self.stop.store(true, Ordering::SeqCst); let t = std::time::Instant::now(); while !self.done() && t.elapsed() < Duration::from_secs(4) { std::thread::sleep(Duration::from_millis(30)); } if self.done() { self.handle.take().and_then(|h| h.join().ok()) } else { None } } } /// Loudness of a block of samples in dBFS. pub fn db(samples: &[f32]) -> f64 { if samples.is_empty() { return -160.0; } let rms = (samples.iter().map(|s| (*s as f64) * (*s as f64)).sum::() / samples.len() as f64).sqrt(); if rms <= 1e-8 { -160.0 } else { 20.0 * rms.log10() } } /// Append the mono mix of `data` to the buffer. Runs inside cpal's real-time callback, so it /// only takes the lock with `try_lock` (blocking there starves the audio device → xruns and /// crackly audio; the reader sleeps 100 ms between looks, so a skipped block is rare and /// harmless) and does no per-sample allocation. fn mix(data: &[T], channels: usize, buf: &Arc>>, to_f32: impl Fn(T) -> f32) { let Ok(mut g) = buf.try_lock() else { return }; g.reserve(data.len() / channels.max(1)); for frame in data.chunks(channels) { g.push(frame.iter().map(|s| to_f32(*s)).sum::() / frame.len() as f32); } } fn run(file: PathBuf, listen: Option, max_secs: u64, stop: Arc) -> &'static str { use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; let host = cpal::default_host(); let Some(device) = host.default_input_device() else { return "fail" }; let Ok(cfg) = device.default_input_config() else { return "fail" }; let rate = cfg.sample_rate(); let channels = cfg.channels().max(1) as usize; let buf: Arc>> = Arc::new(Mutex::new(Vec::new())); let b = buf.clone(); let err = |_| {}; let stream = match cfg.sample_format() { cpal::SampleFormat::F32 => device.build_input_stream(cfg.config(), move |d: &[f32], _: &_| mix(d, channels, &b, |x| x), err, None), cpal::SampleFormat::I16 => device.build_input_stream(cfg.config(), move |d: &[i16], _: &_| mix(d, channels, &b, |x| x as f32 / 32768.0), err, None), cpal::SampleFormat::I32 => device.build_input_stream(cfg.config(), move |d: &[i32], _: &_| mix(d, channels, &b, |x| x as f32 / 2_147_483_648.0), err, None), cpal::SampleFormat::U8 => device.build_input_stream(cfg.config(), move |d: &[u8], _: &_| mix(d, channels, &b, |x| (x as f32 - 128.0) / 128.0), err, None), _ => return "fail", }; let Ok(stream) = stream else { return "fail" }; if stream.play().is_err() { return "fail"; } let tick = (rate as usize / 10).max(1); let (mut heard, mut quiet, mut seen) = (false, 0u32, 0usize); let mut ticks = 0u32; let mut outcome = "ok"; while !stop.load(Ordering::SeqCst) && (ticks as u64) < max_secs * 10 { std::thread::sleep(Duration::from_millis(100)); ticks += 1; if let Some(l) = listen { let g = buf.lock().unwrap(); while seen + tick <= g.len() { let level = db(&g[seen..seen + tick]); seen += tick; if level > l.speech_db { heard = true; quiet = 0; } else if heard { quiet += 1; } } drop(g); if heard && quiet >= l.quiet_ticks { break; } if !heard && ticks >= l.wait_ticks { outcome = "nothing"; break; } } } drop(stream); let samples = std::mem::take(&mut *buf.lock().unwrap()); if listen.is_some() && !heard { outcome = "nothing"; } if std::fs::write(&file, wav(&samples, rate)).is_err() { return "fail"; } outcome } /// 16-bit mono PCM WAV. pub fn wav(samples: &[f32], rate: u32) -> Vec { let data_len = (samples.len() * 2) as u32; let mut v = Vec::with_capacity(44 + data_len as usize); v.extend_from_slice(b"RIFF"); v.extend_from_slice(&(36 + data_len).to_le_bytes()); v.extend_from_slice(b"WAVEfmt "); v.extend_from_slice(&16u32.to_le_bytes()); v.extend_from_slice(&1u16.to_le_bytes()); v.extend_from_slice(&1u16.to_le_bytes()); v.extend_from_slice(&rate.to_le_bytes()); v.extend_from_slice(&(rate * 2).to_le_bytes()); v.extend_from_slice(&2u16.to_le_bytes()); v.extend_from_slice(&16u16.to_le_bytes()); v.extend_from_slice(b"data"); v.extend_from_slice(&data_len.to_le_bytes()); for s in samples { v.extend_from_slice(&((s.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes()); } v } #[cfg(test)] mod tests { use super::*; #[test] fn wav_and_loudness() { let w = wav(&[0.0, 0.5, -0.5], 16000); assert_eq!(&w[0..4], b"RIFF"); assert_eq!(w.len(), 44 + 6); assert_eq!(u32::from_le_bytes([w[24], w[25], w[26], w[27]]), 16000); assert!(db(&[0.0; 100]) < -100.0); assert!((db(&[0.5; 100]) - (-6.02)).abs() < 0.1); } }