Cross-platform distribution, UI improvements, and performance optimizations
- PyInstaller frozen sidecar: spec file, build script, and ffmpeg path resolver for self-contained distribution without Python prerequisites - Dual-mode sidecar launcher: frozen binary (production) with dev mode fallback - Parallel transcription + diarization pipeline (~30-40% faster) - GPU auto-detection for diarization (CUDA when available) - Async run_pipeline command for real-time progress event delivery - Web Audio API backend for instant playback and seeking - OpenAI-compatible provider replacing LiteLLM client-side routing - Cross-platform RAM detection (Linux/macOS/Windows) - Settings: speaker count hint, token reveal toggles, dark dropdown styling - Loading splash screen, flexbox layout fix for viewport overflow - Gitea Actions CI/CD pipeline (Linux, Windows, macOS ARM) - Updated README and CLAUDE.md documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,13 @@ pub fn sidecar() -> &'static SidecarManager {
|
||||
INSTANCE.get_or_init(SidecarManager::new)
|
||||
}
|
||||
|
||||
/// Manages the Python sidecar process lifecycle.
|
||||
/// Uses separated stdin/stdout ownership to avoid BufReader conflicts.
|
||||
/// Manages the sidecar process lifecycle.
|
||||
///
|
||||
/// Supports two modes:
|
||||
/// - **Production**: spawns a frozen PyInstaller binary (no Python required)
|
||||
/// - **Dev mode**: spawns system Python with `-m voice_to_notes.main`
|
||||
///
|
||||
/// Dev mode is active when compiled in debug mode or when `VOICE_TO_NOTES_DEV=1`.
|
||||
pub struct SidecarManager {
|
||||
process: Mutex<Option<Child>>,
|
||||
stdin: Mutex<Option<ChildStdin>>,
|
||||
@@ -30,38 +35,141 @@ impl SidecarManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if we should use dev mode (system Python).
|
||||
fn is_dev_mode() -> bool {
|
||||
cfg!(debug_assertions) || std::env::var("VOICE_TO_NOTES_DEV").is_ok()
|
||||
}
|
||||
|
||||
/// Resolve the frozen sidecar binary path (production mode).
|
||||
fn resolve_sidecar_path() -> Result<std::path::PathBuf, String> {
|
||||
let exe = std::env::current_exe().map_err(|e| format!("Cannot get current exe: {e}"))?;
|
||||
let exe_dir = exe
|
||||
.parent()
|
||||
.ok_or_else(|| "Cannot get exe parent directory".to_string())?;
|
||||
|
||||
let binary_name = if cfg!(target_os = "windows") {
|
||||
"voice-to-notes-sidecar.exe"
|
||||
} else {
|
||||
"voice-to-notes-sidecar"
|
||||
};
|
||||
|
||||
// Tauri places externalBin next to the app binary
|
||||
let path = exe_dir.join(binary_name);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
// Also check inside a subdirectory (onedir PyInstaller output)
|
||||
let subdir_path = exe_dir.join("voice-to-notes-sidecar").join(binary_name);
|
||||
if subdir_path.exists() {
|
||||
return Ok(subdir_path);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Sidecar binary not found. Looked for:\n {}\n {}",
|
||||
path.display(),
|
||||
subdir_path.display(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Find a working Python command for the current platform.
|
||||
fn find_python_command() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"python"
|
||||
} else {
|
||||
"python3"
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the Python sidecar directory for dev mode.
|
||||
fn resolve_python_dir() -> Result<std::path::PathBuf, String> {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let python_dir = std::path::Path::new(manifest_dir)
|
||||
.join("../python")
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot find python directory: {e}"))?;
|
||||
|
||||
if python_dir.exists() {
|
||||
return Ok(python_dir);
|
||||
}
|
||||
|
||||
// Fallback: relative to current exe
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let alt = exe
|
||||
.parent()
|
||||
.ok_or_else(|| "No parent dir".to_string())?
|
||||
.join("../python")
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot find python directory: {e}"))?;
|
||||
|
||||
Ok(alt)
|
||||
}
|
||||
|
||||
/// Ensure the sidecar is running, starting it if needed.
|
||||
pub fn ensure_running(&self) -> Result<(), String> {
|
||||
if self.is_running() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let python_path = std::env::current_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("../python")
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot find python directory: {e}"))?;
|
||||
|
||||
self.start(&python_path.to_string_lossy())
|
||||
if Self::is_dev_mode() {
|
||||
self.start_python_dev()
|
||||
} else {
|
||||
match Self::resolve_sidecar_path() {
|
||||
Ok(path) => self.start_binary(&path),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[sidecar-rs] Frozen binary not found ({e}), falling back to dev mode"
|
||||
);
|
||||
self.start_python_dev()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the Python sidecar process.
|
||||
pub fn start(&self, python_path: &str) -> Result<(), String> {
|
||||
// Stop existing process if any
|
||||
/// Spawn the frozen sidecar binary (production mode).
|
||||
fn start_binary(&self, path: &std::path::Path) -> Result<(), String> {
|
||||
self.stop().ok();
|
||||
eprintln!("[sidecar-rs] Starting frozen sidecar: {}", path.display());
|
||||
|
||||
let mut child = Command::new("python3")
|
||||
.arg("-m")
|
||||
.arg("voice_to_notes.main")
|
||||
.current_dir(python_path)
|
||||
.env("PYTHONPATH", python_path)
|
||||
let child = Command::new(path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start sidecar: {e}"))?;
|
||||
.map_err(|e| format!("Failed to start sidecar binary: {e}"))?;
|
||||
|
||||
// Take ownership of stdin and stdout separately
|
||||
self.attach(child)?;
|
||||
self.wait_for_ready()
|
||||
}
|
||||
|
||||
/// Spawn the Python sidecar in dev mode (system Python).
|
||||
fn start_python_dev(&self) -> Result<(), String> {
|
||||
self.stop().ok();
|
||||
let python_dir = Self::resolve_python_dir()?;
|
||||
let python_cmd = Self::find_python_command();
|
||||
eprintln!(
|
||||
"[sidecar-rs] Starting dev sidecar: {} -m voice_to_notes.main ({})",
|
||||
python_cmd,
|
||||
python_dir.display()
|
||||
);
|
||||
|
||||
let child = Command::new(python_cmd)
|
||||
.arg("-m")
|
||||
.arg("voice_to_notes.main")
|
||||
.current_dir(&python_dir)
|
||||
.env("PYTHONPATH", &python_dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start Python sidecar: {e}"))?;
|
||||
|
||||
self.attach(child)?;
|
||||
self.wait_for_ready()
|
||||
}
|
||||
|
||||
/// Take ownership of a spawned child's stdin/stdout and store the process handle.
|
||||
fn attach(&self, mut child: Child) -> Result<(), String> {
|
||||
let stdin = child.stdin.take().ok_or("Failed to get sidecar stdin")?;
|
||||
let stdout = child.stdout.take().ok_or("Failed to get sidecar stdout")?;
|
||||
let buf_reader = BufReader::new(stdout);
|
||||
@@ -78,10 +186,6 @@ impl SidecarManager {
|
||||
let mut r = self.reader.lock().map_err(|e| e.to_string())?;
|
||||
*r = Some(buf_reader);
|
||||
}
|
||||
|
||||
// Wait for the "ready" message
|
||||
self.wait_for_ready()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -124,70 +228,6 @@ impl SidecarManager {
|
||||
self.send_and_receive_with_progress(msg, |_| {})
|
||||
}
|
||||
|
||||
/// Send a message and read the response, calling on_progress for each progress message.
|
||||
pub fn send_and_receive_with_progress(
|
||||
&self,
|
||||
msg: &IPCMessage,
|
||||
on_progress: impl Fn(&IPCMessage),
|
||||
) -> Result<IPCMessage, String> {
|
||||
// Write to stdin
|
||||
{
|
||||
let mut stdin_guard = self.stdin.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(ref mut stdin) = *stdin_guard {
|
||||
let json = serde_json::to_string(msg).map_err(|e| e.to_string())?;
|
||||
stdin
|
||||
.write_all(json.as_bytes())
|
||||
.map_err(|e| format!("Write error: {e}"))?;
|
||||
stdin
|
||||
.write_all(b"\n")
|
||||
.map_err(|e| format!("Write error: {e}"))?;
|
||||
stdin.flush().map_err(|e| format!("Flush error: {e}"))?;
|
||||
} else {
|
||||
return Err("Sidecar stdin not available".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Read from stdout
|
||||
{
|
||||
let mut reader_guard = self.reader.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(ref mut reader) = *reader_guard {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let bytes_read = reader
|
||||
.read_line(&mut line)
|
||||
.map_err(|e| format!("Read error: {e}"))?;
|
||||
if bytes_read == 0 {
|
||||
return Err("Sidecar closed stdout".to_string());
|
||||
}
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Skip non-JSON lines (library output that leaked to stdout)
|
||||
let response: IPCMessage = match serde_json::from_str(trimmed) {
|
||||
Ok(msg) => msg,
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"[sidecar-rs] Skipping non-JSON line: {}",
|
||||
&trimmed[..trimmed.len().min(200)]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if response.msg_type == "progress" {
|
||||
on_progress(&response);
|
||||
continue;
|
||||
}
|
||||
return Ok(response);
|
||||
}
|
||||
} else {
|
||||
Err("Sidecar stdout not available".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a message and receive the response, calling a callback for intermediate messages.
|
||||
/// Intermediate messages include progress, pipeline.segment, and pipeline.speaker_update.
|
||||
pub fn send_and_receive_with_progress<F>(
|
||||
|
||||
Reference in New Issue
Block a user