1use std::path::PathBuf;
22
23use serde_json::{json, Value};
24
25pub mod parakeet;
26
27pub trait SttHost: Send + Sync {
32 fn whisper_base_url(&self) -> String;
34 fn gateway_url(&self) -> String;
36 fn gateway_bearer(&self) -> Result<String, String>;
38 fn parakeet_model_dir(&self) -> PathBuf;
40}
41
42#[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#[derive(Debug, Clone, Default)]
57pub struct Transcription {
58 pub text: String,
59 pub segments: Vec<TranscriptSegment>,
60}
61
62fn 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
90pub fn default_stt_engine() -> String {
105 if let Ok(env_engine) = std::env::var("RYU_STT_ENGINE") {
106 let trimmed = env_engine.trim();
107 if !trimmed.is_empty() {
108 return trimmed.to_string();
109 }
110 }
111 #[cfg(feature = "voice-parakeet")]
112 {
113 "parakeet".to_string()
114 }
115 #[cfg(not(feature = "voice-parakeet"))]
116 {
117 "whisper".to_string()
118 }
119}
120
121pub async fn transcribe_wav(
130 client: &reqwest::Client,
131 host: &dyn SttHost,
132 bytes: Vec<u8>,
133 filename: String,
134 engine: Option<&str>,
135) -> Result<String, String> {
136 transcribe_wav_detailed(client, host, bytes, filename, engine)
137 .await
138 .map(|t| t.text)
139}
140
141pub async fn transcribe_wav_detailed(
145 client: &reqwest::Client,
146 host: &dyn SttHost,
147 bytes: Vec<u8>,
148 filename: String,
149 engine: Option<&str>,
150) -> Result<Transcription, String> {
151 let engine = engine
154 .map(str::trim)
155 .filter(|s| !s.is_empty())
156 .map(str::to_string)
157 .unwrap_or_else(default_stt_engine);
158
159 if engine == "parakeet" {
171 return parakeet::transcribe(bytes, host.parakeet_model_dir())
172 .await
173 .map(|text| Transcription {
174 text,
175 segments: Vec::new(),
176 })
177 .map_err(|e| format!("parakeet transcription failed: {e:#}"));
178 }
179
180 if engine == "gateway" {
185 return transcribe_via_gateway(client, host, bytes).await;
186 }
187
188 let part = reqwest::multipart::Part::bytes(bytes).file_name(filename);
193 let form = reqwest::multipart::Form::new()
194 .part("file", part)
195 .text("response_format", "verbose_json");
196
197 let url = format!("{}/inference", host.whisper_base_url());
198 let resp = client
199 .post(&url)
200 .multipart(form)
201 .send()
202 .await
203 .map_err(|e| {
204 format!(
205 "whisper voice engine not reachable at {url}: {e}. \
206 Install + start `whispercpp` from the Store first."
207 )
208 })?;
209
210 if !resp.status().is_success() {
211 let status = resp.status();
212 let body = resp.text().await.unwrap_or_default();
213 return Err(format!("whisper returned {status}: {body}"));
214 }
215
216 let value: Value = resp
218 .json()
219 .await
220 .map_err(|e| format!("could not parse whisper response: {e}"))?;
221 let text = value
222 .get("text")
223 .and_then(Value::as_str)
224 .unwrap_or("")
225 .trim()
226 .to_string();
227 let segments = parse_verbose_segments(&value);
228 Ok(Transcription { text, segments })
229}
230
231async fn transcribe_via_gateway(
245 client: &reqwest::Client,
246 host: &dyn SttHost,
247 bytes: Vec<u8>,
248) -> Result<Transcription, String> {
249 use base64::Engine as _;
250
251 let audio_b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
252
253 let provider = std::env::var("RYU_STT_GATEWAY_PROVIDER")
254 .ok()
255 .map(|s| s.trim().to_string())
256 .filter(|s| !s.is_empty())
257 .unwrap_or_else(|| "openai".to_string());
258 let model = std::env::var("RYU_STT_GATEWAY_MODEL")
259 .ok()
260 .map(|s| s.trim().to_string())
261 .filter(|s| !s.is_empty())
262 .unwrap_or_else(|| "whisper-large-v3".to_string());
263
264 let base = host.gateway_url();
265 let base = base.trim_end_matches('/');
266 let url = format!("{base}/v1/audio/transcriptions");
267 let bearer = host.gateway_bearer()?;
268
269 let payload = json!({
270 "model": model,
271 "file": audio_b64,
272 "response_format": "verbose_json",
273 });
274
275 let resp = client
276 .post(&url)
277 .bearer_auth(bearer)
278 .header("x-ryu-slot-stt-provider", &provider)
279 .header("x-ryu-slot-stt-model", &model)
280 .json(&payload)
281 .send()
282 .await
283 .map_err(|e| format!("gateway STT unreachable at {url}: {e}"))?;
284
285 if !resp.status().is_success() {
286 let status = resp.status();
287 let detail = resp.text().await.unwrap_or_default();
288 return Err(format!("gateway STT returned {status}: {detail}"));
289 }
290
291 let value: Value = resp
292 .json()
293 .await
294 .map_err(|e| format!("could not parse gateway STT response: {e}"))?;
295 let text = value
296 .get("text")
297 .and_then(Value::as_str)
298 .unwrap_or("")
299 .trim()
300 .to_string();
301 let segments = parse_verbose_segments(&value);
302 Ok(Transcription { text, segments })
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn parses_verbose_segments_seconds_to_ms() {
311 let body = json!({
312 "text": "hello world",
313 "segments": [
314 { "start": 0.0, "end": 1.5, "text": " hello" },
315 { "start": 1.5, "end": 2.25, "text": " world " },
316 ]
317 });
318 let segs = parse_verbose_segments(&body);
319 assert_eq!(segs.len(), 2);
320 assert_eq!(segs[0].start_ms, 0);
321 assert_eq!(segs[0].end_ms, 1500);
322 assert_eq!(segs[0].text, "hello");
323 assert_eq!(segs[1].start_ms, 1500);
324 assert_eq!(segs[1].end_ms, 2250);
325 assert_eq!(segs[1].text, "world");
326 }
327
328 #[test]
329 fn missing_or_malformed_segments_yield_empty() {
330 assert!(parse_verbose_segments(&json!({ "text": "x" })).is_empty());
331 assert!(parse_verbose_segments(&json!({ "segments": "not-an-array" })).is_empty());
332 let partial = json!({ "segments": [ { "text": "no timings" } ] });
334 assert!(parse_verbose_segments(&partial).is_empty());
335 }
336
337 #[test]
338 fn default_engine_env_override_wins() {
339 let prev = std::env::var("RYU_STT_ENGINE").ok();
341 std::env::set_var("RYU_STT_ENGINE", "gateway");
342 assert_eq!(default_stt_engine(), "gateway");
343 std::env::set_var("RYU_STT_ENGINE", " ");
344 let compiled = default_stt_engine();
346 assert!(compiled == "parakeet" || compiled == "whisper");
347 match prev {
348 Some(v) => std::env::set_var("RYU_STT_ENGINE", v),
349 None => std::env::remove_var("RYU_STT_ENGINE"),
350 }
351 }
352
353 #[test]
354 fn transcript_segment_serializes_camel_case() {
355 let seg = TranscriptSegment {
356 start_ms: 10,
357 end_ms: 20,
358 text: "hi".into(),
359 };
360 let v = serde_json::to_value(&seg).unwrap();
361 assert_eq!(v["startMs"], 10);
362 assert_eq!(v["endMs"], 20);
363 assert_eq!(v["text"], "hi");
364 }
365
366 #[test]
369 fn verbose_segments_clamp_negative_timestamps_to_zero() {
370 let body = json!({
373 "segments": [ { "start": -1.0, "end": -0.5, "text": " neg " } ]
374 });
375 let segs = parse_verbose_segments(&body);
376 assert_eq!(segs.len(), 1);
377 assert_eq!(segs[0].start_ms, 0);
378 assert_eq!(segs[0].end_ms, 0);
379 assert_eq!(segs[0].text, "neg");
380 }
381
382 #[test]
383 fn verbose_segment_missing_text_defaults_empty_but_keeps_timing() {
384 let body = json!({ "segments": [ { "start": 1.0, "end": 2.0 } ] });
386 let segs = parse_verbose_segments(&body);
387 assert_eq!(segs.len(), 1);
388 assert_eq!(segs[0].start_ms, 1000);
389 assert_eq!(segs[0].end_ms, 2000);
390 assert_eq!(segs[0].text, "");
391 }
392
393 #[test]
394 fn transcription_and_segment_defaults_are_empty() {
395 let t = Transcription::default();
396 assert!(t.text.is_empty());
397 assert!(t.segments.is_empty());
398 let s = TranscriptSegment::default();
399 assert_eq!(s.start_ms, 0);
400 assert_eq!(s.end_ms, 0);
401 assert!(s.text.is_empty());
402 let _ = format!("{:?}", t.clone());
404 let _ = format!("{:?}", s.clone());
405 }
406
407 use std::path::PathBuf;
413 use std::sync::{Arc, Mutex};
414
415 use axum::{http::HeaderMap, http::StatusCode, Router};
416 use tokio::net::TcpListener;
417
418 static ENV_LOCK: Mutex<()> = Mutex::new(());
421 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
422 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
423 }
424
425 struct FakeHost {
426 whisper: String,
427 gateway: String,
428 bearer: Result<String, String>,
429 }
430
431 impl Default for FakeHost {
432 fn default() -> Self {
433 Self {
434 whisper: "http://127.0.0.1:0".to_string(),
435 gateway: "http://127.0.0.1:0".to_string(),
436 bearer: Ok("testtoken".to_string()),
437 }
438 }
439 }
440
441 impl SttHost for FakeHost {
442 fn whisper_base_url(&self) -> String {
443 self.whisper.clone()
444 }
445 fn gateway_url(&self) -> String {
446 self.gateway.clone()
447 }
448 fn gateway_bearer(&self) -> Result<String, String> {
449 self.bearer.clone()
450 }
451 fn parakeet_model_dir(&self) -> PathBuf {
452 PathBuf::from("/nonexistent/parakeet-model")
453 }
454 }
455
456 #[derive(Clone, Default)]
457 struct Captured {
458 provider: Option<String>,
459 model: Option<String>,
460 authorization: Option<String>,
461 }
462
463 struct TestServer {
464 addr: std::net::SocketAddr,
465 captured: Arc<Mutex<Vec<Captured>>>,
466 _handle: tokio::task::JoinHandle<()>,
467 }
468
469 impl TestServer {
470 fn url(&self) -> String {
471 format!("http://{}", self.addr)
472 }
473 fn last(&self) -> Captured {
474 self.captured
475 .lock()
476 .unwrap()
477 .last()
478 .cloned()
479 .unwrap_or_default()
480 }
481 }
482
483 async fn spawn_server(status: StatusCode, resp_body: &'static str) -> TestServer {
486 let captured: Arc<Mutex<Vec<Captured>>> = Arc::new(Mutex::new(Vec::new()));
487 let cap = captured.clone();
488 let app = Router::new().fallback(move |headers: HeaderMap, _body: String| {
489 let cap = cap.clone();
490 async move {
491 let get = |k: &str| {
492 headers
493 .get(k)
494 .and_then(|v| v.to_str().ok())
495 .map(str::to_string)
496 };
497 cap.lock().unwrap().push(Captured {
498 provider: get("x-ryu-slot-stt-provider"),
499 model: get("x-ryu-slot-stt-model"),
500 authorization: get("authorization"),
501 });
502 (status, resp_body)
503 }
504 });
505 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
506 let addr = listener.local_addr().unwrap();
507 let handle = tokio::spawn(async move {
508 let _ = axum::serve(listener, app).await;
509 });
510 TestServer {
511 addr,
512 captured,
513 _handle: handle,
514 }
515 }
516
517 async fn dead_url() -> String {
519 let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
520 let addr = l.local_addr().unwrap();
521 drop(l);
522 format!("http://{addr}")
523 }
524
525 #[tokio::test]
528 async fn whisper_success_parses_text_and_segments() {
529 let server = spawn_server(
530 StatusCode::OK,
531 r#"{"text":" hello there ","segments":[{"start":0.0,"end":1.0,"text":" hello"},{"start":1.0,"end":2.0,"text":" there"}]}"#,
532 )
533 .await;
534 let host = FakeHost {
535 whisper: server.url(),
536 ..FakeHost::default()
537 };
538 let client = reqwest::Client::new();
539 let out = transcribe_wav_detailed(
540 &client,
541 &host,
542 b"fakeaudio".to_vec(),
543 "clip.wav".to_string(),
544 Some("whisper"),
545 )
546 .await
547 .expect("whisper transcription should succeed");
548 assert_eq!(out.text, "hello there");
549 assert_eq!(out.segments.len(), 2);
550 assert_eq!(out.segments[0].text, "hello");
551 assert_eq!(out.segments[1].end_ms, 2000);
552 }
553
554 #[tokio::test]
555 async fn whisper_text_only_wrapper_returns_string() {
556 let server = spawn_server(StatusCode::OK, r#"{"text":"just text"}"#).await;
557 let host = FakeHost {
558 whisper: server.url(),
559 ..FakeHost::default()
560 };
561 let client = reqwest::Client::new();
562 let text = transcribe_wav(
564 &client,
565 &host,
566 b"a".to_vec(),
567 "c.wav".to_string(),
568 Some("whisper"),
569 )
570 .await
571 .unwrap();
572 assert_eq!(text, "just text");
573 }
574
575 #[tokio::test]
576 async fn whisper_non_success_status_is_error() {
577 let server = spawn_server(StatusCode::INTERNAL_SERVER_ERROR, "model exploded").await;
578 let host = FakeHost {
579 whisper: server.url(),
580 ..FakeHost::default()
581 };
582 let client = reqwest::Client::new();
583 let err = transcribe_wav_detailed(
584 &client,
585 &host,
586 b"a".to_vec(),
587 "c.wav".to_string(),
588 Some("whisper"),
589 )
590 .await
591 .unwrap_err();
592 assert!(err.contains("whisper returned 500"), "got: {err}");
593 assert!(err.contains("model exploded"), "got: {err}");
594 }
595
596 #[tokio::test]
597 async fn whisper_unparseable_body_is_error() {
598 let server = spawn_server(StatusCode::OK, "this is not json").await;
599 let host = FakeHost {
600 whisper: server.url(),
601 ..FakeHost::default()
602 };
603 let client = reqwest::Client::new();
604 let err = transcribe_wav_detailed(
605 &client,
606 &host,
607 b"a".to_vec(),
608 "c.wav".to_string(),
609 Some("whisper"),
610 )
611 .await
612 .unwrap_err();
613 assert!(
614 err.contains("could not parse whisper response"),
615 "got: {err}"
616 );
617 }
618
619 #[tokio::test]
620 async fn whisper_unreachable_host_is_error() {
621 let host = FakeHost {
622 whisper: dead_url().await,
623 ..FakeHost::default()
624 };
625 let client = reqwest::Client::new();
626 let err = transcribe_wav_detailed(
627 &client,
628 &host,
629 b"a".to_vec(),
630 "c.wav".to_string(),
631 Some("whisper"),
632 )
633 .await
634 .unwrap_err();
635 assert!(err.contains("not reachable"), "got: {err}");
636 assert!(
637 err.contains("whispercpp"),
638 "actionable hint expected: {err}"
639 );
640 }
641
642 #[tokio::test]
643 async fn empty_engine_selector_falls_through_to_compiled_default() {
644 let _g = env_guard();
648 let prev = std::env::var("RYU_STT_ENGINE").ok();
649 std::env::remove_var("RYU_STT_ENGINE");
650 let server = spawn_server(StatusCode::OK, r#"{"text":"fallback"}"#).await;
651 let host = FakeHost {
652 whisper: server.url(),
653 ..FakeHost::default()
654 };
655 let client = reqwest::Client::new();
656 let out = transcribe_wav_detailed(
657 &client,
658 &host,
659 b"a".to_vec(),
660 "c.wav".to_string(),
661 Some(" "),
662 )
663 .await
664 .unwrap();
665 assert_eq!(out.text, "fallback");
666 if let Some(v) = prev {
667 std::env::set_var("RYU_STT_ENGINE", v);
668 }
669 }
670
671 #[tokio::test]
674 async fn gateway_success_sends_default_slot_headers_and_bearer() {
675 let _g = env_guard();
676 std::env::remove_var("RYU_STT_GATEWAY_PROVIDER");
677 std::env::remove_var("RYU_STT_GATEWAY_MODEL");
678 let server = spawn_server(
679 StatusCode::OK,
680 r#"{"text":" gw text ","segments":[{"start":0.0,"end":0.5,"text":"gw"}]}"#,
681 )
682 .await;
683 let host = FakeHost {
684 gateway: server.url(),
685 bearer: Ok("secret-slot-token".to_string()),
686 ..FakeHost::default()
687 };
688 let client = reqwest::Client::new();
689 let out = transcribe_wav_detailed(
690 &client,
691 &host,
692 b"a".to_vec(),
693 "c.wav".to_string(),
694 Some("gateway"),
695 )
696 .await
697 .unwrap();
698 assert_eq!(out.text, "gw text");
699 assert_eq!(out.segments.len(), 1);
700 let cap = server.last();
701 assert_eq!(cap.provider.as_deref(), Some("openai"));
702 assert_eq!(cap.model.as_deref(), Some("whisper-large-v3"));
703 assert_eq!(
704 cap.authorization.as_deref(),
705 Some("Bearer secret-slot-token")
706 );
707 }
708
709 #[tokio::test]
710 async fn gateway_env_overrides_provider_and_model() {
711 let _g = env_guard();
712 std::env::set_var("RYU_STT_GATEWAY_PROVIDER", "groq");
713 std::env::set_var("RYU_STT_GATEWAY_MODEL", "whisper-turbo");
714 let server = spawn_server(StatusCode::OK, r#"{"text":"x"}"#).await;
715 let host = FakeHost {
717 gateway: format!("{}/", server.url()),
718 ..FakeHost::default()
719 };
720 let client = reqwest::Client::new();
721 let out = transcribe_wav_detailed(
722 &client,
723 &host,
724 b"a".to_vec(),
725 "c.wav".to_string(),
726 Some("gateway"),
727 )
728 .await
729 .unwrap();
730 assert_eq!(out.text, "x");
731 let cap = server.last();
732 assert_eq!(cap.provider.as_deref(), Some("groq"));
733 assert_eq!(cap.model.as_deref(), Some("whisper-turbo"));
734 std::env::remove_var("RYU_STT_GATEWAY_PROVIDER");
735 std::env::remove_var("RYU_STT_GATEWAY_MODEL");
736 }
737
738 #[tokio::test]
739 async fn gateway_bearer_error_short_circuits() {
740 let _g = env_guard();
741 std::env::remove_var("RYU_STT_GATEWAY_PROVIDER");
742 std::env::remove_var("RYU_STT_GATEWAY_MODEL");
743 let host = FakeHost {
745 gateway: "http://127.0.0.1:0".to_string(),
746 bearer: Err("no gateway token configured".to_string()),
747 ..FakeHost::default()
748 };
749 let client = reqwest::Client::new();
750 let err = transcribe_wav_detailed(
751 &client,
752 &host,
753 b"a".to_vec(),
754 "c.wav".to_string(),
755 Some("gateway"),
756 )
757 .await
758 .unwrap_err();
759 assert_eq!(err, "no gateway token configured");
760 }
761
762 #[tokio::test]
763 async fn gateway_non_success_status_is_error() {
764 let _g = env_guard();
765 std::env::remove_var("RYU_STT_GATEWAY_PROVIDER");
766 std::env::remove_var("RYU_STT_GATEWAY_MODEL");
767 let server = spawn_server(StatusCode::UNAUTHORIZED, "bad key").await;
768 let host = FakeHost {
769 gateway: server.url(),
770 ..FakeHost::default()
771 };
772 let client = reqwest::Client::new();
773 let err = transcribe_wav_detailed(
774 &client,
775 &host,
776 b"a".to_vec(),
777 "c.wav".to_string(),
778 Some("gateway"),
779 )
780 .await
781 .unwrap_err();
782 assert!(err.contains("gateway STT returned 401"), "got: {err}");
783 assert!(err.contains("bad key"), "got: {err}");
784 }
785
786 #[tokio::test]
787 async fn gateway_unparseable_body_is_error() {
788 let _g = env_guard();
789 std::env::remove_var("RYU_STT_GATEWAY_PROVIDER");
790 std::env::remove_var("RYU_STT_GATEWAY_MODEL");
791 let server = spawn_server(StatusCode::OK, "<html>not json</html>").await;
792 let host = FakeHost {
793 gateway: server.url(),
794 ..FakeHost::default()
795 };
796 let client = reqwest::Client::new();
797 let err = transcribe_wav_detailed(
798 &client,
799 &host,
800 b"a".to_vec(),
801 "c.wav".to_string(),
802 Some("gateway"),
803 )
804 .await
805 .unwrap_err();
806 assert!(
807 err.contains("could not parse gateway STT response"),
808 "got: {err}"
809 );
810 }
811
812 #[tokio::test]
813 async fn gateway_unreachable_host_is_error() {
814 let _g = env_guard();
815 std::env::remove_var("RYU_STT_GATEWAY_PROVIDER");
816 std::env::remove_var("RYU_STT_GATEWAY_MODEL");
817 let host = FakeHost {
818 gateway: dead_url().await,
819 ..FakeHost::default()
820 };
821 let client = reqwest::Client::new();
822 let err = transcribe_wav_detailed(
823 &client,
824 &host,
825 b"a".to_vec(),
826 "c.wav".to_string(),
827 Some("gateway"),
828 )
829 .await
830 .unwrap_err();
831 assert!(err.contains("gateway STT unreachable"), "got: {err}");
832 }
833
834 #[tokio::test]
837 async fn parakeet_engine_without_feature_reports_not_built() {
838 let host = FakeHost::default();
839 let client = reqwest::Client::new();
840 let err = transcribe_wav_detailed(
841 &client,
842 &host,
843 b"a".to_vec(),
844 "c.wav".to_string(),
845 Some("parakeet"),
846 )
847 .await
848 .unwrap_err();
849 assert!(err.contains("parakeet transcription failed"), "got: {err}");
850 assert!(err.contains("not built"), "got: {err}");
851 }
852}