Merge pull request #49 from vladstudio/cancel-actions-in-menu
Cancel actions in menu
This commit is contained in:
commit
a7c8952266
5 changed files with 262 additions and 38 deletions
106
CLAUDE.md
Normal file
106
CLAUDE.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -1,2 +1,10 @@
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|
||||||
|
use crate::utils::cancel_current_operation;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cancel_operation(app: AppHandle) {
|
||||||
|
cancel_current_operation(&app);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use managers::transcription::TranscriptionManager;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::image::Image;
|
use tauri::image::Image;
|
||||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
|
||||||
use tauri::tray::TrayIconBuilder;
|
use tauri::tray::TrayIconBuilder;
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
@ -74,48 +74,11 @@ pub fn run() {
|
||||||
))
|
))
|
||||||
.manage(Mutex::new(ShortcutToggleStates::default()))
|
.manage(Mutex::new(ShortcutToggleStates::default()))
|
||||||
.setup(move |app| {
|
.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()
|
let tray = TrayIconBuilder::new()
|
||||||
.icon(Image::from_path(app.path().resolve(
|
.icon(Image::from_path(app.path().resolve(
|
||||||
"resources/tray_idle.png",
|
"resources/tray_idle.png",
|
||||||
tauri::path::BaseDirectory::Resource,
|
tauri::path::BaseDirectory::Resource,
|
||||||
)?)?)
|
)?)?)
|
||||||
.menu(&menu)
|
|
||||||
.show_menu_on_left_click(true)
|
.show_menu_on_left_click(true)
|
||||||
.icon_as_template(true)
|
.icon_as_template(true)
|
||||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
|
@ -126,6 +89,12 @@ pub fn run() {
|
||||||
show_main_window(app);
|
show_main_window(app);
|
||||||
let _ = app.emit("check-for-updates", ());
|
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" => {
|
"quit" => {
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +103,9 @@ pub fn run() {
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
app.manage(tray);
|
app.manage(tray);
|
||||||
|
|
||||||
|
// Initialize tray menu with idle state
|
||||||
|
utils::update_tray_menu(&app.handle(), &utils::TrayIconState::Idle);
|
||||||
|
|
||||||
// Get the autostart manager
|
// Get the autostart manager
|
||||||
let autostart_manager = app.autolaunch();
|
let autostart_manager = app.autolaunch();
|
||||||
// Enable autostart
|
// Enable autostart
|
||||||
|
|
@ -182,6 +154,7 @@ pub fn run() {
|
||||||
shortcut::change_translate_to_english_setting,
|
shortcut::change_translate_to_english_setting,
|
||||||
shortcut::change_selected_language_setting,
|
shortcut::change_selected_language_setting,
|
||||||
trigger_update_check,
|
trigger_update_check,
|
||||||
|
commands::cancel_operation,
|
||||||
commands::models::get_available_models,
|
commands::models::get_available_models,
|
||||||
commands::models::get_model_info,
|
commands::models::get_model_info,
|
||||||
commands::models::download_model,
|
commands::models::download_model,
|
||||||
|
|
|
||||||
|
|
@ -258,4 +258,25 @@ impl AudioRecordingManager {
|
||||||
_ => None,
|
_ => 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use std::fs::File;
|
||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use tauri::image::Image;
|
use tauri::image::Image;
|
||||||
|
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||||
use tauri::tray::TrayIcon;
|
use tauri::tray::TrayIcon;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
@ -70,6 +71,7 @@ pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub enum TrayIconState {
|
pub enum TrayIconState {
|
||||||
Idle,
|
Idle,
|
||||||
Recording,
|
Recording,
|
||||||
|
|
@ -93,6 +95,120 @@ pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) {
|
||||||
)
|
)
|
||||||
.expect("failed to set icon"),
|
.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::<ManagedToggleState>();
|
||||||
|
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<String> = 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::<Arc<AudioRecordingManager>>();
|
||||||
|
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::<TrayIcon>();
|
||||||
|
let _ = tray.set_menu(Some(menu));
|
||||||
let _ = tray.set_icon_as_template(true);
|
let _ = tray.set_icon_as_template(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue