perf/pipeline-improvements #1
@@ -5,16 +5,23 @@ import json
|
|||||||
|
|
||||||
from voice_to_notes.ipc.messages import IPCMessage
|
from voice_to_notes.ipc.messages import IPCMessage
|
||||||
from voice_to_notes.ipc.protocol import read_message, write_message
|
from voice_to_notes.ipc.protocol import read_message, write_message
|
||||||
|
import voice_to_notes.ipc.protocol as protocol
|
||||||
|
|
||||||
|
|
||||||
def test_write_message(capsys):
|
def test_write_message():
|
||||||
msg = IPCMessage(id="req-1", type="pong", payload={"ok": True})
|
buf = io.StringIO()
|
||||||
write_message(msg)
|
# Temporarily replace the IPC output stream
|
||||||
captured = capsys.readouterr()
|
old_out = protocol._ipc_out
|
||||||
parsed = json.loads(captured.out.strip())
|
protocol._ipc_out = buf
|
||||||
assert parsed["id"] == "req-1"
|
try:
|
||||||
assert parsed["type"] == "pong"
|
msg = IPCMessage(id="req-1", type="pong", payload={"ok": True})
|
||||||
assert parsed["payload"]["ok"] is 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):
|
def test_read_message(monkeypatch):
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from voice_to_notes.ipc.messages import IPCMessage
|
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:
|
def read_message() -> IPCMessage | None:
|
||||||
"""Read a single JSON-line message from stdin. Returns None on EOF."""
|
"""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:
|
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=(",", ":"))
|
line = json.dumps(msg.to_dict(), separators=(",", ":"))
|
||||||
sys.stdout.write(line + "\n")
|
out.write(line + "\n")
|
||||||
sys.stdout.flush()
|
out.flush()
|
||||||
|
|
||||||
|
|
||||||
def write_dict(data: dict[str, Any]) -> None:
|
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=(",", ":"))
|
line = json.dumps(data, separators=(",", ":"))
|
||||||
sys.stdout.write(line + "\n")
|
out.write(line + "\n")
|
||||||
sys.stdout.flush()
|
out.flush()
|
||||||
|
|
||||||
|
|
||||||
def _log(message: str) -> None:
|
def _log(message: str) -> None:
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ from __future__ import annotations
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
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,
|
HandlerRegistry,
|
||||||
hardware_detect_handler,
|
hardware_detect_handler,
|
||||||
make_ai_chat_handler,
|
make_ai_chat_handler,
|
||||||
@@ -15,8 +21,8 @@ from voice_to_notes.ipc.handlers import (
|
|||||||
make_transcribe_handler,
|
make_transcribe_handler,
|
||||||
ping_handler,
|
ping_handler,
|
||||||
)
|
)
|
||||||
from voice_to_notes.ipc.messages import ready_message
|
from voice_to_notes.ipc.messages import ready_message # noqa: E402
|
||||||
from voice_to_notes.ipc.protocol import read_message, write_message
|
from voice_to_notes.ipc.protocol import read_message, write_message # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def create_registry() -> HandlerRegistry:
|
def create_registry() -> HandlerRegistry:
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ pub mod llama;
|
|||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
|
use tauri::window::Color;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
use commands::ai::{ai_chat, ai_configure, ai_list_providers};
|
use commands::ai::{ai_chat, ai_configure, ai_list_providers};
|
||||||
use commands::export::export_transcript;
|
use commands::export::export_transcript;
|
||||||
use commands::project::{create_project, get_project, list_projects};
|
use commands::project::{create_project, get_project, list_projects};
|
||||||
@@ -20,6 +23,13 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.manage(app_state)
|
.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![
|
.invoke_handler(tauri::generate_handler![
|
||||||
create_project,
|
create_project,
|
||||||
get_project,
|
get_project,
|
||||||
|
|||||||
@@ -107,8 +107,9 @@ impl SidecarManager {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Non-ready message: something is wrong
|
// Non-JSON or non-ready line — skip and keep waiting
|
||||||
break;
|
eprintln!("[sidecar-rs] Skipping pre-ready line: {}", &trimmed[..trimmed.len().min(200)]);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err("Sidecar did not send ready message".to_string())
|
Err("Sidecar did not send ready message".to_string())
|
||||||
@@ -160,8 +161,14 @@ impl SidecarManager {
|
|||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let response: IPCMessage = serde_json::from_str(trimmed)
|
// Skip non-JSON lines (library output that leaked to stdout)
|
||||||
.map_err(|e| format!("Parse error: {e}"))?;
|
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" {
|
if response.msg_type == "progress" {
|
||||||
on_progress(&response);
|
on_progress(&response);
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"width": 1200,
|
"width": 1200,
|
||||||
"height": 800,
|
"height": 800,
|
||||||
"minWidth": 800,
|
"minWidth": 800,
|
||||||
"minHeight": 600
|
"minHeight": 600,
|
||||||
|
"decorations": true,
|
||||||
|
"transparent": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" style="margin:0;padding:0;background:#0a0a23;height:100%;">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>Voice to Notes</title>
|
<title>Voice to Notes</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover" style="margin:0;padding:0;background:#0a0a23;overflow:hidden;">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { segments, speakers } from '$lib/stores/transcript';
|
import { segments, speakers } from '$lib/stores/transcript';
|
||||||
import { settings, loadSettings } from '$lib/stores/settings';
|
import { settings, loadSettings } from '$lib/stores/settings';
|
||||||
import type { Segment, Speaker } from '$lib/types/transcript';
|
import type { Segment, Speaker } from '$lib/types/transcript';
|
||||||
import { onMount } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
|
|
||||||
let waveformPlayer: WaveformPlayer;
|
let waveformPlayer: WaveformPlayer;
|
||||||
let audioUrl = $state('');
|
let audioUrl = $state('');
|
||||||
@@ -87,12 +87,15 @@
|
|||||||
audioUrl = convertFileSrc(filePath);
|
audioUrl = convertFileSrc(filePath);
|
||||||
waveformPlayer?.loadAudio(audioUrl);
|
waveformPlayer?.loadAudio(audioUrl);
|
||||||
|
|
||||||
// Start pipeline (transcription + diarization)
|
// Start pipeline — show overlay immediately before heavy processing
|
||||||
isTranscribing = true;
|
isTranscribing = true;
|
||||||
transcriptionProgress = 0;
|
transcriptionProgress = 0;
|
||||||
transcriptionStage = 'Starting...';
|
transcriptionStage = 'Starting...';
|
||||||
transcriptionMessage = 'Initializing pipeline...';
|
transcriptionMessage = 'Initializing pipeline...';
|
||||||
|
|
||||||
|
// Flush DOM so the progress overlay renders before the blocking invoke
|
||||||
|
await tick();
|
||||||
|
|
||||||
// Listen for progress events from the sidecar
|
// Listen for progress events from the sidecar
|
||||||
const unlisten = await listen<{
|
const unlisten = await listen<{
|
||||||
percent: number;
|
percent: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user