Skip to main content

voice_bird_cli/transcription/
mod.rs

1// Local inference engines (and their helpers) are gated off Windows:
2// Windows is cloud-only since 0.4.0, so whisper-rs / parakeet-rs / sysinfo
3// never enter the Windows dependency graph (see Cargo.toml).
4#[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,   // always 16_000
64        hop_ms: u32,        // whisper-rs only
65        min_window_ms: u32, // whisper-rs only
66    },
67    Cloud {
68        api_key: String,
69        language: Option<String>,
70        sample_rate: u32,
71        /// WebSocket URL of the Voice Bird Web `/api/audio/stream`
72        /// endpoint to stream PCM to.
73        server_url: String,
74        /// Device label sent to voicebird.app in the init handshake;
75        /// surfaces in the live-session card so users can tell which
76        /// audio source is being streamed when multiple stream.
77        device_name: String,
78        /// Source-application label sent in the init handshake.
79        /// Empty for mic / system captures (UI falls back to
80        /// `device_name`); the app's display name (e.g. "Chrome",
81        /// "Safari") for `SessionSource::App` loopback captures so
82        /// each (device, app) pair gets its own row in the
83        /// Transcriptions tab.
84        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
106/// Select the transcription engine for a recording. Returns an error for
107/// cases that should surface to the user (e.g. cloud broadcast enabled but
108/// no key, or local mode requested on cloud-only Windows).
109///
110/// When `cloud_broadcast_enabled` is true, the cloud Voice Bird Web
111/// engine is selected unconditionally — it bypasses local Whisper and
112/// streams PCM to the user's voicebird.app account. `prefer` is only
113/// consulted for local-engine selection (whisperkit / whisper_rs).
114pub 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    // Windows has no local engines (cloud-only since 0.4.0). This branch is
144    // defensive — the app forces cloud on at config load and clamps per-source
145    // settings — but it guarantees a coherent error if a hand-edited config
146    // slips through with cloud off.
147    #[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
180/// Locate the `voice-bird-whisperkit` Swift sidecar binary. We probe, in
181/// order: the macOS `.app` bundle layout (Resources/), a sibling next to
182/// the current executable (release layout), and the dev `.build/release`
183/// produced by `cargo run -p xtask -- build-sidecar`. Returns `None` on
184/// non-macOS or if no candidate exists.
185pub fn sidecar_path() -> Option<std::path::PathBuf> {
186    let exe = std::env::current_exe().ok()?;
187    let dir = exe.parent()?;
188    // .app bundle layout
189    let bundle = dir.join("../Resources/voice-bird-whisperkit");
190    if bundle.exists() {
191        return Some(bundle);
192    }
193    // sibling binary (dev / release layout)
194    let sibling = dir.join("voice-bird-whisperkit");
195    if sibling.exists() {
196        return Some(sibling);
197    }
198    // project dev fallback (running via `cargo run`, target/debug/voice-bird-cli)
199    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        // 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
251    #[cfg(windows)]
252    #[test]
253    fn windows_without_cloud_returns_err() {
254        // Windows has no local engines; asking for one must surface the
255        // cloud-only error rather than panic or silently pick anything.
256        let err = try_select_engine("auto", false, "", "", None).err().unwrap();
257        assert!(err.to_lowercase().contains("cloud-only"));
258    }
259}