voice_bird_cli/transcription/
mod.rs1pub mod auto_select;
2pub mod local_agreement;
3pub mod mock;
4pub mod models;
5pub mod refinement_engine;
6pub mod voicebird_engine;
7pub mod whisper_kit_engine;
8pub mod whisper_rs_engine;
9
10use std::time::Duration;
11
12use serde::{Deserialize, Serialize};
13use tokio::sync::{broadcast, mpsc, oneshot};
14
15use crate::session::writer::WrittenSegment;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Token {
19 pub text: String,
20 pub t_start_ms: u64,
21 pub t_end_ms: u64,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Segment {
26 pub t_start: Duration,
27 pub t_end: Duration,
28 pub text: String,
29 pub tokens: Vec<Token>,
30}
31
32impl From<&Segment> for WrittenSegment {
33 fn from(s: &Segment) -> Self {
34 WrittenSegment {
35 t_start_ms: s.t_start.as_millis() as u64,
36 t_end_ms: s.t_end.as_millis() as u64,
37 text: s.text.clone(),
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
43pub enum EngineEvent {
44 ModelLoaded { name: String },
45 Committed(Segment),
46 Tentative(String),
47 Error(String),
48}
49
50#[derive(Debug, Clone)]
51pub enum EngineConfig {
52 Local {
53 model_path: std::path::PathBuf,
54 language: Option<String>,
55 sample_rate: u32, hop_ms: u32, min_window_ms: u32, },
59 Cloud {
60 api_key: String,
61 language: Option<String>,
62 sample_rate: u32,
63 server_url: String,
66 device_name: String,
70 app_name: String,
77 },
78}
79
80pub struct EngineHandle {
81 pub pcm_tx: mpsc::Sender<Vec<f32>>,
82 pub events_rx: broadcast::Receiver<EngineEvent>,
83 pub shutdown: oneshot::Sender<()>,
84}
85
86pub trait TranscriptionEngine: Send {
87 fn start(&mut self, cfg: EngineConfig) -> anyhow::Result<EngineHandle>;
88}
89
90pub fn select_engine(
97 prefer: &str,
98 sidecar_path: Option<&std::path::Path>,
99) -> Box<dyn TranscriptionEngine> {
100 #[cfg(target_os = "macos")]
101 {
102 if prefer == "whisperkit" || prefer == "auto" {
103 if let Some(path) = sidecar_path {
104 if path.exists() {
105 return Box::new(whisper_kit_engine::WhisperKitEngine::new(
106 path.to_path_buf(),
107 ));
108 }
109 }
110 }
111 }
112 let _ = (prefer, sidecar_path);
114 Box::new(whisper_rs_engine::WhisperRsEngine::default())
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum EngineKind {
119 WhisperRs,
120 WhisperKit,
121 VoiceBirdWeb,
122}
123
124pub fn try_select_engine(
132 prefer: &str,
133 cloud_broadcast_enabled: bool,
134 voicebird_api_key: &str,
135 voicebird_server_url: &str,
136 sidecar_path: Option<&std::path::Path>,
137) -> Result<(EngineKind, Box<dyn TranscriptionEngine>), String> {
138 if cloud_broadcast_enabled {
139 if voicebird_api_key.is_empty() {
140 return Err(
141 "Live broadcast enabled but no Voice Bird API key — open settings (press ',')"
142 .into(),
143 );
144 }
145 if voicebird_server_url.is_empty() {
146 return Err(
147 "Live broadcast enabled but no Voice Bird server URL — open settings (press ',')"
148 .into(),
149 );
150 }
151 return Ok((
152 EngineKind::VoiceBirdWeb,
153 Box::new(voicebird_engine::VoiceBirdEngine::new(
154 voicebird_api_key.to_string(),
155 voicebird_server_url.to_string(),
156 )),
157 ));
158 }
159
160 #[cfg(target_os = "macos")]
161 {
162 if prefer == "whisperkit" || prefer == "auto" {
163 if let Some(path) = sidecar_path {
164 if path.exists() {
165 return Ok((
166 EngineKind::WhisperKit,
167 Box::new(whisper_kit_engine::WhisperKitEngine::new(
168 path.to_path_buf(),
169 )),
170 ));
171 }
172 }
173 }
174 }
175 let _ = (prefer, sidecar_path);
176 Ok((
177 EngineKind::WhisperRs,
178 Box::<whisper_rs_engine::WhisperRsEngine>::default(),
179 ))
180}
181
182pub fn sidecar_path() -> Option<std::path::PathBuf> {
188 let exe = std::env::current_exe().ok()?;
189 let dir = exe.parent()?;
190 let bundle = dir.join("../Resources/voice-bird-whisperkit");
192 if bundle.exists() {
193 return Some(bundle);
194 }
195 let sibling = dir.join("voice-bird-whisperkit");
197 if sibling.exists() {
198 return Some(sibling);
199 }
200 let dev = dir.join("../../whisperkit-helper/.build/release/voice-bird-whisperkit");
202 if dev.exists() {
203 return Some(dev);
204 }
205 None
206}
207
208#[cfg(test)]
209mod select_tests {
210 use super::*;
211
212 const TEST_URL: &str = "wss://example.test/api/audio/stream";
213
214 #[test]
215 fn broadcast_with_key_returns_cloud_engine() {
216 let res = try_select_engine("auto", true, "vb-fake", TEST_URL, None);
217 let (kind, _engine) = res.expect("expected Ok");
218 assert_eq!(kind, EngineKind::VoiceBirdWeb);
219 }
220
221 #[test]
222 fn broadcast_without_key_returns_err() {
223 let err = try_select_engine("auto", true, "", TEST_URL, None)
224 .err()
225 .unwrap();
226 assert!(err.to_lowercase().contains("api key"));
227 }
228
229 #[test]
230 fn broadcast_without_url_returns_err() {
231 let err = try_select_engine("auto", true, "vb-fake", "", None)
232 .err()
233 .unwrap();
234 assert!(err.to_lowercase().contains("server url"));
235 }
236
237 #[test]
238 fn local_path_ignores_credentials() {
239 let (kind, _) = try_select_engine("whisper_rs", false, "", "", None).unwrap();
240 assert_eq!(kind, EngineKind::WhisperRs);
241 }
242
243 #[test]
244 fn local_path_when_broadcast_off_even_with_creds() {
245 let (kind, _) = try_select_engine("whisper_rs", false, "vb-fake", TEST_URL, None).unwrap();
248 assert_eq!(kind, EngineKind::WhisperRs);
249 }
250}