AnyBot/src/mic.rs

172 lines
6.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}
}