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:
2026-04-10 22:04:39 +01:00
parent f9d65ff850
commit 0cea6a4b28
19 changed files with 1948 additions and 490 deletions
+139 -2
View File
@@ -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)