Fix critical integration issues for end-to-end functionality

- Rewrite SidecarManager as singleton with OnceLock, reusing one Python
  process across all commands instead of spawning per call
- Separate stdin/stdout ownership with dedicated BufReader to prevent
  data corruption between wait_for_ready and send_and_receive
- Add ensure_running() for auto-start on first command
- Fix asset protocol URL: use convertFileSrc() instead of manual
  encodeURIComponent which broke file paths with slashes
- Add +layout.svelte with global dark theme, CSS reset, and custom
  scrollbar styling to prevent white flash on startup
- Register AppState with Tauri .manage(), initialize SQLite database
  on app startup at ~/.voicetonotes/voice_to_notes.db
- Wire project commands (create/get/list) to real database queries
  instead of placeholder stubs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 16:50:14 -08:00
parent d3c2954c5e
commit d00281f0c7
9 changed files with 205 additions and 134 deletions

View File

@@ -1,19 +1,7 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::sidecar::messages::IPCMessage; use crate::sidecar::messages::IPCMessage;
use crate::sidecar::SidecarManager; use crate::sidecar::sidecar;
fn get_sidecar() -> Result<SidecarManager, String> {
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}"))?;
let manager = SidecarManager::new();
manager.start(&python_path.to_string_lossy())?;
Ok(manager)
}
/// Send a chat message to the AI provider via the Python sidecar. /// Send a chat message to the AI provider via the Python sidecar.
#[tauri::command] #[tauri::command]
@@ -22,14 +10,8 @@ pub fn ai_chat(
transcript_context: Option<String>, transcript_context: Option<String>,
provider: Option<String>, provider: Option<String>,
) -> Result<Value, String> { ) -> Result<Value, String> {
let manager = get_sidecar()?; let manager = sidecar();
manager.ensure_running()?;
let request_id = uuid::Uuid::new_v4().to_string();
let payload = json!({
"action": "chat",
"messages": messages,
"transcript_context": transcript_context.unwrap_or_default(),
});
// If a specific provider is requested, set it first // If a specific provider is requested, set it first
if let Some(p) = provider { if let Some(p) = provider {
@@ -41,7 +23,17 @@ pub fn ai_chat(
let _ = manager.send_and_receive(&set_msg)?; let _ = manager.send_and_receive(&set_msg)?;
} }
let msg = IPCMessage::new(&request_id, "ai.chat", payload); let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new(
&request_id,
"ai.chat",
json!({
"action": "chat",
"messages": messages,
"transcript_context": transcript_context.unwrap_or_default(),
}),
);
let response = manager.send_and_receive(&msg)?; let response = manager.send_and_receive(&msg)?;
if response.msg_type == "error" { if response.msg_type == "error" {
@@ -57,7 +49,8 @@ pub fn ai_chat(
/// List available AI providers. /// List available AI providers.
#[tauri::command] #[tauri::command]
pub fn ai_list_providers() -> Result<Value, String> { pub fn ai_list_providers() -> Result<Value, String> {
let manager = get_sidecar()?; let manager = sidecar();
manager.ensure_running()?;
let request_id = uuid::Uuid::new_v4().to_string(); let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new( let msg = IPCMessage::new(
@@ -73,7 +66,8 @@ pub fn ai_list_providers() -> Result<Value, String> {
/// Configure an AI provider with API key/settings. /// Configure an AI provider with API key/settings.
#[tauri::command] #[tauri::command]
pub fn ai_configure(provider: String, config: Value) -> Result<Value, String> { pub fn ai_configure(provider: String, config: Value) -> Result<Value, String> {
let manager = get_sidecar()?; let manager = sidecar();
manager.ensure_running()?;
let request_id = uuid::Uuid::new_v4().to_string(); let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new( let msg = IPCMessage::new(

View File

@@ -1,7 +1,7 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::sidecar::messages::IPCMessage; use crate::sidecar::messages::IPCMessage;
use crate::sidecar::SidecarManager; use crate::sidecar::sidecar;
/// Export transcript to caption/text format via the Python sidecar. /// Export transcript to caption/text format via the Python sidecar.
#[tauri::command] #[tauri::command]
@@ -12,16 +12,8 @@ pub fn export_transcript(
output_path: String, output_path: String,
title: Option<String>, title: Option<String>,
) -> Result<Value, String> { ) -> Result<Value, String> {
let python_path = std::env::current_dir() let manager = sidecar();
.map_err(|e| e.to_string())? manager.ensure_running()?;
.join("../python")
.canonicalize()
.map_err(|e| format!("Cannot find python directory: {e}"))?;
let python_path_str = python_path.to_string_lossy().to_string();
let manager = SidecarManager::new();
manager.start(&python_path_str)?;
let request_id = uuid::Uuid::new_v4().to_string(); let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new( let msg = IPCMessage::new(

View File

@@ -1,27 +1,23 @@
use tauri::State;
use crate::db::models::Project; use crate::db::models::Project;
use crate::db::queries;
use crate::state::AppState;
#[tauri::command] #[tauri::command]
pub fn create_project(name: String) -> Result<Project, String> { pub fn create_project(name: String, state: State<AppState>) -> Result<Project, String> {
// TODO: Use actual database connection from app state let conn = state.db.lock().map_err(|e| e.to_string())?;
Ok(Project { queries::create_project(&conn, &name).map_err(|e| e.to_string())
id: uuid::Uuid::new_v4().to_string(),
name,
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
settings: None,
status: "active".to_string(),
})
} }
#[tauri::command] #[tauri::command]
pub fn get_project(id: String) -> Result<Option<Project>, String> { pub fn get_project(id: String, state: State<AppState>) -> Result<Option<Project>, String> {
// TODO: Use actual database connection from app state let conn = state.db.lock().map_err(|e| e.to_string())?;
let _ = id; queries::get_project(&conn, &id).map_err(|e| e.to_string())
Ok(None)
} }
#[tauri::command] #[tauri::command]
pub fn list_projects() -> Result<Vec<Project>, String> { pub fn list_projects(state: State<AppState>) -> Result<Vec<Project>, String> {
// TODO: Use actual database connection from app state let conn = state.db.lock().map_err(|e| e.to_string())?;
Ok(vec![]) queries::list_projects(&conn).map_err(|e| e.to_string())
} }

View File

@@ -1,12 +1,9 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::sidecar::messages::IPCMessage; use crate::sidecar::messages::IPCMessage;
use crate::sidecar::SidecarManager; use crate::sidecar::sidecar;
/// Start transcription of an audio file via the Python sidecar. /// Start transcription of an audio file via the Python sidecar.
///
/// This is a blocking command — it starts the sidecar if needed,
/// sends the transcribe request, and waits for the result.
#[tauri::command] #[tauri::command]
pub fn transcribe_file( pub fn transcribe_file(
file_path: String, file_path: String,
@@ -14,17 +11,8 @@ pub fn transcribe_file(
device: Option<String>, device: Option<String>,
language: Option<String>, language: Option<String>,
) -> Result<Value, String> { ) -> Result<Value, String> {
// Determine Python sidecar path (relative to app) let manager = sidecar();
let python_path = std::env::current_dir() manager.ensure_running()?;
.map_err(|e| e.to_string())?
.join("../python")
.canonicalize()
.map_err(|e| format!("Cannot find python directory: {e}"))?;
let python_path_str = python_path.to_string_lossy().to_string();
let manager = SidecarManager::new();
manager.start(&python_path_str)?;
let request_id = uuid::Uuid::new_v4().to_string(); let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new( let msg = IPCMessage::new(
@@ -63,16 +51,8 @@ pub fn run_pipeline(
max_speakers: Option<u32>, max_speakers: Option<u32>,
skip_diarization: Option<bool>, skip_diarization: Option<bool>,
) -> Result<Value, String> { ) -> Result<Value, String> {
let python_path = std::env::current_dir() let manager = sidecar();
.map_err(|e| e.to_string())? manager.ensure_running()?;
.join("../python")
.canonicalize()
.map_err(|e| format!("Cannot find python directory: {e}"))?;
let python_path_str = python_path.to_string_lossy().to_string();
let manager = SidecarManager::new();
manager.start(&python_path_str)?;
let request_id = uuid::Uuid::new_v4().to_string(); let request_id = uuid::Uuid::new_v4().to_string();
let msg = IPCMessage::new( let msg = IPCMessage::new(

View File

@@ -10,12 +10,16 @@ use commands::project::{create_project, get_project, list_projects};
use commands::settings::{load_settings, save_settings}; use commands::settings::{load_settings, save_settings};
use commands::system::{get_data_dir, llama_list_models, llama_start, llama_status, llama_stop}; use commands::system::{get_data_dir, llama_list_models, llama_start, llama_status, llama_stop};
use commands::transcribe::{run_pipeline, transcribe_file}; use commands::transcribe::{run_pipeline, transcribe_file};
use state::AppState;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let app_state = AppState::new().expect("Failed to initialize app state");
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.manage(app_state)
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
create_project, create_project,
get_project, get_project,

View File

@@ -2,38 +2,82 @@ pub mod ipc;
pub mod messages; pub mod messages;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::process::{Child, Command, Stdio}; use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::Mutex; use std::sync::{Mutex, OnceLock};
use crate::sidecar::messages::IPCMessage; use crate::sidecar::messages::IPCMessage;
/// Get the global sidecar manager singleton.
pub fn sidecar() -> &'static SidecarManager {
static INSTANCE: OnceLock<SidecarManager> = OnceLock::new();
INSTANCE.get_or_init(SidecarManager::new)
}
/// Manages the Python sidecar process lifecycle. /// Manages the Python sidecar process lifecycle.
/// Uses separated stdin/stdout ownership to avoid BufReader conflicts.
pub struct SidecarManager { pub struct SidecarManager {
process: Mutex<Option<Child>>, process: Mutex<Option<Child>>,
stdin: Mutex<Option<ChildStdin>>,
reader: Mutex<Option<BufReader<std::process::ChildStdout>>>,
} }
impl SidecarManager { impl SidecarManager {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
process: Mutex::new(None), process: Mutex::new(None),
stdin: Mutex::new(None),
reader: Mutex::new(None),
} }
} }
/// 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())
}
/// Spawn the Python sidecar process. /// Spawn the Python sidecar process.
pub fn start(&self, python_path: &str) -> Result<(), String> { pub fn start(&self, python_path: &str) -> Result<(), String> {
let child = Command::new("python3") // Stop existing process if any
self.stop().ok();
let mut child = Command::new("python3")
.arg("-m") .arg("-m")
.arg("voice_to_notes.main") .arg("voice_to_notes.main")
.current_dir(python_path) .current_dir(python_path)
.env("PYTHONPATH", python_path) .env("PYTHONPATH", python_path)
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::inherit()) // Let sidecar logs go to parent's stderr .stderr(Stdio::inherit())
.spawn() .spawn()
.map_err(|e| format!("Failed to start sidecar: {e}"))?; .map_err(|e| format!("Failed to start sidecar: {e}"))?;
// Take ownership of stdin and stdout separately
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);
{
let mut proc = self.process.lock().map_err(|e| e.to_string())?; let mut proc = self.process.lock().map_err(|e| e.to_string())?;
*proc = Some(child); *proc = Some(child);
}
{
let mut s = self.stdin.lock().map_err(|e| e.to_string())?;
*s = Some(stdin);
}
{
let mut r = self.reader.lock().map_err(|e| e.to_string())?;
*r = Some(buf_reader);
}
// Wait for the "ready" message // Wait for the "ready" message
self.wait_for_ready()?; self.wait_for_ready()?;
@@ -43,35 +87,40 @@ impl SidecarManager {
/// Wait for the sidecar to send its ready message. /// Wait for the sidecar to send its ready message.
fn wait_for_ready(&self) -> Result<(), String> { fn wait_for_ready(&self) -> Result<(), String> {
let mut proc = self.process.lock().map_err(|e| e.to_string())?; let mut reader_guard = self.reader.lock().map_err(|e| e.to_string())?;
if let Some(ref mut child) = *proc { if let Some(ref mut reader) = *reader_guard {
if let Some(ref mut stdout) = child.stdout { let mut line = String::new();
let reader = BufReader::new(stdout); loop {
for line in reader.lines() { line.clear();
let line = line.map_err(|e| format!("Read error: {e}"))?; let bytes = reader
if line.is_empty() { .read_line(&mut line)
.map_err(|e| format!("Read error: {e}"))?;
if bytes == 0 {
return Err("Sidecar closed stdout before sending ready".to_string());
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue; continue;
} }
if let Ok(msg) = serde_json::from_str::<IPCMessage>(&line) { if let Ok(msg) = serde_json::from_str::<IPCMessage>(trimmed) {
if msg.msg_type == "ready" { if msg.msg_type == "ready" {
return Ok(()); return Ok(());
} }
} }
// If we got a non-ready message, something's wrong but don't block forever // Non-ready message: something is wrong
break; break;
} }
} }
}
Err("Sidecar did not send ready message".to_string()) Err("Sidecar did not send ready message".to_string())
} }
/// Send a message to the sidecar and read the response. /// Send a message to the sidecar and read the response.
/// This is a blocking call. /// This is a blocking call.
pub fn send_and_receive(&self, msg: &IPCMessage) -> Result<IPCMessage, String> { pub fn send_and_receive(&self, msg: &IPCMessage) -> Result<IPCMessage, String> {
let mut proc = self.process.lock().map_err(|e| e.to_string())?; // Write to stdin
if let Some(ref mut child) = *proc { {
// Write message to stdin let mut stdin_guard = self.stdin.lock().map_err(|e| e.to_string())?;
if let Some(ref mut stdin) = child.stdin { if let Some(ref mut stdin) = *stdin_guard {
let json = serde_json::to_string(msg).map_err(|e| e.to_string())?; let json = serde_json::to_string(msg).map_err(|e| e.to_string())?;
stdin stdin
.write_all(json.as_bytes()) .write_all(json.as_bytes())
@@ -83,12 +132,13 @@ impl SidecarManager {
} else { } else {
return Err("Sidecar stdin not available".to_string()); return Err("Sidecar stdin not available".to_string());
} }
}
// Read response from stdout // Read from stdout
if let Some(ref mut stdout) = child.stdout { {
let mut reader = BufReader::new(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(); let mut line = String::new();
// Read lines until we get a response (skip progress messages, collect them)
loop { loop {
line.clear(); line.clear();
let bytes_read = reader let bytes_read = reader
@@ -101,41 +151,46 @@ impl SidecarManager {
if trimmed.is_empty() { if trimmed.is_empty() {
continue; continue;
} }
let response: IPCMessage = let response: IPCMessage = serde_json::from_str(trimmed)
serde_json::from_str(trimmed).map_err(|e| format!("Parse error: {e}"))?; .map_err(|e| format!("Parse error: {e}"))?;
// If it's a progress message, we could emit it as an event // Skip progress messages, return the final result/error
// For now, skip progress and return the final result/error
if response.msg_type != "progress" { if response.msg_type != "progress" {
return Ok(response); return Ok(response);
} }
} }
} else { } else {
return Err("Sidecar stdout not available".to_string()); Err("Sidecar stdout not available".to_string())
} }
} else {
Err("Sidecar not running".to_string())
} }
} }
/// Stop the sidecar process. /// Stop the sidecar process.
pub fn stop(&self) -> Result<(), String> { pub fn stop(&self) -> Result<(), String> {
// Drop stdin to signal EOF
{
let mut stdin_guard = self.stdin.lock().map_err(|e| e.to_string())?;
*stdin_guard = None;
}
// Drop reader
{
let mut reader_guard = self.reader.lock().map_err(|e| e.to_string())?;
*reader_guard = None;
}
// Wait for process to exit
{
let mut proc = self.process.lock().map_err(|e| e.to_string())?; let mut proc = self.process.lock().map_err(|e| e.to_string())?;
if let Some(ref mut child) = proc.take() { if let Some(ref mut child) = proc.take() {
// Close stdin to signal EOF
drop(child.stdin.take());
// Wait briefly for clean exit, then kill
match child.wait() { match child.wait() {
Ok(_) => Ok(()), Ok(_) => {}
Err(e) => { Err(_) => {
let _ = child.kill(); let _ = child.kill();
Err(format!("Sidecar did not exit cleanly: {e}"))
} }
} }
} else { }
}
Ok(()) Ok(())
} }
}
pub fn is_running(&self) -> bool { pub fn is_running(&self) -> bool {
let proc = self.process.lock().ok(); let proc = self.process.lock().ok();

View File

@@ -3,17 +3,28 @@ use std::sync::Mutex;
use rusqlite::Connection; use rusqlite::Connection;
use crate::db;
use crate::llama::LlamaManager;
/// Shared application state managed by Tauri. /// Shared application state managed by Tauri.
pub struct AppState { pub struct AppState {
pub db: Mutex<Option<Connection>>, pub db: Mutex<Connection>,
pub data_dir: PathBuf, pub data_dir: PathBuf,
} }
impl AppState { impl AppState {
pub fn new(data_dir: PathBuf) -> Self { pub fn new() -> Result<Self, String> {
Self { let data_dir = LlamaManager::data_dir();
db: Mutex::new(None), std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Cannot create data dir: {e}"))?;
let db_path = data_dir.join("voice_to_notes.db");
let conn = db::open_database(&db_path)
.map_err(|e| format!("Cannot open database: {e}"))?;
Ok(Self {
db: Mutex::new(conn),
data_dir, data_dir,
} })
} }
} }

39
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,39 @@
<script>
let { children } = $props();
</script>
{@render children()}
<style>
:global(html, body) {
margin: 0;
padding: 0;
background: #0a0a23;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
overflow: hidden;
height: 100%;
}
:global(*, *::before, *::after) {
box-sizing: border-box;
}
:global(::-webkit-scrollbar) {
width: 8px;
}
:global(::-webkit-scrollbar-track) {
background: #0a0a23;
}
:global(::-webkit-scrollbar-thumb) {
background: #2a3a5e;
border-radius: 4px;
}
:global(::-webkit-scrollbar-thumb:hover) {
background: #4a5568;
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { invoke } from '@tauri-apps/api/core'; import { invoke, convertFileSrc } from '@tauri-apps/api/core';
import { open, save } from '@tauri-apps/plugin-dialog'; import { open, save } from '@tauri-apps/plugin-dialog';
import WaveformPlayer from '$lib/components/WaveformPlayer.svelte'; import WaveformPlayer from '$lib/components/WaveformPlayer.svelte';
import TranscriptEditor from '$lib/components/TranscriptEditor.svelte'; import TranscriptEditor from '$lib/components/TranscriptEditor.svelte';
@@ -81,8 +81,8 @@
}); });
if (!filePath) return; if (!filePath) return;
// Convert file path to URL for wavesurfer // Convert file path to asset URL for wavesurfer
audioUrl = `asset://localhost/${encodeURIComponent(filePath)}`; audioUrl = convertFileSrc(filePath);
waveformPlayer?.loadAudio(audioUrl); waveformPlayer?.loadAudio(audioUrl);
// Start pipeline (transcription + diarization) // Start pipeline (transcription + diarization)