diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9388408 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +**Prerequisites:** +- [Rust](https://rustup.rs/) (latest stable) +- [Bun](https://bun.sh/) package manager + +**Core Development:** +```bash +# Install dependencies +bun install + +# Run in development mode +bun run tauri dev +# If cmake error on macOS: +CMAKE_POLICY_VERSION_MINIMUM=3.5 bun run tauri dev + +# Build for production +bun run tauri build + +# Frontend only development +bun run dev # Start Vite dev server +bun run build # Build frontend (TypeScript + Vite) +bun run preview # Preview built frontend +``` + +**Model Setup (Required for Development):** +```bash +# Create models directory +mkdir -p src-tauri/resources/models + +# Download required VAD model +curl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx +``` + +## Architecture Overview + +Handy is a cross-platform desktop speech-to-text application built with Tauri (Rust backend + React/TypeScript frontend). + +### Core Components + +**Backend (Rust - src-tauri/src/):** +- `lib.rs` - Main application entry point with Tauri setup, tray menu, and managers +- `managers/` - Core business logic managers: + - `audio.rs` - Audio recording and device management + - `model.rs` - Whisper model downloading and management + - `transcription.rs` - Speech-to-text processing pipeline +- `audio_toolkit/` - Low-level audio processing: + - `audio/` - Device enumeration, recording, resampling + - `vad/` - Voice Activity Detection using Silero VAD +- `commands/` - Tauri command handlers for frontend communication +- `shortcut.rs` - Global keyboard shortcut handling +- `settings.rs` - Application settings management + +**Frontend (React/TypeScript - src/):** +- `App.tsx` - Main application component with onboarding flow +- `components/settings/` - Settings UI components +- `components/model-selector/` - Model management interface +- `hooks/` - React hooks for settings and model management +- `lib/types.ts` - Shared TypeScript type definitions + +### Key Architecture Patterns + +**Manager Pattern:** Core functionality is organized into managers (Audio, Model, Transcription) that are initialized at startup and managed by Tauri's state system. + +**Command-Event Architecture:** Frontend communicates with backend via Tauri commands, backend sends updates via events. + +**Pipeline Processing:** Audio → VAD → Whisper → Text output with configurable components at each stage. + +### Technology Stack + +**Core Libraries:** +- `whisper-rs` - Local Whisper inference with GPU acceleration +- `cpal` - Cross-platform audio I/O +- `vad-rs` - Voice Activity Detection +- `rdev` - Global keyboard shortcuts +- `rubato` - Audio resampling +- `rodio` - Audio playback for feedback sounds + +**Platform-Specific Features:** +- macOS: Metal acceleration for Whisper, accessibility permissions +- Windows: Vulkan acceleration, code signing +- Linux: OpenBLAS + Vulkan acceleration + +### Application Flow + +1. **Initialization:** App starts minimized to tray, loads settings, initializes managers +2. **Model Setup:** First-run downloads preferred Whisper model (Small/Medium/Turbo/Large) +3. **Recording:** Global shortcut triggers audio recording with VAD filtering +4. **Processing:** Audio sent to Whisper model for transcription +5. **Output:** Text pasted to active application via system clipboard + +### Settings System + +Settings are stored using Tauri's store plugin with reactive updates: +- Keyboard shortcuts (configurable, supports push-to-talk) +- Audio devices (microphone/output selection) +- Model preferences (Small/Medium/Turbo/Large Whisper variants) +- Audio feedback and translation options + +### Single Instance Architecture + +The app enforces single instance behavior - launching when already running brings the settings window to front rather than creating a new process. \ No newline at end of file diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2d8ee0a..1cddaa5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,2 +1,10 @@ pub mod audio; pub mod models; + +use crate::utils::cancel_current_operation; +use tauri::AppHandle; + +#[tauri::command] +pub fn cancel_operation(app: AppHandle) { + cancel_current_operation(&app); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 11e4d7e..eb8e6fe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,7 +12,7 @@ use managers::transcription::TranscriptionManager; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tauri::image::Image; -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; + use tauri::tray::TrayIconBuilder; use tauri::Emitter; use tauri::{AppHandle, Manager}; @@ -74,48 +74,11 @@ pub fn run() { )) .manage(Mutex::new(ShortcutToggleStates::default())) .setup(move |app| { - let version = env!("CARGO_PKG_VERSION"); - let version_label = format!("Handy v{}", version); - let version_i = MenuItem::with_id(app, "version", &version_label, false, None::<&str>)?; - - // Platform-specific accelerators - #[cfg(target_os = "macos")] - let settings_accelerator = Some("Cmd+,"); - #[cfg(not(target_os = "macos"))] - let settings_accelerator = Some("Ctrl+,"); - - #[cfg(target_os = "macos")] - let quit_accelerator = Some("Cmd+Q"); - #[cfg(not(target_os = "macos"))] - let quit_accelerator = Some("Ctrl+Q"); - - let settings_i = - MenuItem::with_id(app, "settings", "Settings...", true, settings_accelerator)?; - let check_updates_i = MenuItem::with_id( - app, - "check_updates", - "Check for Updates...", - true, - None::<&str>, - )?; - let quit_i = MenuItem::with_id(app, "quit", "Quit", true, quit_accelerator)?; - let menu = Menu::with_items( - app, - &[ - &version_i, - &PredefinedMenuItem::separator(app)?, - &settings_i, - &check_updates_i, - &PredefinedMenuItem::separator(app)?, - &quit_i, - ], - )?; let tray = TrayIconBuilder::new() .icon(Image::from_path(app.path().resolve( "resources/tray_idle.png", tauri::path::BaseDirectory::Resource, )?)?) - .menu(&menu) .show_menu_on_left_click(true) .icon_as_template(true) .on_menu_event(|app, event| match event.id.as_ref() { @@ -126,6 +89,12 @@ pub fn run() { show_main_window(app); let _ = app.emit("check-for-updates", ()); } + "cancel" => { + use crate::utils::cancel_current_operation; + + // Use centralized cancellation that handles all operations + cancel_current_operation(app); + } "quit" => { app.exit(0); } @@ -134,6 +103,9 @@ pub fn run() { .build(app)?; app.manage(tray); + // Initialize tray menu with idle state + utils::update_tray_menu(&app.handle(), &utils::TrayIconState::Idle); + // Get the autostart manager let autostart_manager = app.autolaunch(); // Enable autostart @@ -182,6 +154,7 @@ pub fn run() { shortcut::change_translate_to_english_setting, shortcut::change_selected_language_setting, trigger_update_check, + commands::cancel_operation, commands::models::get_available_models, commands::models::get_model_info, commands::models::download_model, diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index e5c8994..0988aff 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -258,4 +258,25 @@ impl AudioRecordingManager { _ => None, } } + + /// Cancel any ongoing recording without returning audio samples + pub fn cancel_recording(&self) { + let mut state = self.state.lock().unwrap(); + + if let RecordingState::Recording { .. } = *state { + *state = RecordingState::Idle; + drop(state); + + if let Some(rec) = self.recorder.lock().unwrap().as_ref() { + let _ = rec.stop(); // Discard the result + } + + *self.is_recording.lock().unwrap() = false; + + // In on-demand mode turn the mic off again + if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { + self.stop_microphone_stream(); + } + } + } } diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 54bf605..400b271 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -11,6 +11,7 @@ use std::fs::File; use std::io::BufReader; use std::thread; use tauri::image::Image; +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIcon; use tauri::AppHandle; use tauri::Manager; @@ -70,6 +71,7 @@ pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { Ok(()) } +#[derive(Clone, Debug, PartialEq)] pub enum TrayIconState { Idle, Recording, @@ -93,6 +95,120 @@ pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) { ) .expect("failed to set icon"), )); + + // Update menu based on state + update_tray_menu(app, &icon); +} + +/// Centralized cancellation function that can be called from anywhere in the app. +/// Handles cancelling both recording and transcription operations and updates UI state. +pub fn cancel_current_operation(app: &AppHandle) { + use crate::actions::ACTION_MAP; + use crate::managers::audio::AudioRecordingManager; + use crate::ManagedToggleState; + use std::sync::Arc; + + println!("Initiating operation cancellation..."); + + // First, reset all shortcut toggle states and call stop actions + // This is critical for non-push-to-talk mode where shortcuts toggle on/off + let toggle_state_manager = app.state::(); + if let Ok(mut states) = toggle_state_manager.lock() { + // For each currently active toggle, call its stop action and reset state + let active_bindings: Vec = states + .active_toggles + .iter() + .filter(|(_, &is_active)| is_active) + .map(|(binding_id, _)| binding_id.clone()) + .collect(); + + for binding_id in active_bindings { + println!("Stopping active action for binding: {}", binding_id); + + // Call the action's stop method to ensure proper cleanup + if let Some(action) = ACTION_MAP.get(&binding_id) { + action.stop(app, &binding_id, "cancelled"); + } + + // Reset the toggle state + if let Some(is_active) = states.active_toggles.get_mut(&binding_id) { + *is_active = false; + } + } + } else { + eprintln!("Warning: Failed to lock toggle state manager during cancellation"); + } + + // Cancel any ongoing recording + let audio_manager = app.state::>(); + audio_manager.cancel_recording(); + + // Update tray icon and menu to idle state + change_tray_icon(app, TrayIconState::Idle); + + println!("Operation cancellation completed - returned to idle state"); +} + +pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState) { + // Platform-specific accelerators + #[cfg(target_os = "macos")] + let (settings_accelerator, quit_accelerator) = (Some("Cmd+,"), Some("Cmd+Q")); + #[cfg(not(target_os = "macos"))] + let (settings_accelerator, quit_accelerator) = (Some("Ctrl+,"), Some("Ctrl+Q")); + + // Create common menu items + let version_label = format!("Handy v{}", env!("CARGO_PKG_VERSION")); + let version_i = MenuItem::with_id(app, "version", &version_label, false, None::<&str>) + .expect("failed to create version item"); + let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, settings_accelerator) + .expect("failed to create settings item"); + let check_updates_i = MenuItem::with_id( + app, + "check_updates", + "Check for Updates...", + true, + None::<&str>, + ) + .expect("failed to create check updates item"); + let quit_i = MenuItem::with_id(app, "quit", "Quit", true, quit_accelerator) + .expect("failed to create quit item"); + let separator = || PredefinedMenuItem::separator(app).expect("failed to create separator"); + + let menu = match state { + TrayIconState::Recording | TrayIconState::Transcribing => { + let cancel_i = MenuItem::with_id(app, "cancel", "Cancel", true, None::<&str>) + .expect("failed to create cancel item"); + Menu::with_items( + app, + &[ + &version_i, + &separator(), + &cancel_i, + &separator(), + &settings_i, + &check_updates_i, + &separator(), + &quit_i, + ], + ) + .expect("failed to create menu") + } + TrayIconState::Idle => Menu::with_items( + app, + &[ + &version_i, + &separator(), + &settings_i, + &check_updates_i, + &separator(), + &quit_i, + ], + ) + .expect("failed to create menu"), + }; + + let tray = app.state::(); + let _ = tray.set_menu(Some(menu)); let _ = tray.set_icon_as_template(true); }