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