AnyBot/src/mic.rs

172 lines
6.5 KiB
Rust
Raw Normal View History

// 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<AtomicBool>,
handle: Option<std::thread::JoinHandle<&'static str>>,
}
/// 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<Listen>, 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::<f64>() / 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<T: Copy>(data: &[T], channels: usize, buf: &Arc<Mutex<Vec<f32>>>, 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::<f32>() / frame.len() as f32);
}
}
fn run(file: PathBuf, listen: Option<Listen>, max_secs: u64, stop: Arc<AtomicBool>) -> &'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<Mutex<Vec<f32>>> = 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<u8> {
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);
}
}