AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
// 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() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 07:48:22 +00:00
|
|
|
|
/// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
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() {
|
2026-11-05 07:48:22 +00:00
|
|
|
|
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),
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
_ => 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|