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