mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-19 06:40:38 -07:00
feat(mcp): Rust-owned speaking pill with self-contained audio playback
The pill window now surfaces for agent-initiated speech without main-window
involvement. Rust subscribes to /events/speak via a tokio task + reqwest
streaming body (speak_monitor.rs), shows the pill, and forwards events to
the dictate webview over Tauri's event bus. The pill plays audio via a
plain HTMLAudioElement and emits dictate:hide when playback ends. The
pill stays hidden through the ~1 s generation wait and only surfaces when
audio actually starts, with the counter armed at that moment.
Fixes a shared-dict mutation in mcp_server/events.publish() that caused
the second subscriber (Rust speak_monitor) to receive `event: message`
instead of named speak-start/speak-end frames. Also teaches the speak_monitor
parser to handle CRLF framing (sse-starlette default). Main-window
AudioPlayer now skips autoplay for source in {mcp, rest} to avoid
double-play when both windows are alive.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0cef2c9fe1
commit
6b75e097e1
@@ -172,7 +172,6 @@ pub struct HotkeyMonitor {
|
||||
|
||||
impl HotkeyMonitor {
|
||||
pub fn spawn(app: AppHandle, bindings: Bindings) -> Self {
|
||||
eprintln!("[HotkeyMonitor] spawn() called with {} bindings", bindings.len());
|
||||
let chord = Arc::new(Mutex::new(Chord::new(bindings)));
|
||||
let chord_for_thread = chord.clone();
|
||||
let app_for_thread = app.clone();
|
||||
@@ -184,9 +183,7 @@ impl HotkeyMonitor {
|
||||
#[cfg(target_os = "macos")]
|
||||
rdev::set_is_main_thread(false);
|
||||
|
||||
eprintln!("[HotkeyMonitor] background thread entering rdev::listen");
|
||||
let result = listen(move |event| {
|
||||
eprintln!("[HotkeyMonitor] rdev event: {:?}", event.event_type);
|
||||
let input = match event.event_type {
|
||||
EventType::KeyPress(k) => KeyEvent::Down(k),
|
||||
EventType::KeyRelease(k) => KeyEvent::Up(k),
|
||||
@@ -198,17 +195,11 @@ impl HotkeyMonitor {
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if !effects.is_empty() {
|
||||
eprintln!("[HotkeyMonitor] chord matched, effects: {:?}", effects);
|
||||
}
|
||||
|
||||
for effect in effects {
|
||||
apply_effect(&app_for_thread, effect);
|
||||
}
|
||||
});
|
||||
|
||||
// listen() blocks forever on success; reaching here means it errored.
|
||||
eprintln!("[HotkeyMonitor] rdev::listen returned (this only happens on error): {:?}", result);
|
||||
if let Err(err) = result {
|
||||
eprintln!(
|
||||
"HotkeyMonitor: rdev::listen failed ({:?}). Global chord detection is disabled. On macOS, grant Input Monitoring in System Settings → Privacy & Security → Input Monitoring and relaunch.",
|
||||
|
||||
+53
-28
@@ -11,6 +11,7 @@ mod hotkey_monitor;
|
||||
mod input_monitoring;
|
||||
#[cfg(desktop)]
|
||||
mod key_codes;
|
||||
mod speak_monitor;
|
||||
mod synthetic_keys;
|
||||
|
||||
use std::sync::Mutex;
|
||||
@@ -66,33 +67,56 @@ fn build_dictate_window(app: &tauri::AppHandle) -> tauri::Result<tauri::WebviewW
|
||||
/// `Effect::StartRecording` path runs, minus the focus snapshot — this is
|
||||
/// for agent-initiated speech, not dictation, so there's no focused text
|
||||
/// field to paste into.
|
||||
/// Build the pill webview if it doesn't exist yet. Idempotent — used by
|
||||
/// agent-speech to prime the webview on speak-start so its listeners can
|
||||
/// register before the actual show arrives from `audio.onplaying`.
|
||||
#[cfg(desktop)]
|
||||
pub fn show_dictate_window(app: &tauri::AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(DICTATE_WINDOW_LABEL) {
|
||||
// current_monitor() returns None when the window has been parked
|
||||
// off any display by the hide path; fall back to the primary.
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.ok()
|
||||
.flatten()
|
||||
.or_else(|| window.primary_monitor().ok().flatten());
|
||||
if let Some(monitor) = monitor {
|
||||
let monitor_pos = monitor.position();
|
||||
let monitor_size = monitor.size();
|
||||
if let Ok(win_size) = window.outer_size() {
|
||||
let x = monitor_pos.x
|
||||
+ (monitor_size.width as i32 - win_size.width as i32) / 2;
|
||||
let y = monitor_pos.y + (monitor_size.height as f64 * 0.04) as i32;
|
||||
let _ = window.set_position(PhysicalPosition::new(x, y));
|
||||
}
|
||||
pub fn ensure_dictate_window(app: &tauri::AppHandle) {
|
||||
if app.get_webview_window(DICTATE_WINDOW_LABEL).is_none() {
|
||||
if let Err(e) = build_dictate_window(app) {
|
||||
eprintln!("ensure_dictate_window: failed to build pill: {e}");
|
||||
}
|
||||
let _ = window.set_ignore_cursor_events(false);
|
||||
let _ = window.show();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
pub fn show_dictate_window(app: &tauri::AppHandle) {
|
||||
// Build on demand so agent-initiated speech works before the user has
|
||||
// enabled the global hotkey (the hotkey path is the other place this
|
||||
// window gets built, see `enable_hotkey`).
|
||||
let window = match app.get_webview_window(DICTATE_WINDOW_LABEL) {
|
||||
Some(w) => w,
|
||||
None => match build_dictate_window(app) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
eprintln!("show_dictate_window: failed to build pill window: {e}");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
// current_monitor() returns None when the window has been parked
|
||||
// off any display by the hide path; fall back to the primary.
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.ok()
|
||||
.flatten()
|
||||
.or_else(|| window.primary_monitor().ok().flatten());
|
||||
if let Some(monitor) = monitor {
|
||||
let monitor_pos = monitor.position();
|
||||
let monitor_size = monitor.size();
|
||||
if let Ok(win_size) = window.outer_size() {
|
||||
let x = monitor_pos.x
|
||||
+ (monitor_size.width as i32 - win_size.width as i32) / 2;
|
||||
let y = monitor_pos.y + (monitor_size.height as f64 * 0.04) as i32;
|
||||
let _ = window.set_position(PhysicalPosition::new(x, y));
|
||||
}
|
||||
}
|
||||
let _ = window.set_ignore_cursor_events(false);
|
||||
let _ = window.show();
|
||||
}
|
||||
|
||||
const LEGACY_PORT: u16 = 8000;
|
||||
const SERVER_PORT: u16 = 17493;
|
||||
pub(crate) const SERVER_PORT: u16 = 17493;
|
||||
|
||||
/// Find a voicebox-server process listening on a given port (Windows only).
|
||||
///
|
||||
@@ -895,7 +919,6 @@ fn enable_hotkey(
|
||||
push_to_talk: Vec<String>,
|
||||
toggle_to_talk: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
eprintln!("[enable_hotkey] called: push={:?}, toggle={:?}", push_to_talk, toggle_to_talk);
|
||||
let bindings = build_chord_bindings(&push_to_talk, &toggle_to_talk)?;
|
||||
|
||||
// Fire the Input Monitoring TCC prompt explicitly from the user's
|
||||
@@ -908,9 +931,7 @@ fn enable_hotkey(
|
||||
// The call returns the current grant state; we ignore it because
|
||||
// rdev::listen will surface its own error via stderr, and the
|
||||
// settings UI polls `check_input_monitoring_permission` separately.
|
||||
let granted = input_monitoring::request();
|
||||
eprintln!("[enable_hotkey] IOHIDRequestAccess returned granted={}", granted);
|
||||
eprintln!("[enable_hotkey] IOHIDCheckAccess says trusted={}", input_monitoring::is_trusted());
|
||||
let _ = input_monitoring::request();
|
||||
|
||||
// The dictate pill webview must exist before the first chord fires so it
|
||||
// can subscribe to `dictate:start`. Build it here (idempotent — Tauri
|
||||
@@ -1225,13 +1246,17 @@ pub fn run() {
|
||||
|
||||
// Agent-initiated speech (voicebox.speak over MCP or POST /speak)
|
||||
// pops the pill up so the user can see what's coming out of their
|
||||
// machine. The DictateWindow subscribes to /events/speak via SSE
|
||||
// and emits `dictate:show` on speak-start; we repeat the same
|
||||
// position+show dance the hotkey path uses.
|
||||
// machine. The `dictate:show` listener is kept for any frontend
|
||||
// caller that wants to force-surface the pill directly, but the
|
||||
// primary source is `speak_monitor` below — Rust subscribes to
|
||||
// the backend /events/speak SSE stream so the pill surfaces even
|
||||
// when no JS window is active.
|
||||
let handle_for_show = app.handle().clone();
|
||||
app.handle().listen("dictate:show", move |_event| {
|
||||
show_dictate_window(&handle_for_show);
|
||||
});
|
||||
|
||||
speak_monitor::spawn_speak_monitor(app.handle().clone());
|
||||
}
|
||||
|
||||
// Hide title bar icon on Windows
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Rust-side subscriber for the backend `/events/speak` SSE stream.
|
||||
//!
|
||||
//! Owns the pill-window lifecycle for agent-initiated speech. The dictate
|
||||
//! webview used to do this itself via `EventSource`, but hidden WebKit
|
||||
//! windows on macOS throttle long-lived network connections, so speak events
|
||||
//! never reached the pill. Tauri's event bus, on the other hand, reliably
|
||||
//! delivers events to hidden webviews (the chord path proves it), so we
|
||||
//! subscribe here and fan out via `emit`.
|
||||
//!
|
||||
//! Flow:
|
||||
//! backend speak-start → show dictate window + emit("dictate:speak-start")
|
||||
//! backend speak-end → emit("dictate:speak-end")
|
||||
//! The pill webview handles the rest (audio playback, then emits
|
||||
//! `dictate:hide` back to Rust when the audio element's `ended` fires).
|
||||
//!
|
||||
//! The task reconnects on any error with a 2 s backoff. There's no fancy
|
||||
//! exponential backoff — the backend either dies with the app or comes back
|
||||
//! quickly, and constant 2 s polling is cheap.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::{ensure_dictate_window, SERVER_PORT};
|
||||
|
||||
pub fn spawn_speak_monitor(app: AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run(app).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run(app: AppHandle) {
|
||||
let url = format!("http://127.0.0.1:{}/events/speak", SERVER_PORT);
|
||||
let client = match reqwest::Client::builder().build() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("speak_monitor: failed to build HTTP client: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
loop {
|
||||
if let Err(e) = stream_once(&client, &url, &app).await {
|
||||
eprintln!("speak_monitor: stream err: {e}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_once(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut resp = client
|
||||
.get(url)
|
||||
.header("Accept", "text/event-stream")
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("speak_monitor: backend returned {}", resp.status()).into());
|
||||
}
|
||||
let mut buf = String::new();
|
||||
while let Some(chunk) = resp.chunk().await? {
|
||||
buf.push_str(std::str::from_utf8(&chunk)?);
|
||||
// sse-starlette emits CRLF framing; the spec also permits LF, so
|
||||
// handle either. Drain whichever separator appears first.
|
||||
loop {
|
||||
let crlf = buf.find("\r\n\r\n");
|
||||
let lf = buf.find("\n\n");
|
||||
let (end, sep_len) = match (crlf, lf) {
|
||||
(Some(c), Some(l)) if c <= l => (c, 4),
|
||||
(Some(c), None) => (c, 4),
|
||||
(_, Some(l)) => (l, 2),
|
||||
(None, None) => break,
|
||||
};
|
||||
let frame: String = buf.drain(..end + sep_len).collect();
|
||||
if let Some((event, data)) = parse_frame(&frame) {
|
||||
dispatch(app, &event, &data);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a single SSE frame into (event_name, data_json).
|
||||
///
|
||||
/// Returns None for comment-only frames (lines starting with `:`) and
|
||||
/// for frames without a recognizable `event:` or `data:` line.
|
||||
fn parse_frame(frame: &str) -> Option<(String, String)> {
|
||||
let mut event: Option<String> = None;
|
||||
let mut data_lines: Vec<&str> = Vec::new();
|
||||
for line in frame.lines() {
|
||||
if line.is_empty() || line.starts_with(':') {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("event:") {
|
||||
event = Some(rest.trim().to_string());
|
||||
} else if let Some(rest) = line.strip_prefix("data:") {
|
||||
data_lines.push(rest.trim_start());
|
||||
}
|
||||
}
|
||||
let event = event?;
|
||||
let data = data_lines.join("\n");
|
||||
Some((event, data))
|
||||
}
|
||||
|
||||
fn dispatch(app: &AppHandle, event: &str, data: &str) {
|
||||
match event {
|
||||
"speak-start" => {
|
||||
// Build the pill webview hidden if it doesn't exist yet so its
|
||||
// listeners can register — but don't *show* it here. The pill
|
||||
// surfaces itself from `audio.onplaying` via `dictate:show`, so
|
||||
// users never see the empty-silent generation window.
|
||||
ensure_dictate_window(app);
|
||||
let _ = app.emit("dictate:speak-start", data.to_string());
|
||||
}
|
||||
"speak-end" => {
|
||||
let _ = app.emit("dictate:speak-end", data.to_string());
|
||||
}
|
||||
// `ready` and `ping` are heartbeats; ignore.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user