ryu_stt/lib.rs
1//! Speech-to-text (STT) modality primitive: `transcribe(audio) -> text` behind a
2//! swappable engine seam.
3//!
4//! Three engines, one dispatch ([`transcribe_wav_detailed`]):
5//! - **parakeet** (default where the `voice-parakeet` feature is compiled): the
6//! in-process ONNX engine — the genuinely in-process hot path, never IPC (see
7//! [`parakeet`]).
8//! - **whisper**: forwarded to a local whisper.cpp voice server's `/inference`
9//! (a thin HTTP proxy).
10//! - **gateway**: the swappable cloud STT slot, routed through the Gateway's
11//! `/v1/audio/transcriptions` with the per-attribute `x-ryu-slot-stt-*` headers
12//! (a thin HTTP proxy).
13//!
14//! Per the Core-vs-Gateway rule the *dispatch* is a Core concern (it decides
15//! *what runs* — which local voice engine handles the audio); this crate owns the
16//! reusable transcription logic + result types, while the host couplings it
17//! cannot own — the whisper base-url, the Gateway url/bearer, and the parakeet
18//! model directory — are injected via the narrow [`SttHost`] trait. The crate has
19//! ZERO dependency on `apps/core` (mirrors `ryu-search`'s `SearchEmbedder` seam).
20
21use std::path::PathBuf;
22
23use serde_json::{json, Value};
24
25pub mod parakeet;
26
27/// Narrow host seam for the STT dispatch: the couplings the crate cannot own
28/// because they read Core config/paths (the whisper sidecar base-url, the Gateway
29/// url + bearer, and the extracted parakeet model directory). Core implements
30/// this in `apps/core/src/stt_host.rs`.
31pub trait SttHost: Send + Sync {
32 /// Base URL of the local whisper.cpp voice server (`{base}/inference`).
33 fn whisper_base_url(&self) -> String;
34 /// Base URL of the Gateway (`{base}/v1/audio/transcriptions`).
35 fn gateway_url(&self) -> String;
36 /// The Gateway bearer token slot (never a raw provider API key).
37 fn gateway_bearer(&self) -> Result<String, String>;
38 /// The extracted parakeet ONNX model directory (a `~/.ryu` path Core owns).
39 fn parakeet_model_dir(&self) -> PathBuf;
40}
41
42/// One timestamped transcript segment. Serialized camelCase
43/// (`startMs`/`endMs`/`text`) so it matches the cross-surface clip contract.
44#[derive(Debug, Clone, Default, serde::Serialize)]
45#[serde(rename_all = "camelCase")]
46pub struct TranscriptSegment {
47 pub start_ms: u64,
48 pub end_ms: u64,
49 pub text: String,
50}
51
52/// A transcription result: the full text plus optional timestamped segments.
53/// Segments are populated whenever the engine returns them (Whisper
54/// `verbose_json` via the Gateway or local whisper.cpp); parakeet returns text
55/// only, so its `segments` is empty.
56#[derive(Debug, Clone, Default)]
57pub struct Transcription {
58 pub text: String,
59 pub segments: Vec<TranscriptSegment>,
60}
61
62/// Parse OpenAI/whisper `verbose_json` `segments` (each with `start`/`end` in
63/// seconds and `text`) into millisecond [`TranscriptSegment`]s. An absent or
64/// malformed array yields an empty vec.
65fn parse_verbose_segments(body: &Value) -> Vec<TranscriptSegment> {
66 body.get("segments")
67 .and_then(Value::as_array)
68 .map(|arr| {
69 arr.iter()
70 .filter_map(|s| {
71 let start = s.get("start").and_then(Value::as_f64)?;
72 let end = s.get("end").and_then(Value::as_f64)?;
73 let text = s
74 .get("text")
75 .and_then(Value::as_str)
76 .unwrap_or("")
77 .trim()
78 .to_string();
79 Some(TranscriptSegment {
80 start_ms: (start.max(0.0) * 1000.0) as u64,
81 end_ms: (end.max(0.0) * 1000.0) as u64,
82 text,
83 })
84 })
85 .collect()
86 })
87 .unwrap_or_default()
88}
89
90/// The cross-surface default STT engine, resolved as a swappable default (never
91/// a hardcoded literal). Parakeet v3 (in-process ONNX) is the default whenever
92/// this build compiled the `voice-parakeet` feature — the shipped dev and
93/// release binaries do, so the installed app transcribes with parakeet out of the
94/// box. Lean CI/`cargo test` builds omit the feature and fall back to whisper.cpp
95/// so transcription still works there. `RYU_STT_ENGINE` overrides both, so one
96/// env var re-points every surface.
97pub fn default_stt_engine() -> String {
98 if let Ok(env_engine) = std::env::var("RYU_STT_ENGINE") {
99 let trimmed = env_engine.trim();
100 if !trimmed.is_empty() {
101 return trimmed.to_string();
102 }
103 }
104 #[cfg(feature = "voice-parakeet")]
105 {
106 "parakeet".to_string()
107 }
108 #[cfg(not(feature = "voice-parakeet"))]
109 {
110 "whisper".to_string()
111 }
112}
113
114/// Transcribe raw audio bytes to text. Routes to the in-process parakeet engine
115/// (the default — see [`default_stt_engine`]) or the whisper.cpp voice server
116/// (`engine == Some("whisper")`).
117///
118/// The reusable core of the `/api/voice/transcribe` route, factored out so other
119/// Core callers (e.g. the meetings pipeline) can transcribe a WAV chunk without
120/// going through an HTTP multipart handler. Returns the transcript or a
121/// human-readable error string.
122pub async fn transcribe_wav(
123 client: &reqwest::Client,
124 host: &dyn SttHost,
125 bytes: Vec<u8>,
126 filename: String,
127 engine: Option<&str>,
128) -> Result<String, String> {
129 transcribe_wav_detailed(client, host, bytes, filename, engine)
130 .await
131 .map(|t| t.text)
132}
133
134/// Like [`transcribe_wav`] but also returns timestamped segments when the engine
135/// provides them (Whisper `verbose_json` via the Gateway or local whisper.cpp).
136/// Parakeet (the in-process default) returns text only, so its segments are empty.
137pub async fn transcribe_wav_detailed(
138 client: &reqwest::Client,
139 host: &dyn SttHost,
140 bytes: Vec<u8>,
141 filename: String,
142 engine: Option<&str>,
143) -> Result<Transcription, String> {
144 // Resolve the engine: an explicit non-empty selector wins; otherwise fall
145 // back to the swappable cross-surface default (parakeet where compiled in).
146 let engine = engine
147 .map(str::trim)
148 .filter(|s| !s.is_empty())
149 .map(str::to_string)
150 .unwrap_or_else(default_stt_engine);
151
152 // Route to the in-process parakeet engine (default). Text only — no segments.
153 if engine == "parakeet" {
154 return parakeet::transcribe(bytes, host.parakeet_model_dir())
155 .await
156 .map(|text| Transcription {
157 text,
158 segments: Vec::new(),
159 })
160 .map_err(|e| format!("parakeet transcription failed: {e:#}"));
161 }
162
163 // Gateway-routed Whisper: the swappable cloud STT slot (default provider
164 // OpenAI, default model Groq's `whisper-large-v3`). Core emits only the
165 // per-attribute slot headers + a bearer to the Gateway — never a raw provider
166 // key (CLAUDE.md §1: routing/measuring the model call is a Gateway concern).
167 if engine == "gateway" {
168 return transcribe_via_gateway(client, host, bytes).await;
169 }
170
171 // Default: forward to whisper.cpp's `/inference` multipart endpoint. Request
172 // `verbose_json` so the response carries per-segment timings (whisper.cpp
173 // degrades to a plain `{ "text": ... }` when it can't, which parses to no
174 // segments — never an error).
175 let part = reqwest::multipart::Part::bytes(bytes).file_name(filename);
176 let form = reqwest::multipart::Form::new()
177 .part("file", part)
178 .text("response_format", "verbose_json");
179
180 let url = format!("{}/inference", host.whisper_base_url());
181 let resp = client
182 .post(&url)
183 .multipart(form)
184 .send()
185 .await
186 .map_err(|e| {
187 format!(
188 "whisper voice engine not reachable at {url}: {e}. \
189 Install + start `whispercpp` from the Store first."
190 )
191 })?;
192
193 if !resp.status().is_success() {
194 let status = resp.status();
195 let body = resp.text().await.unwrap_or_default();
196 return Err(format!("whisper returned {status}: {body}"));
197 }
198
199 // whisper.cpp returns `{ "text": "...", "segments": [...] }` for verbose_json.
200 let value: Value = resp
201 .json()
202 .await
203 .map_err(|e| format!("could not parse whisper response: {e}"))?;
204 let text = value
205 .get("text")
206 .and_then(Value::as_str)
207 .unwrap_or("")
208 .trim()
209 .to_string();
210 let segments = parse_verbose_segments(&value);
211 Ok(Transcription { text, segments })
212}
213
214/// Transcribe audio through the Gateway's `/v1/audio/transcriptions`, the
215/// swappable cloud STT slot. The audio is base64-encoded into a JSON body (Core
216/// carries no multipart to the Gateway) with the per-attribute slot headers that
217/// tell the Gateway which provider/model to route to. Bearer is the Gateway
218/// token slot — never a raw provider API key.
219///
220/// FLAG (whisper-gateway, pre-existing gap owned by `apps/gateway`, out of scope
221/// here): for true end-to-end the Gateway's OpenAI provider must re-multipart
222/// this base64 audio upstream — real Groq/OpenAI `/audio/transcriptions` need a
223/// multipart file, but `providers/openai.rs` currently forwards JSON verbatim.
224/// The Gateway owner must also point `modality_map[Stt]`/`base_url` at Groq. Until
225/// then, set `RYU_CLIP_STT_ENGINE=whisper` (local whisper.cpp) to ship without
226/// waiting — and captions-first means most YouTube ingests never hit Whisper.
227async fn transcribe_via_gateway(
228 client: &reqwest::Client,
229 host: &dyn SttHost,
230 bytes: Vec<u8>,
231) -> Result<Transcription, String> {
232 use base64::Engine as _;
233
234 let audio_b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
235
236 let provider = std::env::var("RYU_STT_GATEWAY_PROVIDER")
237 .ok()
238 .map(|s| s.trim().to_string())
239 .filter(|s| !s.is_empty())
240 .unwrap_or_else(|| "openai".to_string());
241 let model = std::env::var("RYU_STT_GATEWAY_MODEL")
242 .ok()
243 .map(|s| s.trim().to_string())
244 .filter(|s| !s.is_empty())
245 .unwrap_or_else(|| "whisper-large-v3".to_string());
246
247 let base = host.gateway_url();
248 let base = base.trim_end_matches('/');
249 let url = format!("{base}/v1/audio/transcriptions");
250 let bearer = host.gateway_bearer()?;
251
252 let payload = json!({
253 "model": model,
254 "file": audio_b64,
255 "response_format": "verbose_json",
256 });
257
258 let resp = client
259 .post(&url)
260 .bearer_auth(bearer)
261 .header("x-ryu-slot-stt-provider", &provider)
262 .header("x-ryu-slot-stt-model", &model)
263 .json(&payload)
264 .send()
265 .await
266 .map_err(|e| format!("gateway STT unreachable at {url}: {e}"))?;
267
268 if !resp.status().is_success() {
269 let status = resp.status();
270 let detail = resp.text().await.unwrap_or_default();
271 return Err(format!("gateway STT returned {status}: {detail}"));
272 }
273
274 let value: Value = resp
275 .json()
276 .await
277 .map_err(|e| format!("could not parse gateway STT response: {e}"))?;
278 let text = value
279 .get("text")
280 .and_then(Value::as_str)
281 .unwrap_or("")
282 .trim()
283 .to_string();
284 let segments = parse_verbose_segments(&value);
285 Ok(Transcription { text, segments })
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn parses_verbose_segments_seconds_to_ms() {
294 let body = json!({
295 "text": "hello world",
296 "segments": [
297 { "start": 0.0, "end": 1.5, "text": " hello" },
298 { "start": 1.5, "end": 2.25, "text": " world " },
299 ]
300 });
301 let segs = parse_verbose_segments(&body);
302 assert_eq!(segs.len(), 2);
303 assert_eq!(segs[0].start_ms, 0);
304 assert_eq!(segs[0].end_ms, 1500);
305 assert_eq!(segs[0].text, "hello");
306 assert_eq!(segs[1].start_ms, 1500);
307 assert_eq!(segs[1].end_ms, 2250);
308 assert_eq!(segs[1].text, "world");
309 }
310
311 #[test]
312 fn missing_or_malformed_segments_yield_empty() {
313 assert!(parse_verbose_segments(&json!({ "text": "x" })).is_empty());
314 assert!(parse_verbose_segments(&json!({ "segments": "not-an-array" })).is_empty());
315 // An entry missing start/end is skipped, not an error.
316 let partial = json!({ "segments": [ { "text": "no timings" } ] });
317 assert!(parse_verbose_segments(&partial).is_empty());
318 }
319
320 #[test]
321 fn default_engine_env_override_wins() {
322 // Save/restore to avoid leaking into other tests in the same process.
323 let prev = std::env::var("RYU_STT_ENGINE").ok();
324 std::env::set_var("RYU_STT_ENGINE", "gateway");
325 assert_eq!(default_stt_engine(), "gateway");
326 std::env::set_var("RYU_STT_ENGINE", " ");
327 // Blank falls through to the compiled default (parakeet or whisper).
328 let compiled = default_stt_engine();
329 assert!(compiled == "parakeet" || compiled == "whisper");
330 match prev {
331 Some(v) => std::env::set_var("RYU_STT_ENGINE", v),
332 None => std::env::remove_var("RYU_STT_ENGINE"),
333 }
334 }
335
336 #[test]
337 fn transcript_segment_serializes_camel_case() {
338 let seg = TranscriptSegment {
339 start_ms: 10,
340 end_ms: 20,
341 text: "hi".into(),
342 };
343 let v = serde_json::to_value(&seg).unwrap();
344 assert_eq!(v["startMs"], 10);
345 assert_eq!(v["endMs"], 20);
346 assert_eq!(v["text"], "hi");
347 }
348}