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