Skip to main content

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}