diff --git a/python/tests/test_protocol.py b/python/tests/test_protocol.py index 95a588a..688c41f 100644 --- a/python/tests/test_protocol.py +++ b/python/tests/test_protocol.py @@ -5,16 +5,23 @@ import json from voice_to_notes.ipc.messages import IPCMessage from voice_to_notes.ipc.protocol import read_message, write_message +import voice_to_notes.ipc.protocol as protocol -def test_write_message(capsys): - msg = IPCMessage(id="req-1", type="pong", payload={"ok": True}) - write_message(msg) - captured = capsys.readouterr() - parsed = json.loads(captured.out.strip()) - assert parsed["id"] == "req-1" - assert parsed["type"] == "pong" - assert parsed["payload"]["ok"] is True +def test_write_message(): + buf = io.StringIO() + # Temporarily replace the IPC output stream + old_out = protocol._ipc_out + protocol._ipc_out = buf + try: + msg = IPCMessage(id="req-1", type="pong", payload={"ok": True}) + write_message(msg) + parsed = json.loads(buf.getvalue().strip()) + assert parsed["id"] == "req-1" + assert parsed["type"] == "pong" + assert parsed["payload"]["ok"] is True + finally: + protocol._ipc_out = old_out def test_read_message(monkeypatch): diff --git a/python/voice_to_notes/ipc/protocol.py b/python/voice_to_notes/ipc/protocol.py index e55393f..57054d7 100644 --- a/python/voice_to_notes/ipc/protocol.py +++ b/python/voice_to_notes/ipc/protocol.py @@ -1,13 +1,53 @@ -"""JSON-line protocol reader/writer over stdin/stdout.""" +"""JSON-line protocol reader/writer over stdin/stdout. + +IMPORTANT: stdout is reserved exclusively for IPC messages. +At init time we save the real stdout, then redirect sys.stdout → stderr +so that any rogue print() calls from libraries don't corrupt the IPC stream. +""" from __future__ import annotations +import io import json +import os import sys from typing import Any from voice_to_notes.ipc.messages import IPCMessage +# Save the real stdout fd for IPC before any library can pollute it. +# Then redirect sys.stdout to stderr so library prints go to stderr. +_ipc_out: io.TextIOWrapper | None = None + + +def init_ipc() -> None: + """Capture real stdout for IPC and redirect sys.stdout to stderr. + + Must be called once at sidecar startup, before importing any ML libraries. + """ + global _ipc_out + if _ipc_out is not None: + return # already initialised + + # Duplicate the real stdout fd so we keep it even after redirect + real_stdout_fd = os.dup(sys.stdout.fileno()) + _ipc_out = io.TextIOWrapper( + io.BufferedWriter(io.FileIO(real_stdout_fd, "w")), + encoding="utf-8", + line_buffering=True, + ) + + # Redirect sys.stdout → stderr so print() from libraries goes to stderr + sys.stdout = sys.stderr + + +def _get_ipc_out() -> io.TextIOWrapper: + """Return the IPC output stream, falling back to sys.__stdout__.""" + if _ipc_out is not None: + return _ipc_out + # Fallback if init_ipc() was never called (e.g. in tests) + return sys.__stdout__ + def read_message() -> IPCMessage | None: """Read a single JSON-line message from stdin. Returns None on EOF.""" @@ -29,17 +69,19 @@ def read_message() -> IPCMessage | None: def write_message(msg: IPCMessage) -> None: - """Write a JSON-line message to stdout.""" + """Write a JSON-line message to the IPC channel (real stdout).""" + out = _get_ipc_out() line = json.dumps(msg.to_dict(), separators=(",", ":")) - sys.stdout.write(line + "\n") - sys.stdout.flush() + out.write(line + "\n") + out.flush() def write_dict(data: dict[str, Any]) -> None: - """Write a raw dict as a JSON-line message to stdout.""" + """Write a raw dict as a JSON-line message to the IPC channel.""" + out = _get_ipc_out() line = json.dumps(data, separators=(",", ":")) - sys.stdout.write(line + "\n") - sys.stdout.flush() + out.write(line + "\n") + out.flush() def _log(message: str) -> None: diff --git a/python/voice_to_notes/main.py b/python/voice_to_notes/main.py index fedff95..77e9e7d 100644 --- a/python/voice_to_notes/main.py +++ b/python/voice_to_notes/main.py @@ -5,7 +5,13 @@ from __future__ import annotations import signal import sys -from voice_to_notes.ipc.handlers import ( +# CRITICAL: Capture real stdout for IPC *before* importing any ML libraries +# that might print to stdout and corrupt the JSON-line protocol. +from voice_to_notes.ipc.protocol import init_ipc + +init_ipc() + +from voice_to_notes.ipc.handlers import ( # noqa: E402 HandlerRegistry, hardware_detect_handler, make_ai_chat_handler, @@ -15,8 +21,8 @@ from voice_to_notes.ipc.handlers import ( make_transcribe_handler, ping_handler, ) -from voice_to_notes.ipc.messages import ready_message -from voice_to_notes.ipc.protocol import read_message, write_message +from voice_to_notes.ipc.messages import ready_message # noqa: E402 +from voice_to_notes.ipc.protocol import read_message, write_message # noqa: E402 def create_registry() -> HandlerRegistry: diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ff523ae..e13b003 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,9 @@ pub mod llama; pub mod sidecar; pub mod state; +use tauri::window::Color; +use tauri::Manager; + use commands::ai::{ai_chat, ai_configure, ai_list_providers}; use commands::export::export_transcript; use commands::project::{create_project, get_project, list_projects}; @@ -20,6 +23,13 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .manage(app_state) + .setup(|app| { + // Set the webview background to match the app's dark theme + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_background_color(Some(Color(10, 10, 35, 255))); + } + Ok(()) + }) .invoke_handler(tauri::generate_handler![ create_project, get_project, diff --git a/src-tauri/src/sidecar/mod.rs b/src-tauri/src/sidecar/mod.rs index c8cc33e..39cf8dc 100644 --- a/src-tauri/src/sidecar/mod.rs +++ b/src-tauri/src/sidecar/mod.rs @@ -107,8 +107,9 @@ impl SidecarManager { return Ok(()); } } - // Non-ready message: something is wrong - break; + // Non-JSON or non-ready line — skip and keep waiting + eprintln!("[sidecar-rs] Skipping pre-ready line: {}", &trimmed[..trimmed.len().min(200)]); + continue; } } Err("Sidecar did not send ready message".to_string()) @@ -160,8 +161,14 @@ impl SidecarManager { if trimmed.is_empty() { continue; } - let response: IPCMessage = serde_json::from_str(trimmed) - .map_err(|e| format!("Parse error: {e}"))?; + // 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); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5d02585..52f8401 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,9 @@ "width": 1200, "height": 800, "minWidth": 800, - "minHeight": 600 + "minHeight": 600, + "decorations": true, + "transparent": false } ], "security": { diff --git a/src/app.html b/src/app.html index b155e3e..5ad1b28 100644 --- a/src/app.html +++ b/src/app.html @@ -1,5 +1,5 @@ - + @@ -7,7 +7,7 @@ Voice to Notes %sveltekit.head% - +
%sveltekit.body%
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 061e480..d1e1eba 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -11,7 +11,7 @@ import { segments, speakers } from '$lib/stores/transcript'; import { settings, loadSettings } from '$lib/stores/settings'; import type { Segment, Speaker } from '$lib/types/transcript'; - import { onMount } from 'svelte'; + import { onMount, tick } from 'svelte'; let waveformPlayer: WaveformPlayer; let audioUrl = $state(''); @@ -87,12 +87,15 @@ audioUrl = convertFileSrc(filePath); waveformPlayer?.loadAudio(audioUrl); - // Start pipeline (transcription + diarization) + // Start pipeline — show overlay immediately before heavy processing isTranscribing = true; transcriptionProgress = 0; transcriptionStage = 'Starting...'; transcriptionMessage = 'Initializing pipeline...'; + // Flush DOM so the progress overlay renders before the blocking invoke + await tick(); + // Listen for progress events from the sidecar const unlisten = await listen<{ percent: number;