Files
mouth/src/overlay.rs
T
steve 0cea6a4b28 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>
2026-04-10 22:04:39 +01:00

339 lines
11 KiB
Rust

use std::num::NonZeroU32;
use tracing::{debug, error, info, warn};
use winit::application::ApplicationHandler;
use winit::dpi::{LogicalSize, PhysicalPosition};
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy};
use winit::window::{Window, WindowAttributes, WindowId, WindowLevel};
use crate::config::OverlayPosition;
const OVERLAY_WIDTH: u32 = 150;
const OVERLAY_HEIGHT: u32 = 18;
/// State of the overlay display.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OverlayState {
Hidden,
Recording,
Transcribing,
Done,
Error,
}
/// Events sent to the overlay from the coordinator.
#[derive(Debug, Clone)]
pub enum OverlayEvent {
SetState(OverlayState),
Shutdown,
}
/// The overlay application handler for winit.
struct OverlayApp {
window: Option<std::rc::Rc<Window>>,
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 {
fn draw(&mut self) {
let Some(surface) = &mut self.surface else { return };
let Some(window) = &self.window else { return };
let size = window.inner_size();
if size.width == 0 || size.height == 0 {
return;
}
let Ok(w) = NonZeroU32::try_from(size.width) else { return };
let Ok(h) = NonZeroU32::try_from(size.height) else { return };
if surface.resize(w, h).is_err() {
return;
}
let Ok(mut buffer) = surface.buffer_mut() else { return };
let color = match self.state {
OverlayState::Hidden => 0x00000000,
OverlayState::Recording => 0xFFDD3333, // Red
OverlayState::Transcribing => 0xFFDDAA33, // Amber
OverlayState::Done => 0xFF33AA33, // Green
OverlayState::Error => 0xFFDD3333, // Red
};
let width = size.width as usize;
let height = size.height as usize;
for y in 0..height {
for x in 0..width {
let radius = 8;
let in_corner = (x < radius || x >= width - radius)
&& (y < radius || y >= height - radius);
let pixel = if in_corner {
let cx = if x < radius { radius } else { width - radius - 1 };
let cy = if y < radius { radius } else { height - radius - 1 };
let dx = x as i32 - cx as i32;
let dy = y as i32 - cy as i32;
if dx * dx + dy * dy <= (radius * radius) as i32 {
color
} else {
0x00000000
}
} else {
color
};
buffer[y * width + x] = pixel;
}
}
let _ = buffer.present();
}
fn update_visibility(&self) {
if let Some(window) = &self.window {
let visible = self.state != OverlayState::Hidden;
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 {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return;
}
let attrs = WindowAttributes::default()
.with_title("Mouth")
.with_inner_size(LogicalSize::new(OVERLAY_WIDTH, OVERLAY_HEIGHT))
.with_resizable(false)
.with_decorations(false)
.with_transparent(true)
.with_window_level(WindowLevel::AlwaysOnTop)
.with_visible(false);
match event_loop.create_window(attrs) {
Ok(window) => {
let window = std::rc::Rc::new(window);
// Position at top center of primary monitor
if let Some(monitor) = window.current_monitor() {
let screen_size = monitor.size();
let pos = match self.position {
OverlayPosition::Top => PhysicalPosition::new(
(screen_size.width - OVERLAY_WIDTH) / 2,
10,
),
OverlayPosition::Bottom => PhysicalPosition::new(
(screen_size.width - OVERLAY_WIDTH) / 2,
screen_size.height - OVERLAY_HEIGHT - 50,
),
OverlayPosition::None => PhysicalPosition::new(0, 0),
};
window.set_outer_position(pos);
}
let context = softbuffer::Context::new(window.clone()).ok();
let surface = context.and_then(|ctx| {
softbuffer::Surface::new(&ctx, window.clone()).ok()
});
if surface.is_none() {
warn!("Could not create softbuffer surface — overlay rendering disabled");
}
self.surface = surface;
self.window = Some(window);
info!("Overlay window created");
}
Err(e) => {
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) {
match event {
OverlayEvent::SetState(state) => {
debug!("Overlay state: {:?} -> {:?}", self.state, state);
self.state = state;
self.update_visibility();
self.draw();
}
OverlayEvent::Shutdown => {
info!("Overlay shutting down");
event_loop.exit();
}
}
}
fn window_event(&mut self, _event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
if let WindowEvent::RedrawRequested = event {
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.
pub fn create_event_loop() -> Result<(EventLoop<OverlayEvent>, EventLoopProxy<OverlayEvent>), winit::error::EventLoopError> {
let event_loop: EventLoop<OverlayEvent> = EventLoop::with_user_event().build()?;
let proxy = event_loop.create_proxy();
Ok((event_loop, proxy))
}
/// Run the event loop with the given position config.
pub fn run_event_loop(
event_loop: EventLoop<OverlayEvent>,
position: OverlayPosition,
) -> Result<(), winit::error::EventLoopError> {
let mut app = OverlayApp {
window: None,
surface: None,
state: OverlayState::Hidden,
position,
_tray_icon: None,
tray_exit_id: None,
};
event_loop.run_app(&mut app)
}