172 lines
6.5 KiB
Rust
172 lines
6.5 KiB
Rust
// 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);
|
||
}
|
||
}
|