返回 CodeWhale
voice.rs
根目录 / crates / tui / src / runtime_api / voice.rs
1 //! Voice/dictation HTTP for native clients (APPS-98).
2 //!
3 //! The runtime owns the host microphone and the ASR dispatch — recording and
4 //! transcription are the same implementation the TUI's `/voice` commands run,
5 //! exposed headlessly so a desktop client gets text back instead of driving a
6 //! terminal. There is deliberately no second speech stack and no audio upload
7 //! path: dictate means "record on the host this runtime runs on".
8 //!
9 //! Fail-closed as data: no recorder, no speech, missing provider auth, or an
10 //! ASR failure all answer `200` with `ok: false` + a machine-readable
11 //! `reason` — a desktop client reads `GET /v1/voice` first and disables its
12 //! dictation affordance when `available` is false.
13 //!
14 //! Routes:
15 //! GET /v1/voice — capability: recorder, ASR selection, send phrases
16 //! POST /v1/voice/dictate — record + transcribe → insert text
17 //! POST /v1/voice/send — record + transcribe + send-suffix detection
18 //! POST /v1/voice/control — record + assisted dictation; body {"composer"}
19
20 use axum::Json;
21 use axum::extract::State;
22 use serde::Deserialize;
23 use serde_json::{Value, json};
24
25 use super::{ApiError, RuntimeApiState};
26 use crate::commands::voice as voice_core;
27 use voice_core::{DictateError, DictateMode};
28
29 /// One mic per host — serialize captures so concurrent dictate requests get
30 /// an honest "no speech" for the loser rather than fighting over the device.
31 static DICTATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
32
33 /// `GET /v1/voice` — what this host can do: whether a recorder exists, which
34 /// ASR backend the live config resolves to, and the send-suffix contract.
35 pub(super) async fn voice_status(
36 State(state): State<RuntimeApiState>,
37 ) -> Result<Json<Value>, ApiError> {
38 let (asr_kind, asr_model) = {
39 let config = state.config.read();
40 voice_core::asr_choice(&config)
41 };
42 Ok(Json(json!({
43 "available": voice_core::is_available(),
44 "recorder": voice_core::recorder_command(),
45 "asr": { "kind": asr_kind, "model": asr_model },
46 "modes": ["insert", "send", "control"],
47 "send_phrases": voice_core::SEND_PHRASES,
48 "max_record_seconds": voice_core::MAX_RECORD_SECS,
49 })))
50 }
51
52 fn dictate_failure(error: DictateError) -> Json<Value> {
53 Json(json!({
54 "ok": false,
55 "reason": error.reason(),
56 "message": error.to_string(),
57 }))
58 }
59
60 async fn dictate(state: &RuntimeApiState, mode: DictateMode) -> Result<Json<Value>, ApiError> {
61 // Clone before recording: holding the config lock across a ~10s capture
62 // would stall unrelated config writes for the duration.
63 let config = state.config.read().clone();
64 let _permit = DICTATE_LOCK.lock().await;
65 match voice_core::dictate_once(&config, mode).await {
66 Ok(outcome) => Ok(Json(json!({
67 "ok": true,
68 "text": outcome.text,
69 "send": outcome.send,
70 "assisted": outcome.assisted,
71 "asr": { "kind": outcome.asr_kind, "model": outcome.asr_model },
72 }))),
73 Err(error) => Ok(dictate_failure(error)),
74 }
75 }
76
77 /// `POST /v1/voice/dictate` — record + transcribe → text to insert.
78 pub(super) async fn voice_dictate(
79 State(state): State<RuntimeApiState>,
80 ) -> Result<Json<Value>, ApiError> {
81 dictate(&state, DictateMode::Insert).await
82 }
83
84 /// `POST /v1/voice/send` — dictate with send-suffix detection; the client
85 /// submits when `send` is true (empty `text` = submit the current draft).
86 pub(super) async fn voice_send(
87 State(state): State<RuntimeApiState>,
88 ) -> Result<Json<Value>, ApiError> {
89 dictate(&state, DictateMode::Send).await
90 }
91
92 #[derive(Deserialize)]
93 #[serde(deny_unknown_fields)]
94 pub(super) struct VoiceControlRequest {
95 /// The client's current composer text — the model sees it for
96 /// AI-assisted dictation (the `/voice-control` pipeline).
97 #[serde(default)]
98 composer: String,
99 }
100
101 /// `POST /v1/voice/control` — assisted dictation with composer context.
102 /// `assisted: false` in the response means a free ASR kind handled the audio
103 /// and the composer text was never seen.
104 pub(super) async fn voice_control(
105 State(state): State<RuntimeApiState>,
106 body: Option<Json<VoiceControlRequest>>,
107 ) -> Result<Json<Value>, ApiError> {
108 let composer = body
109 .map(|Json(request)| request.composer)
110 .unwrap_or_default();
111 dictate(&state, DictateMode::Control(composer)).await
112 }
113
113 lines RUST