use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const VOICE_RMS_ABS_MIN: f64 = 80.0;
const SPEECH_NOISE_RATIO: f64 = 3.0;
const NOISE_EMA_WEIGHT: f64 = 0.1;
fn speech_threshold(noise_rms: f64) -> f64 {
(noise_rms * SPEECH_NOISE_RATIO).max(VOICE_RMS_ABS_MIN)
}
const MAX_INPUT_GAIN: f32 = 100.0;
pub const CLIPPING_WARN_RATIO: f32 = 0.02;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GainPref {
Fixed(f32),
Auto,
}
const STT_TARGET_PEAK: f32 = 0.7;
const MAX_AUTO_GAIN: f32 = 40.0;
fn input_gain_pref() -> GainPref {
let raw = std::env::var("RPI_VOICE_INPUT_GAIN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
load_config()
.get("input_gain")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
});
match raw.as_deref() {
Some(value) if value.eq_ignore_ascii_case("auto") => GainPref::Auto,
Some(value) => match value.parse::<f32>() {
Ok(gain) if gain.is_finite() && gain > 1.0 => GainPref::Fixed(gain.min(MAX_INPUT_GAIN)),
_ => GainPref::Fixed(1.0),
},
None => GainPref::Auto,
}
}
pub fn set_input_gain_pref(value: &str) -> Result<String, String> {
let value = value.trim().to_ascii_lowercase();
if value.eq_ignore_ascii_case("auto") {
save_config("input_gain", "auto")?;
return Ok("auto (normalise each recording for STT)".to_string());
}
let gain: f32 = value
.parse()
.map_err(|_| format!("'{value}' is not a number or 'auto'"))?;
if !gain.is_finite() || gain < 1.0 {
return Err(format!("gain must be >= 1 (got '{value}')"));
}
let gain = gain.min(MAX_INPUT_GAIN);
save_config("input_gain", &format!("{gain}"))?;
Ok(format!("x{gain:.0}"))
}
pub fn gain_pref_label() -> String {
match input_gain_pref() {
GainPref::Auto => "auto (normalise for STT)".to_string(),
GainPref::Fixed(gain) if gain <= 1.0 => "off".to_string(),
GainPref::Fixed(gain) => format!("x{gain:.0}"),
}
}
#[derive(Clone, Copy, Debug)]
pub struct RecordParams {
pub max_ms: u64,
pub silence_ms: u64,
pub min_speech_ms: u64,
pub no_speech_ms: Option<u64>,
pub warmup_ms: u64,
}
impl Default for RecordParams {
fn default() -> Self {
Self {
max_ms: 20_000,
silence_ms: 1_200,
min_speech_ms: 500,
no_speech_ms: None,
warmup_ms: 0,
}
}
}
impl RecordParams {
pub fn from_env() -> Self {
let mut p = Self::default();
if let Some(v) = env_millis("RPI_VOICE_RECORD_MS") {
p.max_ms = v.max(500);
}
if let Some(v) = env_millis("RPI_VOICE_SILENCE_MS") {
p.silence_ms = v;
}
if let Some(v) = env_millis("RPI_VOICE_MIN_SPEECH_MS") {
p.min_speech_ms = v;
}
if let Some(v) = env_millis("RPI_VOICE_NO_SPEECH_MS") {
p.no_speech_ms = Some(v);
}
if let Some(v) = env_millis("RPI_VOICE_WARMUP_MS") {
p.warmup_ms = v;
}
p
}
pub fn for_auto_turn() -> Self {
let mut p = Self::from_env();
if std::env::var("RPI_VOICE_RECORD_MS").is_err() {
p.max_ms = 60_000;
}
if std::env::var("RPI_VOICE_NO_SPEECH_MS").is_err() {
p.no_speech_ms = Some(10_000);
}
if std::env::var("RPI_VOICE_WARMUP_MS").is_err() {
p.warmup_ms = 800;
}
p
}
}
fn env_millis(name: &str) -> Option<u64> {
std::env::var(name)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
}
#[derive(Clone)]
pub struct Recording {
pub samples: Vec<i16>,
pub sample_rate: u32,
pub channels: u16,
pub peak_level: f32,
pub speech_ms: u64,
pub device_name: String,
pub gain: f32,
pub clipped_ratio: f32,
}
impl Recording {
pub fn duration_secs(&self) -> f64 {
if self.sample_rate == 0 || self.channels == 0 {
return 0.0;
}
self.samples.len() as f64 / (self.sample_rate as f64 * self.channels as f64)
}
pub fn to_wav_bytes(&self) -> Result<Vec<u8>, String> {
let gain = self.stt_normalisation_gain();
let samples_16k: Vec<i16> = self
.resample_to_16k_mono()
.into_iter()
.map(|sample| {
if gain == 1.0 {
sample
} else {
((sample as f32 * gain).clamp(i16::MIN as f32, i16::MAX as f32)) as i16
}
})
.collect();
let spec = hound::WavSpec {
channels: 1,
sample_rate: 16000,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut cursor = std::io::Cursor::new(Vec::new());
let mut writer = hound::WavWriter::new(&mut cursor, spec)
.map_err(|e| format!("WAV writer error: {e}"))?;
for sample in &samples_16k {
writer
.write_sample(*sample)
.map_err(|e| format!("WAV write error: {e}"))?;
}
writer
.finalize()
.map_err(|e| format!("WAV finalize error: {e}"))?;
Ok(cursor.into_inner())
}
#[cfg(feature = "local-stt")]
pub fn to_f32_16k_mono(&self) -> Vec<f32> {
let mut samples: Vec<f32> = self
.resample_to_16k_mono()
.into_iter()
.map(|s| s as f32 / 32768.0)
.collect();
let gain = self.stt_normalisation_gain();
if gain != 1.0 {
for sample in &mut samples {
*sample = (*sample * gain).clamp(-1.0, 1.0);
}
}
samples
}
fn stt_normalisation_gain(&self) -> f32 {
match input_gain_pref() {
GainPref::Fixed(gain) => gain,
GainPref::Auto => {
if self.peak_level <= 0.0 {
return 1.0;
}
(STT_TARGET_PEAK / self.peak_level).clamp(1.0, MAX_AUTO_GAIN)
}
}
}
fn resample_to_16k_mono(&self) -> Vec<i16> {
let mono: Vec<i16> = if self.channels == 1 {
self.samples.clone()
} else {
let ch = self.channels as usize;
self.samples
.chunks(ch)
.map(|chunk| {
let sum: i32 = chunk.iter().map(|&s| s as i32).sum();
(sum / ch as i32) as i16
})
.collect()
};
let src_rate = self.sample_rate as f64;
let target_rate = 16000.0;
if (src_rate - target_rate).abs() < 1.0 {
return mono;
}
let ratio = target_rate / src_rate;
let out_len = (mono.len() as f64 * ratio) as usize;
let mut out = Vec::with_capacity(out_len);
for i in 0..out_len {
let src_pos = i as f64 / ratio;
let idx = src_pos as usize;
let frac = src_pos - idx as f64;
if idx + 1 < mono.len() {
let interpolated = (1.0 - frac) * mono[idx] as f64 + frac * mono[idx + 1] as f64;
out.push(interpolated as i16);
} else if idx < mono.len() {
out.push(mono[idx]);
}
}
out
}
}
#[derive(Default)]
struct VoiceActivity {
last_voice: Option<Instant>,
speech_ms: u64,
peak: f32,
noise_rms: f64,
frames: u64,
}
#[derive(Clone, Default)]
pub struct LevelMeter(Arc<std::sync::atomic::AtomicU32>);
impl LevelMeter {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self) -> f32 {
self.0.load(Ordering::Relaxed) as f32 / 10_000.0
}
pub fn set(&self, level: f32) {
let scaled = (level.clamp(0.0, 1.0) * 10_000.0) as u32;
self.0.store(scaled, Ordering::Relaxed);
}
}
type SampleFeed = Box<dyn FnMut(&[i16]) + Send>;
pub fn record_until_with_level(
stop: Arc<AtomicBool>,
params: RecordParams,
meter: Option<LevelMeter>,
) -> Result<Recording, String> {
let host = cpal::default_host();
let (device, device_name) = select_input_device(&host)?;
let config = device
.default_input_config()
.map_err(|e| format!("Input config error: {e}"))?;
let sample_rate = config.sample_rate().0;
let channels = config.channels();
let samples: Arc<Mutex<Vec<i16>>> = Arc::new(Mutex::new(Vec::new()));
let activity: Arc<Mutex<VoiceActivity>> = Arc::new(Mutex::new(VoiceActivity::default()));
let clip_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let total_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let stop_flag = stop.clone();
let capture_started = Instant::now();
let err_fn = |err| eprintln!("Audio stream error: {err}");
let gain = match input_gain_pref() {
GainPref::Fixed(gain) => gain,
GainPref::Auto => 1.0,
};
let mut feed: SampleFeed = {
let samples = samples.clone();
let activity = activity.clone();
let stop = stop_flag.clone();
let meter = meter.clone();
let channels = channels as usize;
let sample_rate = sample_rate as f64;
let clip_counter = clip_counter.clone();
let total_counter = total_counter.clone();
let warmup_ms = params.warmup_ms;
Box::new(move |data: &[i16]| {
if stop.load(Ordering::Relaxed) {
return;
}
let boosted;
let data: &[i16] = if gain == 1.0 {
data
} else {
let mut clipped = 0usize;
let out = data
.iter()
.map(|sample| {
let scaled = *sample as f32 * gain;
if scaled > i16::MAX as f32 || scaled < i16::MIN as f32 {
clipped += 1;
}
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
})
.collect::<Vec<i16>>();
if !out.is_empty() {
clip_counter.fetch_add(clipped, Ordering::Relaxed);
total_counter.fetch_add(out.len(), Ordering::Relaxed);
}
boosted = out;
&boosted
};
samples.lock().unwrap().extend_from_slice(data);
if let Some(meter) = &meter {
let peak = data
.iter()
.map(|s| (s.unsigned_abs() as f32) / i16::MAX as f32)
.fold(0.0_f32, f32::max);
meter.set(peak);
}
let channels = channels.max(1);
let frames = data.len() / channels;
if frames == 0 {
return;
}
let sum_sq: f64 = data
.iter()
.map(|&s| {
let f = s as f64;
f * f
})
.sum();
let rms = (sum_sq / data.len() as f64).sqrt();
let frame_ms = (frames as f64 / sample_rate * 1000.0) as u64;
{
let mut a = activity.lock().unwrap();
let level = (rms / i16::MAX as f64) as f32;
if level > a.peak {
a.peak = level;
}
let in_warmup = (capture_started.elapsed().as_millis() as u64) < warmup_ms;
if !in_warmup && rms >= speech_threshold(a.noise_rms) {
a.last_voice = Some(Instant::now());
a.speech_ms = a.speech_ms.saturating_add(frame_ms);
} else {
a.noise_rms = if a.frames == 0 {
rms
} else {
a.noise_rms * (1.0 - NOISE_EMA_WEIGHT) + rms * NOISE_EMA_WEIGHT
};
}
a.frames = a.frames.saturating_add(1);
}
})
};
let stream = match config.sample_format() {
cpal::SampleFormat::I16 => {
let mut feed = feed;
device.build_input_stream(
&config.into(),
move |data: &[i16], _: &cpal::InputCallbackInfo| feed(data),
err_fn,
None,
)
}
cpal::SampleFormat::F32 => device.build_input_stream(
&config.into(),
move |data: &[f32], _: &cpal::InputCallbackInfo| {
let i16_data: Vec<i16> = data
.iter()
.map(|&s| (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16)
.collect();
feed(&i16_data);
},
err_fn,
None,
),
fmt => return Err(format!("Unsupported sample format: {fmt:?}")),
};
let stream = stream.map_err(|e| format!("Build input stream error: {e}"))?;
stream
.play()
.map_err(|e| format!("Play stream error: {e}"))?;
let started = Instant::now();
loop {
if stop.load(Ordering::Relaxed) {
break;
}
let elapsed = started.elapsed();
if elapsed.as_millis() as u64 >= params.max_ms {
break;
}
if params.silence_ms > 0 {
let elapsed_ms = elapsed.as_millis() as u64;
let wait_elapsed_ms = elapsed_ms.saturating_sub(params.warmup_ms);
let (speech_ms, last_voice) = {
let a = activity.lock().unwrap();
(a.speech_ms, a.last_voice)
};
if let Some(no_speech_ms) = params.no_speech_ms {
let quiet_ms = last_voice
.map(|t| t.elapsed().as_millis() as u64)
.unwrap_or(wait_elapsed_ms);
if quiet_ms >= no_speech_ms {
break;
}
}
if speech_ms >= params.min_speech_ms {
let silence = last_voice.map(|t| t.elapsed()).unwrap_or(elapsed);
if silence.as_millis() as u64 >= params.silence_ms {
break;
}
}
}
std::thread::sleep(Duration::from_millis(20));
}
std::thread::sleep(Duration::from_millis(50));
drop(stream);
let final_samples = samples.lock().unwrap().clone();
let (peak_level, speech_ms) = {
let a = activity.lock().unwrap();
(a.peak, a.speech_ms)
};
let clipped = clip_counter.load(Ordering::Relaxed);
let total = total_counter.load(Ordering::Relaxed);
let clipped_ratio = if total == 0 {
0.0
} else {
clipped as f32 / total as f32
};
Ok(Recording {
samples: final_samples,
sample_rate,
channels,
peak_level,
speech_ms,
device_name,
gain,
clipped_ratio,
})
}
fn config_path() -> Option<std::path::PathBuf> {
if let Some(explicit) = std::env::var_os("RPI_VOICE_CONFIG") {
return Some(std::path::PathBuf::from(explicit));
}
let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"))?;
Some(
std::path::PathBuf::from(home)
.join(".rpi")
.join("agent")
.join("voice.json"),
)
}
fn load_config() -> serde_json::Value {
config_path()
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|text| serde_json::from_str(&text).ok())
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::json!({}))
}
fn save_config(key: &str, value: &str) -> Result<std::path::PathBuf, String> {
let path = config_path().ok_or("no HOME/USERPROFILE to store settings under")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let mut config = load_config();
config[key] = serde_json::Value::String(value.to_string());
std::fs::write(&path, config.to_string()).map_err(|e| e.to_string())?;
Ok(path)
}
fn clear_config(key: &str) -> Result<(), String> {
let Some(path) = config_path() else {
return Ok(());
};
let mut config = load_config();
if !config.is_object() || config.get(key).is_none() {
return Ok(());
}
config.as_object_mut().unwrap().remove(key);
std::fs::write(&path, config.to_string()).map_err(|e| e.to_string())
}
fn input_device_pref() -> Option<String> {
std::env::var("RPI_VOICE_INPUT_DEVICE")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
load_config()
.get("input_device")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.filter(|value| !value.is_empty())
})
}
pub fn set_input_device_pref(selector: &str) -> Result<String, String> {
let selector = selector.trim();
if selector.is_empty() {
return Err("empty selector".to_string());
}
let names = list_input_devices();
let matched = names.iter().find(|name| {
name.to_ascii_lowercase()
.contains(&selector.to_ascii_lowercase())
});
let Some(matched) = matched else {
return Err(format!(
"no input device matches '{selector}'. Known: {}",
if names.is_empty() {
"<none>".to_string()
} else {
names.join(", ")
}
));
};
save_config("input_device", matched)?;
Ok(matched.clone())
}
pub fn list_input_devices() -> Vec<String> {
match cpal::default_host().input_devices() {
Ok(devices) => devices.filter_map(|device| device.name().ok()).collect(),
Err(_) => Vec::new(),
}
}
pub fn system_default_device() -> Option<String> {
cpal::default_host()
.default_input_device()
.and_then(|device| device.name().ok())
}
pub fn configured_device_pref() -> Option<String> {
input_device_pref()
}
fn select_input_device(host: &cpal::Host) -> Result<(cpal::Device, String), String> {
let configured_from_env = std::env::var("RPI_VOICE_INPUT_DEVICE")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let wanted = configured_from_env.clone().or_else(|| {
load_config()
.get("input_device")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.filter(|value| !value.is_empty())
});
let wanted_lower = wanted.as_ref().map(|value| value.to_ascii_lowercase());
if let Some(wanted_lower) = wanted_lower {
if let Ok(devices) = host.input_devices() {
for device in devices {
let name = device.name().unwrap_or_default();
if name.to_ascii_lowercase().contains(&wanted_lower) {
return Ok((device, name));
}
}
}
if configured_from_env.is_none() {
let _ = clear_config("input_device");
}
return default_input_device(host);
}
default_input_device(host)
}
fn default_input_device(host: &cpal::Host) -> Result<(cpal::Device, String), String> {
let device = host
.default_input_device()
.ok_or_else(|| "No input device (microphone) found".to_string())?;
let name = device.name().unwrap_or_else(|_| "<unnamed>".to_string());
Ok((device, name))
}
pub fn active_input_device() -> String {
match select_input_device(&cpal::default_host()) {
Ok((_, name)) => name,
Err(error) => format!("<none: {error}>"),
}
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
#[ignore = "needs a microphone and a human to speak"]
fn mic_probe_reports_what_the_default_input_delivers() {
let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let host = cpal::default_host();
println!("--- input devices ---");
if let Ok(devices) = host.input_devices() {
for device in devices {
let name = device.name().unwrap_or_else(|_| "<unnamed>".to_string());
let cfg = device.default_input_config().map(|c| {
format!(
"{}Hz {}ch {:?}",
c.sample_rate().0,
c.channels(),
c.sample_format()
)
});
println!(
" - {name} [{}]",
cfg.unwrap_or_else(|e| format!("no config: {e}"))
);
}
}
match host.default_input_device() {
Some(device) => println!(
"DEFAULT INPUT: {}",
device.name().unwrap_or_else(|_| "<unnamed>".to_string())
),
None => {
println!("DEFAULT INPUT: <none>");
return;
}
}
let params = RecordParams {
max_ms: 4_000,
silence_ms: 0,
min_speech_ms: 0,
no_speech_ms: None,
warmup_ms: 0,
};
let stop = Arc::new(AtomicBool::new(false));
println!("--- recording 4s: SPEAK NOW ---");
let recording = record_until_with_level(stop, params, None).expect("recording");
let rms = {
let sum_sq: f64 = recording
.samples
.iter()
.map(|s| {
let f = *s as f64;
f * f
})
.sum();
if recording.samples.is_empty() {
0.0
} else {
(sum_sq / recording.samples.len() as f64).sqrt()
}
};
println!(
"samples={} rate={} ch={} dur={:.2}s",
recording.samples.len(),
recording.sample_rate,
recording.channels,
recording.duration_secs()
);
println!("rms={:.2} (of {})", rms, i16::MAX);
report_capture(&recording, "default");
if recording.peak_level == 0.0 {
println!("VERDICT: the device delivered pure digital silence (muted / wrong device).");
} else if recording.speech_ms == 0 {
println!(
"VERDICT: audio arrived (peak {:.4}) but stayed under the speech threshold.",
recording.peak_level
);
} else {
println!("VERDICT: speech was detected — capture works.");
}
}
fn per_channel_peak(recording: &Recording) -> Vec<f32> {
let channels = (recording.channels as usize).max(1);
(0..channels)
.map(|channel| {
recording
.samples
.iter()
.skip(channel)
.step_by(channels)
.map(|s| (s.unsigned_abs() as f32) / i16::MAX as f32)
.fold(0.0_f32, f32::max)
})
.collect()
}
fn per_second_peak(recording: &Recording) -> Vec<f32> {
let channels = (recording.channels as usize).max(1);
let per_second = (recording.sample_rate as usize * channels).max(1);
recording
.samples
.chunks(per_second)
.map(|second| {
second
.iter()
.map(|s| (s.unsigned_abs() as f32) / i16::MAX as f32)
.fold(0.0_f32, f32::max)
})
.collect()
}
fn write_probe_wav(recording: &Recording, tag: &str) -> Result<std::path::PathBuf, String> {
let path = std::env::temp_dir().join(format!("rpi-voice-probe-{tag}.wav"));
let spec = hound::WavSpec {
channels: recording.channels,
sample_rate: recording.sample_rate,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::create(&path, spec).map_err(|e| e.to_string())?;
for sample in &recording.samples {
writer.write_sample(*sample).map_err(|e| e.to_string())?;
}
writer.finalize().map_err(|e| e.to_string())?;
Ok(path)
}
fn report_capture(recording: &Recording, tag: &str) {
let channels = per_channel_peak(recording);
let per_second = per_second_peak(recording);
println!(
" used='{}' {:.2}s {}Hz {}ch",
recording.device_name,
recording.duration_secs(),
recording.sample_rate,
recording.channels
);
println!(
" peak={:.4} speech={}ms per-channel peak={:?}",
recording.peak_level,
recording.speech_ms,
channels
.iter()
.map(|p| format!("{p:.4}"))
.collect::<Vec<_>>()
);
println!(
" level per second={:?}",
per_second
.iter()
.map(|p| format!("{p:.3}"))
.collect::<Vec<_>>()
);
match write_probe_wav(recording, tag) {
Ok(path) => println!(" LISTEN: {}", path.display()),
Err(error) => println!(" (could not write wav: {error})"),
}
}
#[test]
#[ignore = "needs a microphone"]
fn mic_probe_on_a_spawned_thread_matches_the_main_thread() {
let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let params = || RecordParams {
max_ms: 3_000,
silence_ms: 0,
min_speech_ms: 0,
no_speech_ms: None,
warmup_ms: 0,
};
println!("--- main thread ---");
let on_main =
record_until_with_level(Arc::new(AtomicBool::new(false)), params(), None).unwrap();
println!(
" peak={:.4} samples={} device='{}'",
on_main.peak_level,
on_main.samples.len(),
on_main.device_name
);
println!("--- spawned thread (what the extension does) ---");
let handle = std::thread::Builder::new()
.name("probe-worker".to_string())
.spawn(move || {
record_until_with_level(Arc::new(AtomicBool::new(false)), params(), None)
})
.unwrap();
let on_worker = handle.join().unwrap().unwrap();
println!(
" peak={:.4} samples={} device='{}'",
on_worker.peak_level,
on_worker.samples.len(),
on_worker.device_name
);
if on_main.peak_level > 0.0 && on_worker.peak_level == 0.0 {
println!("VERDICT: capture only works on the main thread — the extension's worker thread is the bug.");
} else if on_worker.peak_level == 0.0 {
println!("VERDICT: both silent — nothing is reaching the microphone right now.");
} else {
println!("VERDICT: both captured audio — the worker thread is fine.");
}
}
#[test]
#[ignore = "needs microphones and a human to speak"]
fn mic_probe_compares_every_input_device() {
let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let host = cpal::default_host();
let default = host
.default_input_device()
.and_then(|device| device.name().ok());
println!("system default = {default:?}");
let names: Vec<String> = match host.input_devices() {
Ok(devices) => devices.filter_map(|device| device.name().ok()).collect(),
Err(error) => {
println!("cannot enumerate input devices: {error}");
return;
}
};
if names.is_empty() {
println!("no input devices");
return;
}
let previous = std::env::var("RPI_VOICE_INPUT_DEVICE").ok();
for (index, name) in names.iter().enumerate() {
std::env::set_var("RPI_VOICE_INPUT_DEVICE", name);
println!("\n--- {name} : SPEAK NOW (3s) ---");
let ms = std::env::var("RPI_VOICE_PROBE_MS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(4_000);
let params = RecordParams {
max_ms: ms,
silence_ms: 0,
min_speech_ms: 0,
no_speech_ms: None,
warmup_ms: 0,
};
let stop = Arc::new(AtomicBool::new(false));
match record_until_with_level(stop, params, None) {
Ok(recording) => report_capture(&recording, &format!("{index}")),
Err(error) => println!(" FAILED: {error}"),
}
}
match previous {
Some(value) => std::env::set_var("RPI_VOICE_INPUT_DEVICE", value),
None => std::env::remove_var("RPI_VOICE_INPUT_DEVICE"),
}
println!("\nPoint the extension at the winner:");
println!(" RPI_VOICE_INPUT_DEVICE=<substring of that name> rpi");
}
#[test]
fn gain_pref_is_forgiving_and_defaults_to_auto() {
let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("rpi-voice-gain-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let config = dir.join("voice.json");
let _ = std::fs::remove_file(&config);
let previous_config = std::env::var("RPI_VOICE_CONFIG").ok();
let previous_gain = std::env::var("RPI_VOICE_INPUT_GAIN").ok();
std::env::set_var("RPI_VOICE_CONFIG", &config);
std::env::remove_var("RPI_VOICE_INPUT_GAIN");
assert_eq!(input_gain_pref(), GainPref::Auto, "unset means auto");
std::env::set_var("RPI_VOICE_INPUT_GAIN", "auto");
assert_eq!(input_gain_pref(), GainPref::Auto);
for unchanged in ["1", "1.0", "0", "-5", "junk", "NaN", "inf"] {
std::env::set_var("RPI_VOICE_INPUT_GAIN", unchanged);
assert_eq!(
input_gain_pref(),
GainPref::Fixed(1.0),
"`{unchanged}` must leave the signal alone"
);
}
std::env::set_var("RPI_VOICE_INPUT_GAIN", "20");
assert_eq!(input_gain_pref(), GainPref::Fixed(20.0));
std::env::set_var("RPI_VOICE_INPUT_GAIN", "100000");
assert_eq!(input_gain_pref(), GainPref::Fixed(MAX_INPUT_GAIN));
std::env::remove_var("RPI_VOICE_INPUT_GAIN");
assert!(set_input_gain_pref("nonsense").is_err());
assert!(set_input_gain_pref("0.5").is_err());
assert_eq!(
set_input_gain_pref("auto").unwrap(),
"auto (normalise each recording for STT)"
);
assert_eq!(input_gain_pref(), GainPref::Auto);
set_input_gain_pref("12").unwrap();
assert_eq!(input_gain_pref(), GainPref::Fixed(12.0));
match previous_config {
Some(value) => std::env::set_var("RPI_VOICE_CONFIG", value),
None => std::env::remove_var("RPI_VOICE_CONFIG"),
}
match previous_gain {
Some(value) => std::env::set_var("RPI_VOICE_INPUT_GAIN", value),
None => std::env::remove_var("RPI_VOICE_INPUT_GAIN"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn auto_gain_lifts_a_quiet_capture_only() {
let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let previous = std::env::var("RPI_VOICE_INPUT_GAIN").ok();
std::env::set_var("RPI_VOICE_INPUT_GAIN", "auto");
let quiet = Recording {
samples: vec![0; 16],
sample_rate: 16000,
channels: 1,
peak_level: 0.09,
speech_ms: 500,
device_name: "test".to_string(),
gain: 1.0,
clipped_ratio: 0.0,
};
let gain = quiet.stt_normalisation_gain();
assert!(gain > 5.0, "a 0.09 peak should be lifted, got {gain}");
assert!(gain <= MAX_AUTO_GAIN);
let healthy = Recording {
peak_level: 0.6,
..quiet.clone()
};
let healthy_gain = healthy.stt_normalisation_gain();
assert!(
(1.0..1.5).contains(&healthy_gain),
"a 0.6 peak should barely move, got {healthy_gain}"
);
let silent = Recording {
peak_level: 0.0,
..quiet.clone()
};
assert_eq!(silent.stt_normalisation_gain(), 1.0);
std::env::set_var("RPI_VOICE_INPUT_GAIN", "3");
assert_eq!(quiet.stt_normalisation_gain(), 3.0);
match previous {
Some(value) => std::env::set_var("RPI_VOICE_INPUT_GAIN", value),
None => std::env::remove_var("RPI_VOICE_INPUT_GAIN"),
}
}
#[test]
fn input_device_pref_prefers_env_then_file_and_rejects_typos() {
let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("rpi-voice-cfg-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let config = dir.join("voice.json");
let _ = std::fs::remove_file(&config);
let previous_config = std::env::var("RPI_VOICE_CONFIG").ok();
let previous_pref = std::env::var("RPI_VOICE_INPUT_DEVICE").ok();
std::env::set_var("RPI_VOICE_CONFIG", &config);
std::env::remove_var("RPI_VOICE_INPUT_DEVICE");
assert_eq!(input_device_pref(), None);
let error = set_input_device_pref("definitely-not-a-device").unwrap_err();
assert!(error.contains("no input device matches"), "{error}");
assert_eq!(
input_device_pref(),
None,
"a rejected selector must not be persisted"
);
if let Some(existing) = list_input_devices().into_iter().next() {
assert_eq!(set_input_device_pref(&existing).unwrap(), existing);
assert_eq!(input_device_pref().as_deref(), Some(existing.as_str()));
std::env::set_var("RPI_VOICE_INPUT_DEVICE", "env-override");
assert_eq!(input_device_pref().as_deref(), Some("env-override"));
std::env::remove_var("RPI_VOICE_INPUT_DEVICE");
}
std::fs::write(&config, "{ this is not json").unwrap();
assert_eq!(input_device_pref(), None);
match previous_config {
Some(value) => std::env::set_var("RPI_VOICE_CONFIG", value),
None => std::env::remove_var("RPI_VOICE_CONFIG"),
}
match previous_pref {
Some(value) => std::env::set_var("RPI_VOICE_INPUT_DEVICE", value),
None => std::env::remove_var("RPI_VOICE_INPUT_DEVICE"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn speech_threshold_adapts_to_the_measured_ambient() {
let quiet = speech_threshold(31.0);
assert_eq!(quiet, 31.0 * SPEECH_NOISE_RATIO);
assert!(
quiet < 260.0,
"a quiet microphone must not need 260 RMS to register speech: {quiet}"
);
let noisy = speech_threshold(300.0);
assert_eq!(noisy, 300.0 * SPEECH_NOISE_RATIO);
assert!(speech_threshold(0.0) > 0.0);
}
}