返回 CodeWhale
voice.rs
根目录 / crates / tui / src / voice.rs
1 //! Voice capture and transcription core: recorder detection, WAV encoding,
2 //! ASR selection and dispatch, the send-suffix contract, and the headless
3 //! record→transcribe cycle.
4 //!
5 //! Shared by the TUI's `/voice` commands (`commands::voice`, which keeps the
6 //! slash commands and the composer-updating capture loop) and the runtime
7 //! API's `/v1/voice` routes (`runtime_api::voice`). No UI types live here.
8 //!
9 //! ## Recording
10 //!
11 //! Uses platform-specific command-line tools (sox, rec, arecord) to capture
12 //! 16kHz mono 16-bit PCM audio. Records until a silence gap is detected or
13 //! the maximum duration is reached (default 10 s).
14
15 use std::process::{Command, Stdio};
16 use std::sync::LazyLock;
17 use std::time::Duration;
18
19 use regex::Regex;
20
21 use crate::config::Config;
22
23 /// Transcription model requested from the provider's chat-completions API.
24 const ASR_MODEL: &str = "mimo-v2.5-asr";
25 const GROQ_ASR_MODEL: &str = "whisper-large-v3-turbo";
26 /// Local whisper binary names to probe (whisper.cpp, faster-whisper, OpenAI whisper).
27 const LOCAL_WHISPER_BINS: &[&str] = &["whisper", "whisper.cpp", "whisper-cpp", "faster-whisper"];
28 /// Model used for the AI-assisted voice-control pipeline.
29 const VOICE_CONTROL_MODEL: &str = "mimo-v2.5";
30
31 // --- Recorder detection ----------------------------------------------------
32
33 /// Platform-specific recorder definitions.
34 #[derive(Debug, Clone)]
35 struct Recorder {
36 cmd: &'static str,
37 /// CLI arguments for piping raw 16kHz mono S16_LE PCM to stdout.
38 pipe_args: &'static [&'static str],
39 }
40
41 fn detect_recorder() -> Option<Recorder> {
42 // Operator kill-switch: a headless `serve --http` host has no business
43 // opening a microphone; disabling voice here makes `GET /v1/voice`
44 // report `available: false` and every dictate call fail closed.
45 if std::env::var_os("CODEWHALE_DISABLE_VOICE").is_some() {
46 return None;
47 }
48 let candidates: &[Recorder] = if cfg!(target_os = "macos") {
49 &[
50 Recorder {
51 cmd: "sox",
52 pipe_args: &["-d", "-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"],
53 },
54 Recorder {
55 cmd: "rec",
56 pipe_args: &["-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"],
57 },
58 ]
59 } else if cfg!(target_os = "linux") {
60 &[
61 Recorder {
62 cmd: "arecord",
63 pipe_args: &["-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw"],
64 },
65 Recorder {
66 cmd: "sox",
67 pipe_args: &["-d", "-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"],
68 },
69 ]
70 } else if cfg!(target_os = "windows") {
71 &[Recorder {
72 cmd: "sox",
73 pipe_args: &["-d", "-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"],
74 }]
75 } else {
76 &[]
77 };
78
79 candidates
80 .iter()
81 .find(|r| {
82 Command::new(r.cmd)
83 .arg("--version")
84 .stdin(Stdio::null())
85 .stdout(Stdio::null())
86 .stderr(Stdio::null())
87 .spawn()
88 .is_ok()
89 })
90 .cloned()
91 }
92
93 /// Check whether voice recording is available on this system.
94 pub fn is_available() -> bool {
95 detect_recorder().is_some()
96 }
97
98 // --- WAV encoding ----------------------------------------------------------
99
100 /// Encode raw 16kHz mono S16_LE PCM samples as a WAV buffer.
101 fn encode_wav(samples: &[i16]) -> Vec<u8> {
102 let data_size = (samples.len() * 2) as u32;
103 let sample_rate: u32 = 16000;
104 let mut buf = Vec::with_capacity(44 + data_size as usize);
105
106 // RIFF header
107 buf.extend_from_slice(b"RIFF");
108 buf.extend_from_slice(&(36 + data_size).to_le_bytes());
109 buf.extend_from_slice(b"WAVE");
110
111 // fmt chunk
112 buf.extend_from_slice(b"fmt ");
113 buf.extend_from_slice(&16u32.to_le_bytes()); // chunk size
114 buf.extend_from_slice(&1u16.to_le_bytes()); // PCM
115 buf.extend_from_slice(&1u16.to_le_bytes()); // mono
116 buf.extend_from_slice(&sample_rate.to_le_bytes());
117 buf.extend_from_slice(&(sample_rate * 2).to_le_bytes()); // byte rate
118 buf.extend_from_slice(&2u16.to_le_bytes()); // block align
119 buf.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
120
121 // data chunk
122 buf.extend_from_slice(b"data");
123 buf.extend_from_slice(&data_size.to_le_bytes());
124 for &sample in samples {
125 buf.extend_from_slice(&sample.to_le_bytes());
126 }
127
128 buf
129 }
130
131 // --- Recording -------------------------------------------------------------
132
133 /// Maximum recording duration in seconds before auto-stopping.
134 pub const MAX_RECORD_SECS: u64 = 10;
135 /// Minimum segment duration in seconds to consider as valid speech.
136 const MIN_SEGMENT_SECS: f64 = 0.3;
137
138 /// Record audio from the default microphone.
139 ///
140 /// Returns raw 16kHz mono S16_LE PCM samples. Returns `None` if no recorder
141 /// is available, the recording failed, or no speech was detected.
142 pub(crate) fn record_audio() -> Option<(Vec<i16>, Duration)> {
143 let recorder = detect_recorder()?;
144 let start = std::time::Instant::now();
145
146 let mut child = Command::new(recorder.cmd)
147 .args(recorder.pipe_args)
148 .stdin(Stdio::null())
149 .stdout(Stdio::piped())
150 .stderr(Stdio::null())
151 .spawn()
152 .ok()?;
153
154 let stdout = child.stdout.take()?;
155 let mut reader = std::io::BufReader::new(stdout);
156 let mut all_samples: Vec<i16> = Vec::with_capacity(16000 * MAX_RECORD_SECS as usize);
157
158 // Read until timeout or silence
159 let mut buf = [0u8; 320]; // 10ms of 16kHz S16_LE
160 let max_duration = Duration::from_secs(MAX_RECORD_SECS);
161 let mut silence_samples = 0u32;
162 let mut had_speech = false;
163 let speech_threshold: i16 = 500; // RMS-based speech detection threshold
164 let silence_duration_samples = 16000u32; // 1 second of silence to stop
165
166 loop {
167 use std::io::Read;
168 match reader.read_exact(&mut buf) {
169 Ok(()) => {
170 let chunk: Vec<i16> = buf
171 .as_chunks::<2>()
172 .0
173 .iter()
174 .copied()
175 .map(i16::from_le_bytes)
176 .collect();
177
178 // Simple RMS-based VAD
179 let rms = (chunk.iter().map(|&s| (s as f64) * (s as f64)).sum::<f64>()
180 / chunk.len() as f64)
181 .sqrt();
182 let is_speech = rms > speech_threshold as f64;
183
184 if is_speech {
185 had_speech = true;
186 silence_samples = 0;
187 } else if had_speech {
188 silence_samples += chunk.len() as u32;
189 }
190
191 if had_speech {
192 all_samples.extend_from_slice(&chunk);
193 }
194
195 if start.elapsed() > max_duration {
196 let _ = child.kill();
197 break;
198 }
199 if had_speech && silence_samples >= silence_duration_samples {
200 let _ = child.kill();
201 break;
202 }
203 }
204 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
205 Err(_) => {
206 let _ = child.kill();
207 break;
208 }
209 }
210 }
211
212 let _ = child.wait();
213 let elapsed = start.elapsed();
214
215 let min_samples = (MIN_SEGMENT_SECS * 16000.0) as usize;
216 if all_samples.len() < min_samples {
217 return None;
218 }
219
220 Some((all_samples, elapsed))
221 }
222
223 // --- Auto-send suffix ------------------------------------------------------
224
225 /// Trailing phrases that mean "submit this" — the human-readable form of
226 /// `SEND_SUFFIX_RE`; keep in sync with the regex when either changes.
227 pub const SEND_PHRASES: &[&str] = &["send it", "发送", "發送"];
228
229 /// Matches an explicit send instruction at the end of transcribed text:
230 /// "send it" (any spacing/case) or 发送/發送, with trailing punctuation.
231 static SEND_SUFFIX_RE: LazyLock<Regex> = LazyLock::new(|| {
232 Regex::new(r"(?i)(?:^|[\s,,.。!!??]+)(?:send\s*it|发送|發送)[\s.。!!??]*$").unwrap()
233 });
234
235 /// Split a transcript into the message remainder and whether it ended with an
236 /// explicit send instruction. `"ship the fix, send it"` → `("ship the fix", true)`.
237 pub(crate) fn split_send_suffix(text: &str) -> (&str, bool) {
238 match SEND_SUFFIX_RE.find(text) {
239 Some(found) => (text[..found.start()].trim(), true),
240 None => (text.trim(), false),
241 }
242 }
243
244 // --- Transcription ---------------------------------------------------------
245
246 fn base64_encode(data: &[u8]) -> String {
247 use base64::Engine;
248 base64::engine::general_purpose::STANDARD.encode(data)
249 }
250
251 fn chat_completions_url(base_url: &str) -> String {
252 format!("{}/chat/completions", base_url.trim_end_matches('/'))
253 }
254
255 async fn post_chat_completions(
256 api_key: &str,
257 base_url: &str,
258 mut body: serde_json::Value,
259 openrouter_vendor: Option<&str>,
260 ) -> Result<serde_json::Value, String> {
261 crate::client::apply_openrouter_vendor(&mut body, openrouter_vendor);
262 let _inference = crate::client::acquire_remote_control_inference_participant().await;
263 let client = crate::tls::reqwest_client();
264 let resp = client
265 .post(chat_completions_url(base_url))
266 .header("Content-Type", "application/json")
267 .header("Authorization", format!("Bearer {api_key}"))
268 .timeout(Duration::from_secs(30))
269 .json(&body)
270 .send()
271 .await
272 .map_err(|e| format!("request failed: {e}"))?;
273
274 if !resp.status().is_success() {
275 return Err(format!("API returned status {}", resp.status()));
276 }
277
278 resp.json()
279 .await
280 .map_err(|e| format!("failed to parse response: {e}"))
281 }
282
283 /// Send audio to the provider's API for plain transcription.
284 ///
285 /// Uses the chat completions endpoint with `input_audio` content blocks.
286 pub(crate) async fn transcribe(
287 api_key: &str,
288 base_url: &str,
289 audio_samples: &[i16],
290 openrouter_vendor: Option<&str>,
291 ) -> Result<String, String> {
292 transcribe_with_model(
293 api_key,
294 base_url,
295 audio_samples,
296 ASR_MODEL,
297 openrouter_vendor,
298 )
299 .await
300 }
301
302 async fn transcribe_with_model(
303 api_key: &str,
304 base_url: &str,
305 audio_samples: &[i16],
306 model: &str,
307 openrouter_vendor: Option<&str>,
308 ) -> Result<String, String> {
309 let wav = encode_wav(audio_samples);
310 let data_url = format!("data:audio/wav;base64,{}", base64_encode(&wav));
311
312 let body = serde_json::json!({
313 "model": model,
314 "messages": [
315 {
316 "role": "user",
317 "content": [
318 {
319 "type": "input_audio",
320 "input_audio": {
321 "data": data_url
322 }
323 }
324 ]
325 }
326 ],
327 "asr_options": {
328 "language": "auto"
329 }
330 });
331
332 let data = post_chat_completions(api_key, base_url, body, openrouter_vendor).await?;
333 data["choices"][0]["message"]["content"]
334 .as_str()
335 .map(|s| s.trim().to_string())
336 .ok_or_else(|| "no transcription in response".to_string())
337 }
338
339 /// Process audio through the voice-control pipeline: AI-assisted dictation
340 /// that sees the current composer text, mirroring MiMo Code's
341 /// `processVoiceControl`. Used when `/voice-control` is enabled.
342 pub(crate) async fn process_voice_control(
343 api_key: &str,
344 base_url: &str,
345 audio_samples: &[i16],
346 current_text: &str,
347 openrouter_vendor: Option<&str>,
348 ) -> Result<String, String> {
349 let wav = encode_wav(audio_samples);
350 let data_url = format!("data:audio/wav;base64,{}", base64_encode(&wav));
351
352 let user_context = serde_json::json!({
353 "current_text": current_text,
354 "cursor": "end",
355 });
356
357 let body = serde_json::json!({
358 "model": VOICE_CONTROL_MODEL,
359 "messages": [
360 {
361 "role": "system",
362 "content": "You are a voice input assistant. Transcribe the user's speech. Output JSON: {\"text\": \"transcribed text\"}."
363 },
364 {
365 "role": "user",
366 "content": [
367 { "type": "text", "text": user_context.to_string() },
368 { "type": "input_audio", "input_audio": { "data": data_url } }
369 ]
370 }
371 ],
372 "response_format": { "type": "json_object" }
373 });
374
375 let data = post_chat_completions(api_key, base_url, body, openrouter_vendor).await?;
376 let content = data["choices"][0]["message"]["content"]
377 .as_str()
378 .ok_or_else(|| "no response content".to_string())?;
379
380 let parsed: serde_json::Value = serde_json::from_str(content)
381 .map_err(|e| format!("failed to parse voice control JSON: {e}"))?;
382
383 parsed["text"]
384 .as_str()
385 .map(|s| s.to_string())
386 .ok_or_else(|| "no text field in voice control response".to_string())
387 }
388
389 /// Detect best free ASR for this host — local whisper > Groq free > provider fallback.
390 /// Works on macOS (brew install whisper-cpp), Windows (whisper.cpp binary),
391 /// Linux (apt), and HarmonyOS (falls back to cloud).
392 fn detect_free_asr() -> &'static str {
393 for bin in LOCAL_WHISPER_BINS {
394 if Command::new(bin)
395 .arg("--help")
396 .stdin(Stdio::null())
397 .stdout(Stdio::null())
398 .stderr(Stdio::null())
399 .spawn()
400 .is_ok()
401 {
402 return "local-whisper";
403 }
404 }
405 if std::env::var("GROQ_API_KEY").is_ok_and(|v| !v.trim().is_empty()) {
406 return "groq";
407 }
408 "provider"
409 }
410
411 /// Transcribe via local whisper.cpp (free, offline, cross-platform).
412 ///
413 /// The whole body is synchronous — temp-file I/O plus `Command::output()`,
414 /// which blocks for the entire subprocess run — so it runs on the blocking
415 /// pool rather than a Tokio worker (blocking-call convention, #6149).
416 pub(crate) async fn transcribe_local_whisper(audio_samples: &[i16]) -> Result<String, String> {
417 let wav = encode_wav(audio_samples);
418 tokio::task::spawn_blocking(move || transcribe_local_whisper_blocking(&wav))
419 .await
420 .map_err(|e| e.to_string())?
421 }
422
423 fn transcribe_local_whisper_blocking(wav: &[u8]) -> Result<String, String> {
424 // C01-10: a fresh, exclusively created file per call. The old
425 // `cw-voice-<pid>.wav` name was shared by every transcription in the
426 // process (an interim pass, the final pass, a runtime-API dictation) and
427 // predictable in a shared temp dir. Dropping `audio` removes it.
428 let audio = tempfile::Builder::new()
429 .prefix("cw-voice-")
430 .suffix(".wav")
431 .tempfile()
432 .map_err(|e| e.to_string())?;
433 let tmp = audio.path().to_path_buf();
434 std::fs::write(&tmp, wav).map_err(|e| e.to_string())?;
435 // Try each local binary until one succeeds; whisper.cpp outputs to stdout or file.
436 for bin in LOCAL_WHISPER_BINS {
437 let output = Command::new(bin)
438 .arg(tmp.to_string_lossy().as_ref())
439 .arg("--model")
440 .arg("tiny")
441 .arg("--language")
442 .arg("auto")
443 .arg("--output-txt")
444 .output();
445 if let Ok(out) = output
446 && out.status.success()
447 {
448 let txt = String::from_utf8_lossy(&out.stdout).trim().to_string();
449 if !txt.is_empty() {
450 return Ok(txt);
451 }
452 // Some builds write to .txt sidecar
453 let sidecar = tmp.with_extension("txt");
454 if let Ok(s) = std::fs::read_to_string(&sidecar) {
455 let _ = std::fs::remove_file(&sidecar);
456 if !s.trim().is_empty() {
457 return Ok(s.trim().to_string());
458 }
459 }
460 }
461 }
462 Err("local whisper not available".into())
463 }
464
465 /// Transcribe via Groq Whisper large-v3-turbo (free tier, ~$0.04/hr, fast).
466 /// Groq is NOT a full CodeWhale provider yet — this is a direct ASR call
467 /// using `GROQ_API_KEY` only (no provider setup needed). Uses the same
468 /// chat-completions `input_audio` path as Xiaomi so no `multipart` feature.
469 pub(crate) async fn transcribe_groq(audio_samples: &[i16]) -> Result<String, String> {
470 let api_key = std::env::var("GROQ_API_KEY").map_err(|_| "GROQ_API_KEY not set".to_string())?;
471 let base_url = "https://api.groq.com/openai/v1";
472 transcribe_with_model(&api_key, base_url, audio_samples, GROQ_ASR_MODEL, None).await
473 }
474
475 /// Resolve ASR model/provider preference.
476 /// Priority: env `CODEWHALE_ASR_MODEL` > auto-detect (local-whisper > groq >
477 /// provider). Known limitation (C01-10): there is no config-file ASR setting
478 /// yet — `config` is accepted for the day one exists and is not read, so the
479 /// env var is the only explicit override.
480 pub(crate) fn resolve_asr_choice(_config: &Config) -> (String, String) {
481 let explicit = std::env::var("CODEWHALE_ASR_MODEL")
482 .ok()
483 .and_then(|value| explicit_asr_choice(&value));
484 // Auto-detect best free: local whisper (offline, no key) > Groq free
485 // tier > provider ASR (needs key).
486 let (kind, model) = explicit.unwrap_or_else(|| match detect_free_asr() {
487 "local-whisper" => ("local-whisper", "tiny"),
488 "groq" => ("groq", GROQ_ASR_MODEL),
489 _ => ("provider", ASR_MODEL),
490 });
491 (kind.to_string(), model.to_string())
492 }
493
494 /// Map an explicit `CODEWHALE_ASR_MODEL` value to (kind, model). Local
495 /// spellings are tested first: `local-whisper` and `whisper.cpp` both contain
496 /// "whisper", which on its own names Groq's hosted Whisper (C01-10).
497 fn explicit_asr_choice(value: &str) -> Option<(&'static str, &'static str)> {
498 let m = value.trim().to_ascii_lowercase();
499 if m.contains("local") || m.contains("whisper.cpp") || m.contains("whisper-cpp") {
500 return Some(("local-whisper", "tiny"));
501 }
502 if m.contains("groq") || m.contains("whisper") {
503 return Some(("groq", GROQ_ASR_MODEL));
504 }
505 if m.contains("mimo") || m.contains("xiaomi") {
506 return Some(("provider", ASR_MODEL));
507 }
508 None
509 }
510
511 // --- Headless capture (HTTP/native-client path) ----------------------------
512
513 /// What a headless dictation should do with the finished transcript.
514 #[derive(Debug, Clone)]
515 pub enum DictateMode {
516 /// Transcribe and return the text for insertion into the composer.
517 Insert,
518 /// Transcribe, then apply the "send it" / 发送 suffix contract. The
519 /// outcome's `send` flag tells the client to submit; a bare send
520 /// instruction yields empty `text` so the client submits its own draft.
521 Send,
522 /// AI-assisted dictation that sees the client's composer text — the
523 /// `/voice-control` pipeline. Only provider ASR can see context; free
524 /// ASR kinds degrade to plain transcription with `assisted: false`.
525 Control(String),
526 }
527
528 /// Machine-readable failure for the headless path so HTTP clients can
529 /// localize by `reason` rather than parsing message text.
530 #[derive(Debug)]
531 pub enum DictateError {
532 /// No supported recorder binary on this host.
533 NoRecorder,
534 /// Recording produced no usable speech segment.
535 NoSpeech,
536 /// The selected provider ASR needs an API key that is not configured.
537 NoProviderAuth,
538 /// ASR request or transcription failed.
539 Transcription(String),
540 }
541
542 impl DictateError {
543 pub fn reason(&self) -> &'static str {
544 match self {
545 Self::NoRecorder => "no_recorder",
546 Self::NoSpeech => "no_speech",
547 Self::NoProviderAuth => "no_provider_auth",
548 Self::Transcription(_) => "transcription_failed",
549 }
550 }
551 }
552
553 impl std::fmt::Display for DictateError {
554 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
555 match self {
556 Self::NoRecorder => write!(f, "no supported voice recorder on this host"),
557 Self::NoSpeech => write!(f, "no speech detected"),
558 Self::NoProviderAuth => {
559 write!(f, "provider ASR requires a configured API key")
560 }
561 Self::Transcription(e) => write!(f, "{e}"),
562 }
563 }
564 }
565
566 /// Result of one headless record→transcribe cycle.
567 #[derive(Debug)]
568 pub struct DictationOutcome {
569 /// Final transcript (send suffix already stripped for `Send` mode).
570 pub text: String,
571 /// `Send` mode only: the transcript ended with an explicit send phrase.
572 pub send: bool,
573 /// `Control` mode only: the composer context reached the model. False
574 /// when a free ASR kind handled the audio and never saw the context.
575 pub assisted: bool,
576 /// Which ASR backend was selected for this capture.
577 pub asr_kind: String,
578 pub asr_model: String,
579 }
580
581 /// Detected recorder binary name, if any (`"sox"`, `"arecord"`, `"rec"`).
582 pub fn recorder_command() -> Option<&'static str> {
583 detect_recorder().map(|r| r.cmd)
584 }
585
586 /// Resolved ASR selection (`kind`, `model`) for capability reporting.
587 pub fn asr_choice(config: &Config) -> (String, String) {
588 resolve_asr_choice(config)
589 }
590
591 /// One record→transcribe cycle with no UI surface: the HTTP/native-client
592 /// equivalent of `commands::voice::capture_and_transcribe`. Recording runs on a blocking
593 /// thread; transcription follows the same ASR dispatch as the TUI —
594 /// explicit `CODEWHALE_ASR_MODEL` > local whisper > Groq > provider —
595 /// but resolves the provider key lazily so free ASR kinds work without
596 /// provider auth.
597 pub async fn dictate_once(
598 config: &Config,
599 mode: DictateMode,
600 ) -> Result<DictationOutcome, DictateError> {
601 if !is_available() {
602 return Err(DictateError::NoRecorder);
603 }
604 let (samples, _duration) = tokio::task::spawn_blocking(record_audio)
605 .await
606 .ok()
607 .flatten()
608 .ok_or(DictateError::NoSpeech)?;
609
610 let (asr_kind, asr_model) = resolve_asr_choice(config);
611 let assisted = asr_kind == "provider" && matches!(&mode, DictateMode::Control(_));
612 let composer = match &mode {
613 DictateMode::Control(composer) => Some(composer.as_str()),
614 _ => None,
615 };
616 let text = transcribe_selected(config, &asr_kind, &samples, composer).await?;
617
618 let clean = text.trim().to_string();
619 let (text, send) = match mode {
620 DictateMode::Send => {
621 let (remainder, wants_send) = split_send_suffix(&clean);
622 (remainder.to_string(), wants_send)
623 }
624 _ => (clean, false),
625 };
626 Ok(DictationOutcome {
627 text,
628 send,
629 assisted,
630 asr_kind,
631 asr_model,
632 })
633 }
634
635 /// One selected ASR route for both native/HTTP and TUI capture. A failure
636 /// stays on that route: it must not upload the recording to another provider.
637 pub(crate) async fn transcribe_selected(
638 config: &Config,
639 kind: &str,
640 samples: &[i16],
641 composer: Option<&str>,
642 ) -> Result<String, DictateError> {
643 let text = match kind {
644 "local-whisper" => transcribe_local_whisper(samples).await,
645 "groq" => transcribe_groq(samples).await,
646 _ => {
647 let key = config
648 .active_route_api_key()
649 .map_err(|_| DictateError::NoProviderAuth)?;
650 let base = config.active_route_base_url();
651 let vendor = config
652 .openrouter_vendor()
653 .map_err(|error| DictateError::Transcription(error.to_string()))?;
654 match composer {
655 Some(composer) => {
656 process_voice_control(&key, &base, samples, composer, vendor.as_deref()).await
657 }
658 None => transcribe(&key, &base, samples, vendor.as_deref()).await,
659 }
660 }
661 };
662 text.map_err(DictateError::Transcription)
663 }
664
665 #[cfg(test)]
666 mod tests {
667 mod selected_asr;
668
669 use super::*;
670
671 #[tokio::test]
672 async fn voice_requests_preserve_openrouter_vendor_pin() {
673 use wiremock::matchers::{method, path};
674 use wiremock::{Mock, MockServer, ResponseTemplate};
675
676 let server = MockServer::start().await;
677 Mock::given(method("POST"))
678 .and(path("/v1/chat/completions"))
679 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
680 "choices": [{ "message": { "content": "{\"text\":\"hello\"}" } }]
681 })))
682 .expect(3)
683 .mount(&server)
684 .await;
685 let base_url = format!("{}/v1", server.uri());
686 transcribe(
687 "fixture-key",
688 &base_url,
689 &[0; 16],
690 Some("chutes/region-fixture"),
691 )
692 .await
693 .unwrap();
694 process_voice_control(
695 "fixture-key",
696 &base_url,
697 &[0; 16],
698 "existing text",
699 Some("chutes/region-fixture"),
700 )
701 .await
702 .unwrap();
703 transcribe_with_model("fixture-key", &base_url, &[0; 16], GROQ_ASR_MODEL, None)
704 .await
705 .unwrap();
706
707 let requests = server.received_requests().await.unwrap();
708 assert_eq!(requests.len(), 3);
709 for request in &requests[..2] {
710 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
711 assert_eq!(
712 body["provider"],
713 serde_json::json!({"order": ["chutes/region-fixture"], "allow_fallbacks": false})
714 );
715 }
716 let independent: serde_json::Value = serde_json::from_slice(&requests[2].body).unwrap();
717 assert!(independent.get("provider").is_none());
718 }
719
720 #[test]
721 fn explicit_local_whisper_is_not_routed_to_groq() {
722 // C01-10: every local spelling contains "whisper"; none may reach the
723 // hosted Groq backend (which needs a key and sends audio off-host).
724 for local in ["local-whisper", "whisper.cpp", "whisper-cpp", " LOCAL "] {
725 assert_eq!(
726 explicit_asr_choice(local),
727 Some(("local-whisper", "tiny")),
728 "{local}"
729 );
730 }
731 for hosted in ["groq", "whisper", "whisper-large-v3-turbo"] {
732 assert_eq!(
733 explicit_asr_choice(hosted),
734 Some(("groq", GROQ_ASR_MODEL)),
735 "{hosted}"
736 );
737 }
738 assert_eq!(explicit_asr_choice("mimo"), Some(("provider", ASR_MODEL)));
739 assert_eq!(explicit_asr_choice("something-else"), None);
740 }
741
742 #[test]
743 fn wav_encoding_produces_valid_header() {
744 let samples = vec![0i16; 16000]; // 1 second of silence
745 let wav = encode_wav(&samples);
746 assert_eq!(&wav[0..4], b"RIFF");
747 assert_eq!(&wav[8..12], b"WAVE");
748 assert_eq!(&wav[12..16], b"fmt ");
749 // data size = 16000 * 2 = 32000
750 assert_eq!(&wav[4..8], &(36 + 32000u32).to_le_bytes());
751 }
752
753 #[test]
754 fn wav_encoding_empty_is_minimal() {
755 let wav = encode_wav(&[]);
756 assert_eq!(wav.len(), 44);
757 assert_eq!(&wav[4..8], &36u32.to_le_bytes());
758 }
759
760 #[test]
761 fn send_suffix_detected_and_stripped() {
762 assert_eq!(split_send_suffix("send it"), ("", true));
763 assert_eq!(split_send_suffix("Send It!"), ("", true));
764 assert_eq!(split_send_suffix("发送"), ("", true));
765 assert_eq!(split_send_suffix("發送。"), ("", true));
766 assert_eq!(
767 split_send_suffix("ship the fix, send it"),
768 ("ship the fix", true)
769 );
770 assert_eq!(
771 split_send_suffix("修复这个问题,发送"),
772 ("修复这个问题", true)
773 );
774 }
775
776 #[test]
777 fn send_suffix_leaves_plain_text_alone() {
778 assert_eq!(split_send_suffix("send it now"), ("send it now", false));
779 assert_eq!(
780 split_send_suffix("帮我发送一封邮件"),
781 ("帮我发送一封邮件", false)
782 );
783 assert_eq!(split_send_suffix("发送邮件"), ("发送邮件", false));
784 assert_eq!(
785 split_send_suffix("resend it to the queue"),
786 ("resend it to the queue", false)
787 );
788 }
789
790 #[test]
791 fn recorder_detection_does_not_crash() {
792 // Just verify the function runs without panicking
793 let _ = is_available();
794 }
795 }
796
796 lines RUST