Skip to main content

voice_bird_cli/transcription/
mod.rs

1pub 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,   // always 16_000
56        hop_ms: u32,        // whisper-rs only
57        min_window_ms: u32, // whisper-rs only
58    },
59    Cloud {
60        api_key: String,
61        language: Option<String>,
62        sample_rate: u32,
63        /// WebSocket URL of the Voice Bird Web `/api/audio/stream`
64        /// endpoint to stream PCM to.
65        server_url: String,
66        /// Device label sent to voicebird.app in the init handshake;
67        /// surfaces in the live-session card so users can tell which
68        /// audio source is being streamed when multiple stream.
69        device_name: String,
70        /// Source-application label sent in the init handshake.
71        /// Empty for mic / system captures (UI falls back to
72        /// `device_name`); the app's display name (e.g. "Chrome",
73        /// "Safari") for `SessionSource::App` loopback captures so
74        /// each (device, app) pair gets its own row in the
75        /// Transcriptions tab.
76        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
90/// Build a transcription engine based on user preference and whether the
91/// WhisperKit sidecar binary is available on disk. `prefer` comes from
92/// `config.toml`'s `engine_prefer` field (`"auto"`, `"whisperkit"`, or
93/// `"whisper_rs"`). On macOS, `"auto"` and `"whisperkit"` pick the
94/// sidecar iff `sidecar_path` is `Some` and points to an existing file;
95/// otherwise we fall back to `whisper-rs` transparently.
96pub 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    // Silence the unused-parameter warning on non-macOS builds.
113    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
124/// Typed variant of `select_engine`. Returns an error for cases that
125/// should surface to the user (e.g. cloud broadcast enabled but no key).
126///
127/// When `cloud_broadcast_enabled` is true, the cloud Voice Bird Web
128/// engine is selected unconditionally — it bypasses local Whisper and
129/// streams PCM to the user's voicebird.app account. `prefer` is only
130/// consulted for local-engine selection (whisperkit / whisper_rs).
131pub 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
182/// Locate the `voice-bird-whisperkit` Swift sidecar binary. We probe, in
183/// order: the macOS `.app` bundle layout (Resources/), a sibling next to
184/// the current executable (release layout), and the dev `.build/release`
185/// produced by `cargo run -p xtask -- build-sidecar`. Returns `None` on
186/// non-macOS or if no candidate exists.
187pub fn sidecar_path() -> Option<std::path::PathBuf> {
188    let exe = std::env::current_exe().ok()?;
189    let dir = exe.parent()?;
190    // .app bundle layout
191    let bundle = dir.join("../Resources/voice-bird-whisperkit");
192    if bundle.exists() {
193        return Some(bundle);
194    }
195    // sibling binary (dev / release layout)
196    let sibling = dir.join("voice-bird-whisperkit");
197    if sibling.exists() {
198        return Some(sibling);
199    }
200    // project dev fallback (running via `cargo run`, target/debug/voice-bird-cli)
201    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        // When broadcast is off, creds are irrelevant and we land on the
246        // local engine (whisper_rs without a sidecar path).
247        let (kind, _) = try_select_engine("whisper_rs", false, "vb-fake", TEST_URL, None).unwrap();
248        assert_eq!(kind, EngineKind::WhisperRs);
249    }
250}