v0.2.0: System tray, IPC status, VAD, hotkey grab, and polish
- Add system tray icon with Exit menu (tray-icon/muda) - Add IPC daemon status via named pipe (Windows) / Unix socket (Linux) - Add `mouth status` command to query running daemon - Add daemon lock to prevent multiple instances - Hide Windows console window when running as daemon - Wire up Silero VAD model download and speech filtering - Switch hotkey listener from rdev::listen to rdev::grab to consume hotkeys - Add hotkey capture mode in interactive config (press keys instead of typing) - Add all missing key names (brackets, punctuation, numpad, etc.) - Fix ONNX tensor type mismatches (encoder wants i64, decoder wants i32) - Add 300ms lead-in silence to compensate for mic startup latency - Add 300ms trailing recording after stop for speech not to be clipped - Add 50ms silence before audio feedback blips for device warmup - Reduce overlay size (150x18, was 200x36) - Add PolyForm Noncommercial 1.0.0 license - Flesh out user-focused README - Update release script with Gitea/GitHub forge support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+139
-2
@@ -8,8 +8,8 @@ use winit::window::{Window, WindowAttributes, WindowId, WindowLevel};
|
||||
|
||||
use crate::config::OverlayPosition;
|
||||
|
||||
const OVERLAY_WIDTH: u32 = 200;
|
||||
const OVERLAY_HEIGHT: u32 = 36;
|
||||
const OVERLAY_WIDTH: u32 = 150;
|
||||
const OVERLAY_HEIGHT: u32 = 18;
|
||||
|
||||
/// State of the overlay display.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -34,6 +34,8 @@ struct OverlayApp {
|
||||
surface: Option<softbuffer::Surface<std::rc::Rc<Window>, std::rc::Rc<Window>>>,
|
||||
state: OverlayState,
|
||||
position: OverlayPosition,
|
||||
_tray_icon: Option<tray_icon::TrayIcon>,
|
||||
tray_exit_id: Option<tray_icon::menu::MenuId>,
|
||||
}
|
||||
|
||||
impl OverlayApp {
|
||||
@@ -99,6 +101,43 @@ impl OverlayApp {
|
||||
window.set_visible(visible);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_tray_icon(&mut self) {
|
||||
use tray_icon::menu::{Menu, MenuItem};
|
||||
use tray_icon::TrayIconBuilder;
|
||||
|
||||
let menu = Menu::new();
|
||||
let exit_item = MenuItem::new("Exit", true, None);
|
||||
let exit_id = exit_item.id().clone();
|
||||
if let Err(e) = menu.append(&exit_item) {
|
||||
warn!("Failed to add tray menu item: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let icon = match load_tray_icon() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
warn!("Failed to load tray icon: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match TrayIconBuilder::new()
|
||||
.with_menu(Box::new(menu))
|
||||
.with_tooltip("Mouth — Speech to Text")
|
||||
.with_icon(icon)
|
||||
.build()
|
||||
{
|
||||
Ok(tray) => {
|
||||
info!("System tray icon created");
|
||||
self._tray_icon = Some(tray);
|
||||
self.tray_exit_id = Some(exit_id);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create tray icon: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler<OverlayEvent> for OverlayApp {
|
||||
@@ -154,6 +193,9 @@ impl ApplicationHandler<OverlayEvent> for OverlayApp {
|
||||
error!("Failed to create overlay window: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Create tray icon (must be done on the main/event-loop thread)
|
||||
self.create_tray_icon();
|
||||
}
|
||||
|
||||
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: OverlayEvent) {
|
||||
@@ -176,6 +218,99 @@ impl ApplicationHandler<OverlayEvent> for OverlayApp {
|
||||
self.draw();
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// Poll tray menu events
|
||||
if let Some(exit_id) = &self.tray_exit_id {
|
||||
if let Ok(event) = tray_icon::menu::MenuEvent::receiver().try_recv() {
|
||||
if event.id() == exit_id {
|
||||
info!("Exit requested via tray icon");
|
||||
crate::ipc::cleanup();
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_tray_icon() -> Result<tray_icon::Icon, Box<dyn std::error::Error>> {
|
||||
const S: u32 = 32;
|
||||
let mut pixels = vec![0u8; (S * S * 4) as usize];
|
||||
|
||||
let cx = S as f32 / 2.0;
|
||||
|
||||
for y in 0..S {
|
||||
for x in 0..S {
|
||||
let fx = x as f32 + 0.5;
|
||||
let fy = y as f32 + 0.5;
|
||||
let idx = ((y * S + x) * 4) as usize;
|
||||
|
||||
let mut alpha: f32 = 0.0;
|
||||
|
||||
// Microphone body: rounded rectangle (capsule shape)
|
||||
// Center x=16, from y=3 to y=18, radius 5
|
||||
let mic_top = 3.0;
|
||||
let mic_bot = 18.0;
|
||||
let mic_r = 5.5;
|
||||
let mic_cx = cx;
|
||||
{
|
||||
let dy = fy.clamp(mic_top + mic_r, mic_bot - mic_r);
|
||||
let dist = ((fx - mic_cx).powi(2) + (fy - dy).powi(2)).sqrt();
|
||||
if dist <= mic_r {
|
||||
alpha = 1.0;
|
||||
} else if dist <= mic_r + 1.0 {
|
||||
alpha = alpha.max(mic_r + 1.0 - dist); // anti-alias
|
||||
}
|
||||
}
|
||||
|
||||
// Cradle arc: U-shape below mic, from y=14 to y=22
|
||||
{
|
||||
let arc_cy = 14.0;
|
||||
let arc_r = 8.5;
|
||||
let arc_thickness = 2.2;
|
||||
let dx = fx - cx;
|
||||
let dy = fy - arc_cy;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
if fy >= arc_cy && dist >= arc_r - arc_thickness / 2.0 && dist <= arc_r + arc_thickness / 2.0 {
|
||||
let edge_outer = (arc_r + arc_thickness / 2.0 - dist).min(1.0).max(0.0);
|
||||
let edge_inner = (dist - (arc_r - arc_thickness / 2.0)).min(1.0).max(0.0);
|
||||
alpha = alpha.max(edge_outer.min(edge_inner));
|
||||
}
|
||||
}
|
||||
|
||||
// Stem: vertical line from arc bottom to near bottom
|
||||
{
|
||||
let stem_top = 22.0;
|
||||
let stem_bot = 27.0;
|
||||
let stem_w = 1.2;
|
||||
if fy >= stem_top && fy <= stem_bot && (fx - cx).abs() <= stem_w {
|
||||
let edge = (stem_w - (fx - cx).abs()).min(1.0);
|
||||
alpha = alpha.max(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// Base: horizontal line at bottom
|
||||
{
|
||||
let base_y = 27.0;
|
||||
let base_h = 2.0;
|
||||
let base_hw = 5.0;
|
||||
if fy >= base_y && fy <= base_y + base_h && (fx - cx).abs() <= base_hw {
|
||||
let edge = (base_hw - (fx - cx).abs()).min(1.0);
|
||||
alpha = alpha.max(edge);
|
||||
}
|
||||
}
|
||||
|
||||
let a = (alpha.clamp(0.0, 1.0) * 255.0) as u8;
|
||||
// White icon with alpha (looks good on both light and dark taskbars)
|
||||
pixels[idx] = 255; // R
|
||||
pixels[idx + 1] = 255; // G
|
||||
pixels[idx + 2] = 255; // B
|
||||
pixels[idx + 3] = a; // A
|
||||
}
|
||||
}
|
||||
|
||||
let icon = tray_icon::Icon::from_rgba(pixels, S, S)?;
|
||||
Ok(icon)
|
||||
}
|
||||
|
||||
/// Create an event loop and return the proxy for sending events.
|
||||
@@ -195,6 +330,8 @@ pub fn run_event_loop(
|
||||
surface: None,
|
||||
state: OverlayState::Hidden,
|
||||
position,
|
||||
_tray_icon: None,
|
||||
tray_exit_id: None,
|
||||
};
|
||||
|
||||
event_loop.run_app(&mut app)
|
||||
|
||||
Reference in New Issue
Block a user