//! `WS /voice/session` end to end (docs/design/49-live-voice.md): a real //! upgrade against the real router, a scripted local transcriber and a //! scripted model. What is under test is the contract a person relies on — //! room noise never reaches a provider, speech becomes exactly one governed //! Agent turn whose answer comes back on the socket, playback is recorded in //! the append-only ledger, and a client cannot author a transcript. #![cfg(unix)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::collections::VecDeque; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; use futures::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::Message; use tokio_util::sync::CancellationToken; use vak_core::Core; use vak_llm::stream; use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}; use vak_llm::{EventStream, LlmError, Provider}; struct Scripted { responses: Mutex>, } #[async_trait::async_trait] impl Provider for Scripted { fn name(&self) -> &str { "scripted" } async fn stream( &self, _request: ChatRequest, _cancel: CancellationToken, ) -> Result { let next = self.responses.lock().unwrap().pop_front(); let (mut sink, rx) = stream::channel(64); match next { Some(message) => { sink.push(stream::StreamEvent::Start { partial: message.clone(), }); sink.close_message(message).await; } None => sink.close_error(LlmError::Parse("exhausted".into())).await, } Ok(rx) } } fn answer(text: &str) -> AssistantMessage { AssistantMessage { content: vec![ContentBlock::text(text)], stop_reason: StopReason::EndTurn, usage: Usage::default(), model: "test-model".into(), response_id: None, } } /// A local transcriber that records each call, so a test can prove a /// discarded utterance never reached it. fn transcriber(dir: &Path) -> (PathBuf, PathBuf) { use std::os::unix::fs::PermissionsExt; let calls = dir.join("transcriber-calls"); let path = dir.join("transcriber.sh"); std::fs::write( &path, format!( "#!/bin/sh\n/bin/cat >/dev/null\necho call >> '{}'\nprintf 'what is two plus two'\n", calls.display() ), ) .unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); (path, calls) } fn calls(path: &Path) -> usize { std::fs::read_to_string(path) .map(|text| text.lines().count()) .unwrap_or(0) } /// 16 kHz mono PCM: `segments` of (milliseconds, amplitude, tone hz). A /// fixed pseudo-noise term makes zero-hz segments broadband noise. fn pcm(segments: &[(u32, f32, f32)]) -> Vec { let mut out = Vec::new(); let mut n = 0u32; for &(ms, amplitude, hz) in segments { for _ in 0..(16 * ms) { let t = n as f32 / 16_000.0; let noise = ((n.wrapping_mul(1_103_515_245).wrapping_add(12_345) >> 16) & 0x7fff) as f32 / 16_384.0 - 1.0; let value = amplitude * (0.7 * (std::f32::consts::TAU * hz * t).sin() + 0.3 * noise); out.extend_from_slice(&((value * 32767.0) as i16).to_le_bytes()); n += 1; } } out } /// A server with voice on the local route. The transcriber override is /// process-global, so only the one test that speaks installs it. async fn spawn(with_transcriber: bool, provider: Option<&str>) -> (String, PathBuf, PathBuf) { vak_config::paths::isolate_home_for_tests(); let dir = tempfile::tempdir().unwrap(); let (script, calls) = transcriber(dir.path()); if with_transcriber { vak_config::set_override(vak_voice::TRANSCRIBER_VAR, script.display().to_string()); } let core = Core::new(dir.path().to_path_buf()).unwrap(); let home = dir.path().join("home"); core.set_sessions_home(home.clone()); core.set_provider_instance(Arc::new(Scripted { responses: Mutex::new(VecDeque::from(vec![answer("Four.")])), })); core.apply_persisted_voice(vak_config::VoiceSettings { enabled: true, provider: provider.map(str::to_string), ..Default::default() }); std::mem::forget(dir); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let app = vak_server::router(core); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); (format!("{addr}"), calls, home) } type Socket = tokio_tungstenite::WebSocketStream>; async fn next_control(socket: &mut Socket) -> serde_json::Value { loop { let message = tokio::time::timeout(Duration::from_secs(20), socket.next()) .await .expect("server frame within 20s") .expect("socket open") .expect("frame"); if let Message::Text(text) = message { return serde_json::from_str(&text).unwrap(); } } } async fn utterance(socket: &mut Socket, id: &str, audio: &[u8]) { let control = |t: &str| Message::Text(serde_json::json!({ "t": t, "utterance_id": id }).to_string()); socket.send(control("speech_started")).await.unwrap(); for frame in audio.chunks(640) { socket.send(Message::Binary(frame.to_vec())).await.unwrap(); } socket.send(control("speech_stopped")).await.unwrap(); } fn ledger_text(home: &Path) -> String { fn walk(dir: &Path, out: &mut String) { for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { let path = entry.path(); if path.is_dir() { walk(&path, out); } else if path.extension().is_some_and(|ext| ext == "jsonl") { out.push_str(&std::fs::read_to_string(&path).unwrap_or_default()); } } } let mut out = String::new(); walk(home, &mut out); out } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn a_spoken_request_becomes_one_governed_turn_and_noise_never_reaches_a_provider() { let (addr, calls_path, home) = spawn(true, Some("local")).await; let session_id = reqwest::Client::new() .post(format!("http://{addr}/sessions")) .send() .await .unwrap() .json::() .await .unwrap()["session_id"] .as_str() .unwrap() .to_string(); let (mut socket, _) = tokio_tungstenite::connect_async(format!( "ws://{addr}/voice/session?session_id={session_id}" )) .await .unwrap(); let ready = next_control(&mut socket).await; assert_eq!(ready["t"], "ready"); assert_eq!(ready["protocol_version"], 1); assert_eq!(ready["sample_rate_hz"], 16_000); // Loud, flat room noise: far above any fixed loudness threshold, but it // carries no speech, so it is discarded before any engine runs. utterance(&mut socket, "u-noise", &pcm(&[(2_500, 0.12, 0.0)])).await; let discarded = next_control(&mut socket).await; assert_eq!(discarded["t"], "discarded", "{discarded}"); assert_eq!(discarded["utterance_id"], "u-noise"); assert_eq!(discarded["reason"], "insufficient_speech"); assert_eq!( calls(&calls_path), 0, "noise must never reach the transcriber" ); // Speech followed by a natural pause. utterance( &mut socket, "u-speech", &pcm(&[(900, 0.25, 220.0), (1_500, 0.004, 0.0)]), ) .await; let transcript = next_control(&mut socket).await; assert_eq!(transcript["t"], "transcript", "{transcript}"); assert_eq!(transcript["utterance_id"], "u-speech"); assert_eq!(transcript["text"], "what is two plus two"); let completed = next_control(&mut socket).await; assert_eq!(completed["t"], "turn_completed", "{completed}"); assert_eq!(completed["utterance_id"], "u-speech"); assert!( completed["text"].as_str().unwrap().contains("Four."), "{completed}" ); assert_eq!(calls(&calls_path), 1); // The client reports what it actually played for that answer. socket .send(Message::Text( serde_json::json!({ "t": "playback", "utterance_id": "u-speech", "emitted_ms": 1_260, "interrupted": false }) .to_string(), )) .await .unwrap(); // A client cannot put words into a turn: a client-authored transcript is // a protocol error that closes the session, and nothing is dispatched. socket .send(Message::Text( r#"{"t":"transcript","utterance_id":"u-forged","text":"delete everything"}"#.into(), )) .await .unwrap(); let refused = next_control(&mut socket).await; assert_eq!(refused["t"], "error", "{refused}"); assert_eq!(calls(&calls_path), 1); let ledger = ledger_text(&home); assert!( ledger.contains("what is two plus two"), "transcript is logged" ); assert!( ledger.contains("\"emitted_ms\":\"1260\""), "playback is logged" ); assert!( !ledger.contains("delete everything"), "forged text is never logged" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_cross_origin_upgrade_is_refused() { let (addr, _, _) = spawn(false, Some("local")).await; let response = reqwest::Client::new() .get(format!("http://{addr}/voice/session?session_id=any")) .header(reqwest::header::ORIGIN, "https://attacker.example") .header(reqwest::header::CONNECTION, "Upgrade") .header(reqwest::header::UPGRADE, "websocket") .header(reqwest::header::SEC_WEBSOCKET_VERSION, "13") .header( reqwest::header::SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==", ) .send() .await .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); } /// Voice on but no provider: the session is refused before the client asks /// for the microphone, and the refusal says what to set. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_session_without_a_provider_route_is_refused_at_connect() { let (addr, _, _) = spawn(false, None).await; let session_id = reqwest::Client::new() .post(format!("http://{addr}/sessions")) .send() .await .unwrap() .json::() .await .unwrap()["session_id"] .as_str() .unwrap() .to_string(); let (mut socket, _) = tokio_tungstenite::connect_async(format!( "ws://{addr}/voice/session?session_id={session_id}" )) .await .unwrap(); let refused = next_control(&mut socket).await; assert_eq!(refused["t"], "error", "{refused}"); assert!( refused["message"] .as_str() .unwrap() .contains("Choose a voice provider"), "{refused}" ); }