Skip to main content

murmur_core/transcription/
subprocess.rs

1//! Subprocess-based whisper inference.
2//!
3//! Spawns `murmur-whisper-worker` as a child process and communicates via
4//! stdin/stdout pipes. This isolates whisper's Accelerate/BLAS usage from
5//! the parent process's Core Audio thread, avoiding "failed to encode"
6//! error -6 that occurs when both run in the same process.
7
8use anyhow::{Context, Result};
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use std::process::{Child, Command, Stdio};
12
13/// A whisper transcriber that runs inference in a child process.
14pub struct SubprocessTranscriber {
15    child: Child,
16}
17
18impl SubprocessTranscriber {
19    /// Spawn the worker process with the given model and language.
20    ///
21    /// The worker binary is expected at the same location as the current
22    /// executable (same target directory).
23    pub fn new(model_path: &Path, language: &str) -> Result<Self> {
24        let worker_path = find_worker_binary()?;
25        log::info!(
26            "Spawning whisper worker: {} (model: {})",
27            worker_path.display(),
28            model_path.display()
29        );
30
31        let child = Command::new(&worker_path)
32            .arg(model_path.to_str().context("Invalid model path")?)
33            .arg(language)
34            .stdin(Stdio::piped())
35            .stdout(Stdio::piped())
36            .stderr(Stdio::inherit())
37            .spawn()
38            .context("Failed to spawn murmur-whisper-worker")?;
39
40        Ok(Self { child })
41    }
42
43    /// Send audio samples to the worker and get back transcribed text.
44    pub fn transcribe(&mut self, samples: &[f32], translate: bool) -> Result<String> {
45        let stdin = self
46            .child
47            .stdin
48            .as_mut()
49            .context("Worker stdin not available")?;
50        let stdout = self
51            .child
52            .stdout
53            .as_mut()
54            .context("Worker stdout not available")?;
55
56        // Write request: u32(sample_count) | f32[samples] | u8(translate)
57        let count = (samples.len() as u32).to_le_bytes();
58        stdin.write_all(&count)?;
59
60        let sample_bytes: &[u8] =
61            unsafe { std::slice::from_raw_parts(samples.as_ptr() as *const u8, samples.len() * 4) };
62        stdin.write_all(sample_bytes)?;
63
64        let flag = [if translate { 1u8 } else { 0u8 }];
65        stdin.write_all(&flag)?;
66        stdin.flush()?;
67
68        // Read response: u32(text_len) | utf8[text]
69        let mut len_buf = [0u8; 4];
70        stdout
71            .read_exact(&mut len_buf)
72            .context("Failed to read response length from worker")?;
73        let text_len = u32::from_le_bytes(len_buf) as usize;
74
75        if text_len == 0 {
76            return Ok(String::new());
77        }
78
79        let mut text_buf = vec![0u8; text_len];
80        stdout
81            .read_exact(&mut text_buf)
82            .context("Failed to read response text from worker")?;
83
84        String::from_utf8(text_buf).context("Worker returned invalid UTF-8")
85    }
86
87    /// Send shutdown signal to the worker.
88    pub fn shutdown(&mut self) {
89        if let Some(ref mut stdin) = self.child.stdin {
90            let _ = stdin.write_all(&0u32.to_le_bytes());
91            let _ = stdin.flush();
92        }
93        let _ = self.child.wait();
94    }
95}
96
97impl Drop for SubprocessTranscriber {
98    fn drop(&mut self) {
99        self.shutdown();
100    }
101}
102
103/// Find the worker binary next to the current executable.
104fn find_worker_binary() -> Result<PathBuf> {
105    let current_exe = std::env::current_exe().context("Failed to get current executable path")?;
106    let exe_dir = current_exe
107        .parent()
108        .context("Executable has no parent directory")?;
109
110    let worker_name = if cfg!(windows) {
111        "murmur-whisper-worker.exe"
112    } else {
113        "murmur-whisper-worker"
114    };
115
116    let worker_path = exe_dir.join(worker_name);
117    anyhow::ensure!(
118        worker_path.exists(),
119        "murmur-whisper-worker not found at {}. Build it with: cargo build -p murmur-core --bin murmur-whisper-worker",
120        worker_path.display()
121    );
122    Ok(worker_path)
123}