From a108825ebc260eebebcedf91c653e8e67551f372 Mon Sep 17 00:00:00 2001 From: Mate Remias Date: Mon, 16 Mar 2026 10:13:49 +0100 Subject: [PATCH 1/4] Add native StatusNotifierItem system tray support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add opt-in system tray icon via the SNI DBus protocol using the ksni crate. The tray shows daemon state (idle/recording/transcribing), supports left-click to toggle recording, and provides a right-click context menu with toggle, cancel, and quit actions. Disabled by default — enable via [tray] enabled = true, --tray flag, or VOXTYPE_TRAY_ENABLED=true. Gracefully degrades when DBus session bus is unavailable. Also upgrades whisper-rs from 0.15.1 to 0.16.0 to fix bindgen struct generation failures with clang 22 / glibc 2.43. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 149 ++++++++++++++++++++- Cargo.toml | 4 + Dockerfile.avx512 | 3 +- Dockerfile.build | 3 +- Dockerfile.onnx | 3 +- Dockerfile.onnx-avx512 | 3 +- Dockerfile.onnx-cuda | 3 +- Dockerfile.onnx-rocm | 3 +- Dockerfile.vulkan | 3 +- docs/CONFIGURATION.md | 21 +++ docs/USER_MANUAL.md | 52 ++++++++ src/cli.rs | 6 + src/config.rs | 30 +++++ src/daemon.rs | 293 ++++++++++++++++++++++++++++++++++++++++- src/lib.rs | 2 + src/main.rs | 12 ++ src/tray/mod.rs | 37 ++++++ src/tray/sni.rs | 178 +++++++++++++++++++++++++ 18 files changed, 792 insertions(+), 13 deletions(-) create mode 100644 src/tray/mod.rs create mode 100644 src/tray/sni.rs diff --git a/Cargo.lock b/Cargo.lock index e600c6cf..f9d2ccfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,6 +62,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "0.6.21" @@ -129,6 +138,17 @@ dependencies = [ "syn", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -288,6 +308,21 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + [[package]] name = "clap" version = "4.5.53" @@ -307,7 +342,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -334,7 +369,7 @@ version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ea63a92086df93893164221ad4f24142086d535b3a0957b9b9bea2dc86301" dependencies = [ - "clap", + "clap 4.5.53", "roff", ] @@ -500,7 +535,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn", ] @@ -530,6 +565,37 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-codegen" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a49da9fdfbe872d4841d56605dc42efa5e6ca3291299b87f44e1cde91a28617c" +dependencies = [ + "clap 2.34.0", + "dbus", + "xml-rs", +] + +[[package]] +name = "dbus-tree" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f456e698ae8e54575e19ddb1f9b7bce2298568524f215496b248eb9498b4f508" +dependencies = [ + "dbus", +] + [[package]] name = "der" version = "0.7.10" @@ -859,6 +925,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -1192,6 +1267,18 @@ dependencies = [ "libc", ] +[[package]] +name = "ksni" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4934310bdd016e55725482b8d35ac0c16fd058c1b955d8959aa2d953b918c85b" +dependencies = [ + "dbus", + "dbus-codegen", + "dbus-tree", + "thiserror 1.0.69", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1210,6 +1297,15 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -1588,7 +1684,7 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi", + "hermit-abi 0.5.2", "libc", ] @@ -2393,6 +2489,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.11.1" @@ -2446,6 +2548,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2760,6 +2871,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2880,6 +2997,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version_check" version = "0.9.5" @@ -2893,7 +3016,7 @@ dependencies = [ "anyhow", "async-trait", "chrono", - "clap", + "clap 4.5.53", "clap_mangen", "cpal", "directories", @@ -2901,6 +3024,7 @@ dependencies = [ "evdev", "hound", "inotify 0.10.2", + "ksni", "libc", "ndarray 0.16.1", "nix 0.29.0", @@ -3272,6 +3396,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -3657,6 +3790,12 @@ dependencies = [ "tap", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "xtask" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d52058bb..ec706a2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,9 @@ notify = "6" # Single instance check pidlock = "0.1" +# System tray (StatusNotifierItem via DBus) +ksni = { version = "0.2", optional = true } + # Meeting mode (Pro feature) uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } @@ -91,6 +94,7 @@ rusqlite = { version = "0.32", features = ["bundled"] } [features] default = [] +tray = ["dep:ksni"] gpu-vulkan = ["whisper-rs/vulkan"] gpu-cuda = ["whisper-rs/cuda"] gpu-metal = ["whisper-rs/metal"] diff --git a/Dockerfile.avx512 b/Dockerfile.avx512 index ea1bdd91..e9229e29 100644 --- a/Dockerfile.avx512 +++ b/Dockerfile.avx512 @@ -20,6 +20,7 @@ RUN apt-get update && apt-get install -y \ cmake \ pkg-config \ libasound2-dev \ + libdbus-1-dev \ git \ binutils \ && rm -rf /var/lib/apt/lists/* @@ -35,7 +36,7 @@ COPY . . # Build with native optimizations (will use AVX-512 if available on host) ENV RUSTFLAGS="-C target-cpu=native" -RUN cargo build --release \ +RUN cargo build --release --features tray \ && cp target/release/voxtype /tmp/voxtype-avx512 # Verify AVX-512 instructions ARE present diff --git a/Dockerfile.build b/Dockerfile.build index b41c6e94..9a444b8a 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -30,6 +30,7 @@ RUN apt-get update && apt-get install -y \ cmake \ pkg-config \ libasound2-dev \ + libdbus-1-dev \ git \ binutils \ && rm -rf /var/lib/apt/lists/* @@ -54,7 +55,7 @@ ENV CMAKE_C_FLAGS="-mno-avx512f -mno-avx512vl -mno-avx512bw -mno-avx512dq -mno-a ENV CMAKE_CXX_FLAGS="-mno-avx512f -mno-avx512vl -mno-avx512bw -mno-avx512dq -mno-avx512cd -mno-gfni -mno-avxvnni" # Build AVX2 binary -RUN cargo build --release \ +RUN cargo build --release --features tray \ && cp target/release/voxtype /tmp/voxtype-avx2 # Verify no AVX-512 or GFNI instructions diff --git a/Dockerfile.onnx b/Dockerfile.onnx index b13ba66e..22191d36 100644 --- a/Dockerfile.onnx +++ b/Dockerfile.onnx @@ -33,6 +33,7 @@ RUN apt-get update && apt-get install -y \ cmake \ pkg-config \ libasound2-dev \ + libdbus-1-dev \ libssl-dev \ protobuf-compiler \ libprotobuf-dev \ @@ -62,7 +63,7 @@ ENV ORT_STRATEGY=download # Disable LTO for faster builds (can hang on TrueNAS) # Build with all ONNX engines -RUN cargo build --release --features parakeet,moonshine,sensevoice,paraformer,dolphin,omnilingual,ml-diarization \ +RUN cargo build --release --features parakeet,moonshine,sensevoice,paraformer,dolphin,omnilingual,ml-diarization,tray \ --config 'profile.release.lto=false' \ --config 'profile.release.codegen-units=8' \ && cp target/release/voxtype /tmp/voxtype-onnx-avx2 diff --git a/Dockerfile.onnx-avx512 b/Dockerfile.onnx-avx512 index 45f7bfbc..77f89c23 100644 --- a/Dockerfile.onnx-avx512 +++ b/Dockerfile.onnx-avx512 @@ -24,6 +24,7 @@ RUN apt-get update && apt-get install -y \ cmake \ pkg-config \ libasound2-dev \ + libdbus-1-dev \ libssl-dev \ protobuf-compiler \ libprotobuf-dev \ @@ -48,7 +49,7 @@ ENV ORT_STRATEGY=download # Disable LTO for faster builds # Build with all ONNX engines -RUN cargo build --release --features parakeet,moonshine,sensevoice,paraformer,dolphin,omnilingual,ml-diarization \ +RUN cargo build --release --features parakeet,moonshine,sensevoice,paraformer,dolphin,omnilingual,ml-diarization,tray \ --config 'profile.release.lto=false' \ --config 'profile.release.codegen-units=8' \ && cp target/release/voxtype /tmp/voxtype-onnx-avx512 diff --git a/Dockerfile.onnx-cuda b/Dockerfile.onnx-cuda index dc49c429..ef375cd1 100644 --- a/Dockerfile.onnx-cuda +++ b/Dockerfile.onnx-cuda @@ -27,6 +27,7 @@ RUN apt-get update && apt-get install -y \ cmake \ pkg-config \ libasound2-dev \ + libdbus-1-dev \ libssl-dev \ protobuf-compiler \ libprotobuf-dev \ @@ -49,7 +50,7 @@ ENV ORT_STRATEGY=download # Disable LTO for faster builds # Build with all ONNX engines + CUDA support for NVIDIA GPUs -RUN cargo build --release --features parakeet-cuda,moonshine-cuda,sensevoice-cuda,paraformer-cuda,dolphin-cuda,omnilingual-cuda,ml-diarization \ +RUN cargo build --release --features parakeet-cuda,moonshine-cuda,sensevoice-cuda,paraformer-cuda,dolphin-cuda,omnilingual-cuda,ml-diarization,tray \ --config 'profile.release.lto=false' \ --config 'profile.release.codegen-units=8' \ && cp target/release/voxtype /tmp/voxtype-onnx-cuda diff --git a/Dockerfile.onnx-rocm b/Dockerfile.onnx-rocm index ec000efb..827b820a 100644 --- a/Dockerfile.onnx-rocm +++ b/Dockerfile.onnx-rocm @@ -24,6 +24,7 @@ RUN apt-get update && apt-get install -y \ cmake \ pkg-config \ libasound2-dev \ + libdbus-1-dev \ libssl-dev \ protobuf-compiler \ libprotobuf-dev \ @@ -48,7 +49,7 @@ ENV ORT_STRATEGY=download # Disable LTO for faster builds # Build with all ONNX engines + ROCm for Parakeet (only engine with ROCm support) -RUN cargo build --release --features parakeet-rocm,moonshine,sensevoice,paraformer,dolphin,omnilingual,ml-diarization \ +RUN cargo build --release --features parakeet-rocm,moonshine,sensevoice,paraformer,dolphin,omnilingual,ml-diarization,tray \ --config 'profile.release.lto=false' \ --config 'profile.release.codegen-units=8' \ && cp target/release/voxtype /tmp/voxtype-onnx-rocm diff --git a/Dockerfile.vulkan b/Dockerfile.vulkan index 6de91d50..9fee6997 100644 --- a/Dockerfile.vulkan +++ b/Dockerfile.vulkan @@ -24,6 +24,7 @@ RUN apt-get update && apt-get install -y \ cmake \ pkg-config \ libasound2-dev \ + libdbus-1-dev \ git \ binutils \ libvulkan-dev \ @@ -57,7 +58,7 @@ ENV GGML_NATIVE=ON # Build Vulkan binary (verbose to show progress) # Override Cargo.toml's LTO settings to prevent linking hangs -RUN cargo build --release --features gpu-vulkan \ +RUN cargo build --release --features gpu-vulkan,tray \ --config 'profile.release.lto=false' \ --config 'profile.release.codegen-units=8' \ -vv 2>&1 | tee /tmp/build.log \ diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2b5e2ee5..442ce34b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -2440,6 +2440,27 @@ voxtype status --format json --icon-theme nerd-font --- +## [tray] + +System tray icon via StatusNotifierItem (DBus). Disabled by default. + +Requires building with `--features tray` (included in release binaries) and a StatusNotifierHost (KDE Plasma, GNOME with AppIndicator extension, Waybar with tray module). + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `enabled` | bool | `false` | Show system tray icon | + +**Environment variable:** `VOXTYPE_TRAY_ENABLED=true` + +**CLI flag:** `--tray` + +```toml +[tray] +enabled = true +``` + +--- + ## Environment Variables ### RUST_LOG diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index b939da81..8ae53b31 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -23,6 +23,7 @@ Voxtype is a push-to-talk voice-to-text tool for Linux. Optimized for Wayland, w - [Profiles](#profiles) - [Voice Activity Detection](#voice-activity-detection) - [Meeting Mode](#meeting-mode) +- [System Tray](#system-tray) - [Tips & Best Practices](#tips--best-practices) - [Keyboard Shortcuts](#keyboard-shortcuts) - [Integration Examples](#integration-examples) @@ -2032,6 +2033,57 @@ echo_cancel = "disabled" --- +## System Tray + +Voxtype can show a system tray icon that reflects the current state and lets you control recording. + +### Setup + +Enable the tray in your config: + +```toml +[tray] +enabled = true +``` + +Or use the CLI flag: `voxtype --tray` + +Or set the environment variable: `VOXTYPE_TRAY_ENABLED=true` + +### Requirements + +The tray uses the StatusNotifierItem (SNI) DBus protocol. You need a StatusNotifierHost: + +- **KDE Plasma**: Built-in support +- **GNOME**: Install the AppIndicator extension +- **Waybar**: Enable the `tray` module in your Waybar config +- **Other**: Any compositor/panel that supports SNI + +### Usage + +- **Left-click**: Toggle recording on/off +- **Right-click**: Context menu with Toggle Recording, Cancel (during transcription), and Quit + +### Icons + +The tray uses XDG named icons: + +| State | Icon | +|-------|------| +| Idle | `microphone-sensitivity-high` | +| Recording | `media-record` | +| Transcribing | `content-loading-symbolic` | + +### Troubleshooting + +If the tray icon doesn't appear: + +1. Verify `DBUS_SESSION_BUS_ADDRESS` is set (required for SNI) +2. Check that your panel/compositor has a StatusNotifierHost +3. Run with `-vv` to see tray-related log messages + +--- + ## Tips & Best Practices ### For Best Transcription Quality diff --git a/src/cli.rs b/src/cli.rs index 88763b0a..d5b05526 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -297,6 +297,12 @@ pub struct Cli { Appended before auto_submit. Useful for separating sentences when dictating incrementally.")] pub append_text: Option, + // -- Tray -- + + /// Enable system tray icon (StatusNotifierItem) + #[arg(long, help_heading = "Tray")] + pub tray: bool, + // -- VAD -- /// Enable Voice Activity Detection (filter silence before transcription) diff --git a/src/config.rs b/src/config.rs index 25d4bd0c..20e39519 100644 --- a/src/config.rs +++ b/src/config.rs @@ -285,6 +285,12 @@ on_transcription = true # transcribing = "⏳" # stopped = "" +# [tray] +# System tray icon via StatusNotifierItem (DBus) +# Requires building with --features tray (included in release binaries) +# Requires a StatusNotifierHost (KDE Plasma, GNOME with AppIndicator, Waybar tray module) +# enabled = false + # [profiles] # Named profiles for context-specific post-processing # Use with: voxtype record start --profile slack @@ -370,6 +376,10 @@ pub struct Config { #[serde(default = "default_state_file")] pub state_file: Option, + /// System tray configuration + #[serde(default)] + pub tray: TrayConfig, + /// Named profiles for context-specific settings /// Example: [profiles.slack], [profiles.code] /// Use with: `voxtype record start --profile slack` @@ -377,6 +387,20 @@ pub struct Config { pub profiles: HashMap, } +/// System tray (StatusNotifierItem) configuration +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TrayConfig { + /// Enable the system tray icon (default: false) + #[serde(default)] + pub enabled: bool, +} + +impl Default for TrayConfig { + fn default() -> Self { + Self { enabled: false } + } +} + /// Hotkey detection configuration #[derive(Debug, Clone, Deserialize, Serialize)] pub struct HotkeyConfig { @@ -1877,6 +1901,7 @@ impl Default for Config { vad: VadConfig::default(), status: StatusConfig::default(), meeting: MeetingConfig::default(), + tray: TrayConfig::default(), state_file: Some("auto".to_string()), profiles: HashMap::new(), } @@ -2197,6 +2222,11 @@ pub fn load_config(path: Option<&Path>) -> Result { config.text.smart_auto_submit = parse_bool_env(&val); } + // Tray + if let Ok(val) = std::env::var("VOXTYPE_TRAY_ENABLED") { + config.tray.enabled = parse_bool_env(&val); + } + Ok(config) } diff --git a/src/daemon.rs b/src/daemon.rs index 6e50c7a8..88ee349b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -477,6 +477,14 @@ fn cleanup_model_override() { /// Result type for transcription task type TranscriptionResult = std::result::Result; +/// Tray event type used in the select loop. +/// When the tray feature is enabled, this wraps the real TrayEvent. +/// When disabled, this is a unit struct that can never be constructed. +#[cfg(feature = "tray")] +type DaemonTrayEvent = crate::tray::TrayEvent; +#[cfg(not(feature = "tray"))] +type DaemonTrayEvent = std::convert::Infallible; + /// Main daemon that orchestrates all components pub struct Daemon { config: Config, @@ -521,6 +529,9 @@ pub struct Daemon { speech_enhancer: Option>, // Media players that were paused when recording started (for resume on stop) paused_media_players: Vec, + // System tray state sender + #[cfg(feature = "tray")] + tray_state_tx: Option>, } impl Daemon { @@ -618,6 +629,8 @@ impl Daemon { #[cfg(feature = "onnx-common")] speech_enhancer: None, paused_media_players: Vec::new(), + #[cfg(feature = "tray")] + tray_state_tx: None, } } @@ -643,11 +656,21 @@ impl Daemon { } } - /// Update the state file if configured + /// Update the state file if configured, and notify tray fn update_state(&self, state_name: &str) { if let Some(ref path) = self.state_file_path { write_state_file(path, state_name); } + + #[cfg(feature = "tray")] + if let Some(ref tx) = self.tray_state_tx { + let tray_state = match state_name { + "recording" => crate::tray::TrayState::Recording, + "transcribing" => crate::tray::TrayState::Transcribing, + _ => crate::tray::TrayState::Idle, + }; + let _ = tx.send(tray_state); + } } /// Get the transcriber for the current recording session @@ -1546,6 +1569,242 @@ impl Daemon { } } + /// Handle a single tray event. Returns `true` if the daemon should quit. + #[cfg(feature = "tray")] + async fn handle_tray_event( + &mut self, + event: crate::tray::TrayEvent, + state: &mut State, + audio_capture: &mut Option>, + transcriber_preloaded: &Option>, + ) -> bool { + use crate::tray::TrayEvent; + + match event { + TrayEvent::ToggleRecording => { + tracing::debug!("Tray: toggle recording"); + if state.is_idle() { + tracing::info!("Recording started (tray toggle)"); + + if self.config.output.notification.on_recording_start { + send_notification( + "Recording Started", + "Tray toggle", + self.config.output.notification.show_engine_icon, + self.config.engine, + ) + .await; + } + + if self.config.on_demand_loading() { + match self.config.engine { + crate::config::TranscriptionEngine::Whisper => { + let config = self.config.whisper.clone(); + let config_path = self.config_path.clone(); + self.model_load_task = + Some(tokio::task::spawn_blocking(move || { + let mut temp_manager = + ModelManager::new(&config, config_path); + temp_manager.get_transcriber(None) + })); + } + crate::config::TranscriptionEngine::Parakeet + | crate::config::TranscriptionEngine::Moonshine + | crate::config::TranscriptionEngine::SenseVoice + | crate::config::TranscriptionEngine::Paraformer + | crate::config::TranscriptionEngine::Dolphin + | crate::config::TranscriptionEngine::Omnilingual => { + let config = self.config.clone(); + self.model_load_task = + Some(tokio::task::spawn_blocking(move || { + crate::transcribe::create_transcriber(&config) + .map(Arc::from) + })); + } + } + } else { + match self.config.engine { + crate::config::TranscriptionEngine::Whisper => { + if let Some(ref mut mm) = self.model_manager { + if let Err(e) = mm.prepare_model(None) { + tracing::warn!("Failed to prepare model: {}", e); + } + } + } + crate::config::TranscriptionEngine::Parakeet + | crate::config::TranscriptionEngine::Moonshine + | crate::config::TranscriptionEngine::SenseVoice + | crate::config::TranscriptionEngine::Paraformer + | crate::config::TranscriptionEngine::Dolphin + | crate::config::TranscriptionEngine::Omnilingual => { + if let Some(ref t) = transcriber_preloaded { + let transcriber = t.clone(); + tokio::task::spawn_blocking(move || { + transcriber.prepare(); + }); + } + } + } + } + + match audio::create_capture(&self.config.audio) { + Ok(mut capture) => { + if let Err(e) = capture.start().await { + tracing::error!("Failed to start audio: {}", e); + } else { + *audio_capture = Some(capture); + + if self.config.whisper.eager_processing { + tracing::info!("Using eager input processing"); + *state = State::EagerRecording { + started_at: std::time::Instant::now(), + model_override: None, + accumulated_audio: Vec::new(), + chunks_sent: 0, + chunk_results: Vec::new(), + tasks_in_flight: 0, + }; + } else { + *state = State::Recording { + started_at: std::time::Instant::now(), + model_override: None, + }; + } + self.update_state("recording"); + self.play_feedback(SoundEvent::RecordingStart); + + // Run pre-recording hook + if let Some(cmd) = &self.config.output.pre_recording_command { + if let Err(e) = output::run_hook(cmd, "pre_recording").await { + tracing::warn!("{}", e); + } + } + } + } + Err(e) => { + tracing::error!("Failed to create audio capture: {}", e); + self.play_feedback(SoundEvent::Error); + } + } + } else if let State::Recording { + model_override, .. + } = state + { + let transcriber = match self + .get_transcriber_for_recording( + model_override.as_deref(), + transcriber_preloaded, + ) + .await + { + Ok(t) => Some(t), + Err(()) => { + *state = State::Idle; + self.update_state("idle"); + return false; + } + }; + + self.start_transcription_task(state, audio_capture, transcriber) + .await; + } else if state.is_eager_recording() { + // Stop eager recording (same path as SIGUSR2 eager stop) + let model_override = match state { + State::EagerRecording { model_override, .. } => model_override.clone(), + _ => None, + }; + + let duration = state.recording_duration().unwrap_or_default(); + tracing::info!( + "Eager recording stopped via tray ({:.1}s)", + duration.as_secs_f32() + ); + + self.play_feedback(SoundEvent::RecordingStop); + + if self.config.output.notification.on_recording_stop { + send_notification( + "Recording Stopped", + "Transcribing...", + self.config.output.notification.show_engine_icon, + self.config.engine, + ) + .await; + } + + if let Some(mut capture) = audio_capture.take() { + if let Ok(final_samples) = capture.stop().await { + if let State::EagerRecording { + accumulated_audio, .. + } = state + { + accumulated_audio.extend(final_samples); + } + } + } + + let transcriber = match self + .get_transcriber_for_recording( + model_override.as_deref(), + transcriber_preloaded, + ) + .await + { + Ok(t) => t, + Err(()) => { + *state = State::Idle; + self.update_state("idle"); + return false; + } + }; + + self.update_state("transcribing"); + + if let Some(text) = + self.finish_eager_recording(state, transcriber).await + { + *state = State::Transcribing { audio: Vec::new() }; + self.handle_transcription_result(state, Ok(Ok(text))) + .await; + } else { + tracing::debug!("Eager recording produced empty result"); + self.reset_to_idle(state).await; + } + } + } + TrayEvent::CancelTranscription => { + tracing::debug!("Tray: cancel transcription"); + if matches!(state, State::Transcribing { .. }) { + if let Some(task) = self.transcription_task.take() { + task.abort(); + } + tracing::info!("Transcription cancelled (tray)"); + self.play_feedback(SoundEvent::Cancelled); + self.reset_to_idle(state).await; + } + } + TrayEvent::Quit => { + tracing::info!("Quit requested from tray, shutting down..."); + return true; + } + } + + false + } + + /// No-op tray event handler when tray feature is not compiled in + #[cfg(not(feature = "tray"))] + async fn handle_tray_event( + &mut self, + _event: DaemonTrayEvent, + _state: &mut State, + _audio_capture: &mut Option>, + _transcriber_preloaded: &Option>, + ) -> bool { + // Infallible can never be constructed, so this is unreachable + false + } + /// Run the daemon main loop pub async fn run(&mut self) -> Result<()> { tracing::info!("Starting voxtype daemon"); @@ -1718,6 +1977,26 @@ impl Daemon { ); } + // Initialize system tray if enabled + let mut tray_event_rx: Option> = None; + #[cfg(feature = "tray")] + if self.config.tray.enabled { + match crate::tray::spawn_tray() { + Some((rx, tx)) => { + tracing::info!("System tray enabled"); + tray_event_rx = Some(rx); + self.tray_state_tx = Some(tx); + } + None => { + tracing::warn!("Tray requested but DBus session bus unavailable"); + } + } + } + #[cfg(not(feature = "tray"))] + if self.config.tray.enabled { + tracing::warn!("Tray enabled in config but binary built without tray feature"); + } + // Write initial state self.update_state("idle"); @@ -2805,6 +3084,18 @@ impl Daemon { } } + // Handle system tray events (pending forever when tray feature is not enabled) + Some(tray_event) = async { + match &mut tray_event_rx { + Some(rx) => rx.recv().await, + None => std::future::pending::>().await, + } + } => { + if self.handle_tray_event(tray_event, &mut state, &mut audio_capture, &transcriber_preloaded).await { + break; + } + } + // Handle graceful shutdown (SIGINT from Ctrl+C) _ = tokio::signal::ctrl_c() => { tracing::info!("Received SIGINT, shutting down..."); diff --git a/src/lib.rs b/src/lib.rs index 301291a4..3d1685a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,6 +83,8 @@ pub mod setup; pub mod state; pub mod text; pub mod transcribe; +#[cfg(feature = "tray")] +pub mod tray; pub mod vad; pub use cli::{ diff --git a/src/main.rs b/src/main.rs index 6279e7e6..4d8575f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -327,6 +327,18 @@ async fn main() -> anyhow::Result<()> { config.output.pre_recording_command = Some(cmd); } + // Tray override + if cli.tray { + #[cfg(feature = "tray")] + { + config.tray.enabled = true; + } + #[cfg(not(feature = "tray"))] + { + tracing::warn!("--tray flag ignored: binary built without tray feature"); + } + } + // VAD overrides if cli.vad { config.vad.enabled = true; diff --git a/src/tray/mod.rs b/src/tray/mod.rs new file mode 100644 index 00000000..e8b5a13b --- /dev/null +++ b/src/tray/mod.rs @@ -0,0 +1,37 @@ +//! System tray integration via StatusNotifierItem (SNI) protocol +//! +//! Provides a tray icon that reflects daemon state and accepts user interaction. +//! Requires a StatusNotifierHost (KDE Plasma, GNOME with AppIndicator extension, +//! Waybar with tray module, etc.). + +mod sni; + +/// Tray icon state, mirroring daemon state +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayState { + Idle, + Recording, + Transcribing, +} + +/// Events sent from the tray to the daemon +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayEvent { + ToggleRecording, + CancelTranscription, + Quit, +} + +/// Spawn the system tray icon. +/// +/// Returns channels for bidirectional communication, or `None` if the tray +/// could not be started (e.g., no DBus session bus available). +/// +/// - `Receiver`: events from user interaction (click, menu) +/// - `watch::Sender`: send state updates to the tray icon +pub fn spawn_tray() -> Option<( + tokio::sync::mpsc::Receiver, + tokio::sync::watch::Sender, +)> { + sni::spawn() +} diff --git a/src/tray/sni.rs b/src/tray/sni.rs new file mode 100644 index 00000000..ec3a8c04 --- /dev/null +++ b/src/tray/sni.rs @@ -0,0 +1,178 @@ +//! StatusNotifierItem implementation using ksni + +use super::{TrayEvent, TrayState}; +use std::sync::{Arc, Mutex}; + +/// Mutable state shared with ksni callbacks (runs on ksni's thread). +/// Only `state` needs the mutex — it's updated from the tokio watcher task. +struct SharedState { + state: TrayState, +} + +struct VoxtypeTray { + shared: Arc>, + /// Event sender lives outside the mutex — it's Clone+Send and never mutated. + event_tx: std::sync::mpsc::Sender, +} + +impl VoxtypeTray { + fn send_event(&self, event: TrayEvent) { + let _ = self.event_tx.send(event); + } +} + +impl ksni::Tray for VoxtypeTray { + fn id(&self) -> String { + "voxtype".to_string() + } + + fn title(&self) -> String { + "Voxtype".to_string() + } + + fn category(&self) -> ksni::Category { + ksni::Category::ApplicationStatus + } + + fn icon_name(&self) -> String { + let state = self.shared.lock().unwrap().state; + match state { + TrayState::Idle => "microphone-sensitivity-high".to_string(), + TrayState::Recording => "media-record".to_string(), + TrayState::Transcribing => "content-loading-symbolic".to_string(), + } + } + + fn tool_tip(&self) -> ksni::ToolTip { + let state = self.shared.lock().unwrap().state; + let description = match state { + TrayState::Idle => "Voxtype: Ready", + TrayState::Recording => "Voxtype: Recording...", + TrayState::Transcribing => "Voxtype: Transcribing...", + }; + ksni::ToolTip { + title: description.to_string(), + description: String::new(), + icon_name: String::new(), + icon_pixmap: Vec::new(), + } + } + + fn activate(&mut self, _x: i32, _y: i32) { + self.send_event(TrayEvent::ToggleRecording); + } + + fn menu(&self) -> Vec> { + let state = self.shared.lock().unwrap().state; + + let status_label = match state { + TrayState::Idle => "Status: Idle", + TrayState::Recording => "Status: Recording", + TrayState::Transcribing => "Status: Transcribing", + }; + + vec![ + ksni::MenuItem::Standard(ksni::menu::StandardItem { + label: "Toggle Recording".to_string(), + activate: Box::new(|tray: &mut Self| { + tray.send_event(TrayEvent::ToggleRecording); + }), + ..Default::default() + }), + ksni::MenuItem::Standard(ksni::menu::StandardItem { + label: "Cancel".to_string(), + enabled: state == TrayState::Transcribing, + activate: Box::new(|tray: &mut Self| { + tray.send_event(TrayEvent::CancelTranscription); + }), + ..Default::default() + }), + ksni::MenuItem::Separator, + ksni::MenuItem::Standard(ksni::menu::StandardItem { + label: status_label.to_string(), + enabled: false, + ..Default::default() + }), + ksni::MenuItem::Separator, + ksni::MenuItem::Standard(ksni::menu::StandardItem { + label: "Quit".to_string(), + activate: Box::new(|tray: &mut Self| { + tray.send_event(TrayEvent::Quit); + }), + ..Default::default() + }), + ] + } +} + +/// Spawn the tray service and return communication channels. +/// +/// Returns `None` if the tray service fails to start (e.g., no DBus session bus). +pub fn spawn() -> Option<( + tokio::sync::mpsc::Receiver, + tokio::sync::watch::Sender, +)> { + // Channels: daemon <-> tray + let (state_tx, mut state_rx) = tokio::sync::watch::channel(TrayState::Idle); + let (event_tx, event_rx) = tokio::sync::mpsc::channel::(8); + + // Bridge channel: ksni thread (std::sync) -> tokio task -> tokio mpsc + let (std_event_tx, std_event_rx) = std::sync::mpsc::channel::(); + + let shared = Arc::new(Mutex::new(SharedState { + state: TrayState::Idle, + })); + + let tray = VoxtypeTray { + shared: shared.clone(), + event_tx: std_event_tx, + }; + + // Preflight: check DBus session bus is available before spawning ksni, + // since ksni::TrayService::spawn() panics on DBus failure. + if std::env::var("DBUS_SESSION_BUS_ADDRESS").is_err() { + tracing::warn!("DBUS_SESSION_BUS_ADDRESS not set, skipping tray"); + return None; + } + + // Start the ksni service (runs its own event loop on a separate thread) + let service = ksni::TrayService::new(tray); + + let handle = service.handle(); + service.spawn(); + + // Dedicated thread to bridge std::sync::mpsc -> tokio::sync::mpsc. + // ksni callbacks run on ksni's thread, so they use std::sync::mpsc. + // This thread blocks on recv() and forwards to the tokio channel. + if std::thread::Builder::new() + .name("tray-event-bridge".to_string()) + .spawn(move || { + while let Ok(event) = std_event_rx.recv() { + if event_tx.blocking_send(event).is_err() { + break; // receiver dropped, daemon shutting down + } + } + }) + .is_err() + { + tracing::warn!("Failed to spawn tray event bridge thread"); + return None; + } + + // Task: watch for state changes and update the tray + tokio::spawn(async move { + while state_rx.changed().await.is_ok() { + let new_state = *state_rx.borrow(); + { + let mut s = shared.lock().unwrap(); + s.state = new_state; + } + handle.update(|_tray| { + // The tray reads from shared state, so just triggering + // an update is enough to refresh icon/tooltip/menu + }); + } + }); + + Some((event_rx, state_tx)) +} From a39b16ba23308b93d90ce1503e817163f0bc4c0e Mon Sep 17 00:00:00 2001 From: Mate Remias Date: Mon, 16 Mar 2026 10:50:29 +0100 Subject: [PATCH 2/4] Fix tray event channel close busy-loop, empty DBus check, and spawn order - Handle None from tray event channel by setting rx to None, preventing tight loop when channel closes - Treat empty DBUS_SESSION_BUS_ADDRESS as unavailable, not just unset - Spawn bridge thread before ksni service to avoid orphaned tray icon if thread creation fails Co-Authored-By: Claude Opus 4.6 (1M context) --- src/daemon.rs | 14 +++++++++++--- src/tray/sni.rs | 29 +++++++++++++++++------------ 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 88ee349b..8f9079e7 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3085,14 +3085,22 @@ impl Daemon { } // Handle system tray events (pending forever when tray feature is not enabled) - Some(tray_event) = async { + tray_event = async { match &mut tray_event_rx { Some(rx) => rx.recv().await, None => std::future::pending::>().await, } } => { - if self.handle_tray_event(tray_event, &mut state, &mut audio_capture, &transcriber_preloaded).await { - break; + match tray_event { + Some(event) => { + if self.handle_tray_event(event, &mut state, &mut audio_capture, &transcriber_preloaded).await { + break; + } + } + None => { + tracing::warn!("Tray event channel closed"); + tray_event_rx = None; + } } } diff --git a/src/tray/sni.rs b/src/tray/sni.rs index ec3a8c04..41878264 100644 --- a/src/tray/sni.rs +++ b/src/tray/sni.rs @@ -130,20 +130,20 @@ pub fn spawn() -> Option<( // Preflight: check DBus session bus is available before spawning ksni, // since ksni::TrayService::spawn() panics on DBus failure. - if std::env::var("DBUS_SESSION_BUS_ADDRESS").is_err() { - tracing::warn!("DBUS_SESSION_BUS_ADDRESS not set, skipping tray"); - return None; + match std::env::var("DBUS_SESSION_BUS_ADDRESS") { + Err(_) => { + tracing::warn!("DBUS_SESSION_BUS_ADDRESS not set, skipping tray"); + return None; + } + Ok(val) if val.trim().is_empty() => { + tracing::warn!("DBUS_SESSION_BUS_ADDRESS is empty, skipping tray"); + return None; + } + Ok(_) => {} } - // Start the ksni service (runs its own event loop on a separate thread) - let service = ksni::TrayService::new(tray); - - let handle = service.handle(); - service.spawn(); - - // Dedicated thread to bridge std::sync::mpsc -> tokio::sync::mpsc. - // ksni callbacks run on ksni's thread, so they use std::sync::mpsc. - // This thread blocks on recv() and forwards to the tokio channel. + // Spawn the bridge thread first, before ksni service, so we don't + // leave an orphaned tray icon if thread creation fails. if std::thread::Builder::new() .name("tray-event-bridge".to_string()) .spawn(move || { @@ -159,6 +159,11 @@ pub fn spawn() -> Option<( return None; } + // Start the ksni service (runs its own event loop on a separate thread) + let service = ksni::TrayService::new(tray); + let handle = service.handle(); + service.spawn(); + // Task: watch for state changes and update the tray tokio::spawn(async move { while state_rx.changed().await.is_ok() { From 04b0d5b7ad4c23f8943f2e0025ccd8ec3d8abcc4 Mon Sep 17 00:00:00 2001 From: Mate Remias Date: Wed, 1 Apr 2026 17:06:15 +0200 Subject: [PATCH 3/4] Address Copilot review feedback on PR #291 - Fix inaccurate comment in sni.rs: bridge uses std::thread, not tokio task - Add warning log for unknown daemon states in tray state mapping - Add unit tests for --tray CLI flag parsing - Add unit tests for tray config: default, TOML, and env var override Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli.rs | 16 +++++++++++++++ src/config.rs | 54 +++++++++++++++++++++++++++++++++++++++++++++++++ src/daemon.rs | 6 +++++- src/tray/sni.rs | 2 +- 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index d5b05526..a6def06f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1895,4 +1895,20 @@ mod tests { _ => panic!("Expected Record command"), } } + + // ========================================================================= + // Tray flag tests + // ========================================================================= + + #[test] + fn test_tray_flag() { + let cli = Cli::parse_from(["voxtype", "--tray"]); + assert!(cli.tray, "--tray should set tray=true"); + } + + #[test] + fn test_no_tray_flag_by_default() { + let cli = Cli::parse_from(["voxtype"]); + assert!(!cli.tray, "tray should be false by default"); + } } diff --git a/src/config.rs b/src/config.rs index 20e39519..6f294ccc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3677,6 +3677,60 @@ mod tests { // Clipboard Restore Tests // ========================================================================= + // ========================================================================= + // Tray config tests + // ========================================================================= + + #[test] + fn test_tray_disabled_by_default() { + let config = Config::default(); + assert!(!config.tray.enabled); + } + + #[test] + fn test_tray_enabled_from_toml() { + let toml_str = r#" + [hotkey] + key = "SCROLLLOCK" + + [audio] + device = "default" + sample_rate = 16000 + max_duration_secs = 30 + + [whisper] + model = "base.en" + + [output] + mode = "type" + + [tray] + enabled = true + "#; + + let config: Config = toml::from_str(toml_str).unwrap(); + assert!(config.tray.enabled); + } + + #[test] + fn test_tray_env_var_override() { + // Set env var before loading config + std::env::set_var("VOXTYPE_TRAY_ENABLED", "true"); + let config = load_config(None).unwrap(); + assert!(config.tray.enabled, "VOXTYPE_TRAY_ENABLED=true should enable tray"); + + std::env::set_var("VOXTYPE_TRAY_ENABLED", "false"); + let config = load_config(None).unwrap(); + assert!(!config.tray.enabled, "VOXTYPE_TRAY_ENABLED=false should disable tray"); + + // Clean up + std::env::remove_var("VOXTYPE_TRAY_ENABLED"); + } + + // ========================================================================= + // Restore clipboard tests + // ========================================================================= + #[test] fn test_restore_clipboard_defaults() { let config = Config::default(); diff --git a/src/daemon.rs b/src/daemon.rs index 8f9079e7..e67d5962 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -667,7 +667,11 @@ impl Daemon { let tray_state = match state_name { "recording" => crate::tray::TrayState::Recording, "transcribing" => crate::tray::TrayState::Transcribing, - _ => crate::tray::TrayState::Idle, + "idle" => crate::tray::TrayState::Idle, + other => { + tracing::warn!("Unknown daemon state '{}', defaulting tray to Idle", other); + crate::tray::TrayState::Idle + } }; let _ = tx.send(tray_state); } diff --git a/src/tray/sni.rs b/src/tray/sni.rs index 41878264..dfc7eb8b 100644 --- a/src/tray/sni.rs +++ b/src/tray/sni.rs @@ -116,7 +116,7 @@ pub fn spawn() -> Option<( let (state_tx, mut state_rx) = tokio::sync::watch::channel(TrayState::Idle); let (event_tx, event_rx) = tokio::sync::mpsc::channel::(8); - // Bridge channel: ksni thread (std::sync) -> tokio task -> tokio mpsc + // Bridge channel: ksni thread (std::sync) -> bridge thread -> tokio mpsc let (std_event_tx, std_event_rx) = std::sync::mpsc::channel::(); let shared = Arc::new(Mutex::new(SharedState { From a418d58f32c574aa9b82bfaef15d8a0f898a6efc Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Sun, 19 Apr 2026 17:41:53 -0400 Subject: [PATCH 4/4] Extract shared recording logic to eliminate duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recording start sequence (model prep, audio capture, state transition, feedback, media pause, pre-recording hook) was duplicated across push-to-talk, toggle, SIGUSR1, and tray handlers. Same for the eager recording stop sequence. Extract begin_recording() and stop_eager_recording_and_transcribe() so all triggers share one code path. This also fixes the tray handler missing pause_media support. Co-authored-by: Máté Rémiás --- src/daemon.rs | 762 ++++++++++++-------------------------------------- 1 file changed, 183 insertions(+), 579 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index e67d5962..a20f06f2 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1573,6 +1573,172 @@ impl Daemon { } } + /// Begin recording: prepare model, start audio capture, transition state. + /// + /// Callers are responsible for sending notifications (text varies per trigger) + /// and handling profile overrides (hotkey-only concern) before/after this call. + /// Returns `true` if recording started successfully, `false` on failure. + async fn begin_recording( + &mut self, + state: &mut State, + audio_capture: &mut Option>, + transcriber_preloaded: &Option>, + model_override: Option, + ) -> bool { + // Prepare model for transcription + if self.config.on_demand_loading() { + match self.config.engine { + crate::config::TranscriptionEngine::Whisper => { + let config = self.config.whisper.clone(); + let config_path = self.config_path.clone(); + let model_to_load = model_override.clone(); + self.model_load_task = Some(tokio::task::spawn_blocking(move || { + let mut temp_manager = ModelManager::new(&config, config_path); + temp_manager.get_transcriber(model_to_load.as_deref()) + })); + } + crate::config::TranscriptionEngine::Parakeet + | crate::config::TranscriptionEngine::Moonshine + | crate::config::TranscriptionEngine::SenseVoice + | crate::config::TranscriptionEngine::Paraformer + | crate::config::TranscriptionEngine::Dolphin + | crate::config::TranscriptionEngine::Omnilingual => { + let config = self.config.clone(); + self.model_load_task = Some(tokio::task::spawn_blocking(move || { + crate::transcribe::create_transcriber(&config).map(Arc::from) + })); + } + } + } else { + match self.config.engine { + crate::config::TranscriptionEngine::Whisper => { + if let Some(ref mut mm) = self.model_manager { + if let Err(e) = mm.prepare_model(model_override.as_deref()) { + tracing::warn!("Failed to prepare model: {}", e); + } + } + } + crate::config::TranscriptionEngine::Parakeet + | crate::config::TranscriptionEngine::Moonshine + | crate::config::TranscriptionEngine::SenseVoice + | crate::config::TranscriptionEngine::Paraformer + | crate::config::TranscriptionEngine::Dolphin + | crate::config::TranscriptionEngine::Omnilingual => { + if let Some(ref t) = transcriber_preloaded { + let transcriber = t.clone(); + tokio::task::spawn_blocking(move || { + transcriber.prepare(); + }); + } + } + } + } + + // Create and start audio capture + match audio::create_capture(&self.config.audio) { + Ok(mut capture) => { + if let Err(e) = capture.start().await { + tracing::error!("Failed to start audio: {}", e); + self.play_feedback(SoundEvent::Error); + return false; + } + *audio_capture = Some(capture); + + if self.config.whisper.eager_processing { + tracing::info!("Using eager input processing"); + *state = State::EagerRecording { + started_at: std::time::Instant::now(), + model_override, + accumulated_audio: Vec::new(), + chunks_sent: 0, + chunk_results: Vec::new(), + tasks_in_flight: 0, + }; + } else { + *state = State::Recording { + started_at: std::time::Instant::now(), + model_override, + }; + } + self.update_state("recording"); + self.play_feedback(SoundEvent::RecordingStart); + self.pause_media_players().await; + + if let Some(cmd) = &self.config.output.pre_recording_command { + if let Err(e) = output::run_hook(cmd, "pre_recording").await { + tracing::warn!("{}", e); + } + } + true + } + Err(e) => { + tracing::error!("Failed to create audio capture: {}", e); + self.play_feedback(SoundEvent::Error); + false + } + } + } + + /// Stop eager recording, transcribe, and handle the result. + async fn stop_eager_recording_and_transcribe( + &mut self, + state: &mut State, + audio_capture: &mut Option>, + transcriber_preloaded: &Option>, + ) { + let model_override = match state { + State::EagerRecording { model_override, .. } => model_override.clone(), + _ => None, + }; + + let duration = state.recording_duration().unwrap_or_default(); + tracing::info!("Eager recording stopped ({:.1}s)", duration.as_secs_f32()); + + self.play_feedback(SoundEvent::RecordingStop); + + if self.config.output.notification.on_recording_stop { + send_notification( + "Recording Stopped", + "Transcribing...", + self.config.output.notification.show_engine_icon, + self.config.engine, + ) + .await; + } + + if let Some(mut capture) = audio_capture.take() { + if let Ok(final_samples) = capture.stop().await { + if let State::EagerRecording { + accumulated_audio, .. + } = state + { + accumulated_audio.extend(final_samples); + } + } + } + + let transcriber = match self + .get_transcriber_for_recording(model_override.as_deref(), transcriber_preloaded) + .await + { + Ok(t) => t, + Err(()) => { + self.reset_to_idle(state).await; + return; + } + }; + + self.update_state("transcribing"); + + if let Some(text) = self.finish_eager_recording(state, transcriber).await { + *state = State::Transcribing { audio: Vec::new() }; + self.handle_transcription_result(state, Ok(Ok(text))).await; + } else { + tracing::debug!("Eager recording produced empty result"); + self.reset_to_idle(state).await; + } + } + /// Handle a single tray event. Returns `true` if the daemon should quit. #[cfg(feature = "tray")] async fn handle_tray_event( @@ -1600,96 +1766,8 @@ impl Daemon { .await; } - if self.config.on_demand_loading() { - match self.config.engine { - crate::config::TranscriptionEngine::Whisper => { - let config = self.config.whisper.clone(); - let config_path = self.config_path.clone(); - self.model_load_task = - Some(tokio::task::spawn_blocking(move || { - let mut temp_manager = - ModelManager::new(&config, config_path); - temp_manager.get_transcriber(None) - })); - } - crate::config::TranscriptionEngine::Parakeet - | crate::config::TranscriptionEngine::Moonshine - | crate::config::TranscriptionEngine::SenseVoice - | crate::config::TranscriptionEngine::Paraformer - | crate::config::TranscriptionEngine::Dolphin - | crate::config::TranscriptionEngine::Omnilingual => { - let config = self.config.clone(); - self.model_load_task = - Some(tokio::task::spawn_blocking(move || { - crate::transcribe::create_transcriber(&config) - .map(Arc::from) - })); - } - } - } else { - match self.config.engine { - crate::config::TranscriptionEngine::Whisper => { - if let Some(ref mut mm) = self.model_manager { - if let Err(e) = mm.prepare_model(None) { - tracing::warn!("Failed to prepare model: {}", e); - } - } - } - crate::config::TranscriptionEngine::Parakeet - | crate::config::TranscriptionEngine::Moonshine - | crate::config::TranscriptionEngine::SenseVoice - | crate::config::TranscriptionEngine::Paraformer - | crate::config::TranscriptionEngine::Dolphin - | crate::config::TranscriptionEngine::Omnilingual => { - if let Some(ref t) = transcriber_preloaded { - let transcriber = t.clone(); - tokio::task::spawn_blocking(move || { - transcriber.prepare(); - }); - } - } - } - } - - match audio::create_capture(&self.config.audio) { - Ok(mut capture) => { - if let Err(e) = capture.start().await { - tracing::error!("Failed to start audio: {}", e); - } else { - *audio_capture = Some(capture); - - if self.config.whisper.eager_processing { - tracing::info!("Using eager input processing"); - *state = State::EagerRecording { - started_at: std::time::Instant::now(), - model_override: None, - accumulated_audio: Vec::new(), - chunks_sent: 0, - chunk_results: Vec::new(), - tasks_in_flight: 0, - }; - } else { - *state = State::Recording { - started_at: std::time::Instant::now(), - model_override: None, - }; - } - self.update_state("recording"); - self.play_feedback(SoundEvent::RecordingStart); - - // Run pre-recording hook - if let Some(cmd) = &self.config.output.pre_recording_command { - if let Err(e) = output::run_hook(cmd, "pre_recording").await { - tracing::warn!("{}", e); - } - } - } - } - Err(e) => { - tracing::error!("Failed to create audio capture: {}", e); - self.play_feedback(SoundEvent::Error); - } - } + self.begin_recording(state, audio_capture, transcriber_preloaded, None) + .await; } else if let State::Recording { model_override, .. } = state @@ -1703,8 +1781,7 @@ impl Daemon { { Ok(t) => Some(t), Err(()) => { - *state = State::Idle; - self.update_state("idle"); + self.reset_to_idle(state).await; return false; } }; @@ -1712,68 +1789,12 @@ impl Daemon { self.start_transcription_task(state, audio_capture, transcriber) .await; } else if state.is_eager_recording() { - // Stop eager recording (same path as SIGUSR2 eager stop) - let model_override = match state { - State::EagerRecording { model_override, .. } => model_override.clone(), - _ => None, - }; - - let duration = state.recording_duration().unwrap_or_default(); - tracing::info!( - "Eager recording stopped via tray ({:.1}s)", - duration.as_secs_f32() - ); - - self.play_feedback(SoundEvent::RecordingStop); - - if self.config.output.notification.on_recording_stop { - send_notification( - "Recording Stopped", - "Transcribing...", - self.config.output.notification.show_engine_icon, - self.config.engine, - ) - .await; - } - - if let Some(mut capture) = audio_capture.take() { - if let Ok(final_samples) = capture.stop().await { - if let State::EagerRecording { - accumulated_audio, .. - } = state - { - accumulated_audio.extend(final_samples); - } - } - } - - let transcriber = match self - .get_transcriber_for_recording( - model_override.as_deref(), - transcriber_preloaded, - ) - .await - { - Ok(t) => t, - Err(()) => { - *state = State::Idle; - self.update_state("idle"); - return false; - } - }; - - self.update_state("transcribing"); - - if let Some(text) = - self.finish_eager_recording(state, transcriber).await - { - *state = State::Transcribing { audio: Vec::new() }; - self.handle_transcription_result(state, Ok(Ok(text))) - .await; - } else { - tracing::debug!("Eager recording produced empty result"); - self.reset_to_idle(state).await; - } + self.stop_eager_recording_and_transcribe( + state, + audio_capture, + transcriber_preloaded, + ) + .await; } } TrayEvent::CancelTranscription => { @@ -2023,115 +2044,18 @@ impl Daemon { tracing::debug!("Received HotkeyEvent::Pressed (push-to-talk), state.is_idle() = {}, model_override = {:?}, profile_override = {:?}", state.is_idle(), model_override, profile_override); if state.is_idle() { - // Write profile override file if a profile modifier was held if let Some(ref profile_name) = profile_override { write_profile_override(profile_name); } tracing::info!("Recording started"); - // Send notification if enabled if self.config.output.notification.on_recording_start { send_notification("Push to Talk Active", "Recording...", self.config.output.notification.show_engine_icon, self.config.engine).await; } - // Prepare model for transcription - if self.config.on_demand_loading() { - // Start model loading in background - match self.config.engine { - crate::config::TranscriptionEngine::Whisper => { - let config = self.config.whisper.clone(); - let config_path = self.config_path.clone(); - let model_to_load = model_override.clone(); - self.model_load_task = Some(tokio::task::spawn_blocking(move || { - let mut temp_manager = ModelManager::new(&config, config_path); - temp_manager.get_transcriber(model_to_load.as_deref()) - })); - } - crate::config::TranscriptionEngine::Parakeet - | crate::config::TranscriptionEngine::Moonshine - | crate::config::TranscriptionEngine::SenseVoice - | crate::config::TranscriptionEngine::Paraformer - | crate::config::TranscriptionEngine::Dolphin - | crate::config::TranscriptionEngine::Omnilingual => { - let config = self.config.clone(); - self.model_load_task = Some(tokio::task::spawn_blocking(move || { - crate::transcribe::create_transcriber(&config).map(Arc::from) - })); - } - } - tracing::debug!("Started background model loading"); - } else { - // Prepare model (spawns subprocess for gpu_isolation mode) - match self.config.engine { - crate::config::TranscriptionEngine::Whisper => { - if let Some(ref mut mm) = self.model_manager { - if let Err(e) = mm.prepare_model(model_override.as_deref()) { - tracing::warn!("Failed to prepare model: {}", e); - } - } - } - crate::config::TranscriptionEngine::Parakeet - | crate::config::TranscriptionEngine::Moonshine - | crate::config::TranscriptionEngine::SenseVoice - | crate::config::TranscriptionEngine::Paraformer - | crate::config::TranscriptionEngine::Dolphin - | crate::config::TranscriptionEngine::Omnilingual => { - if let Some(ref t) = transcriber_preloaded { - let transcriber = t.clone(); - tokio::task::spawn_blocking(move || { - transcriber.prepare(); - }); - } - } - } - } - - // Create and start audio capture - tracing::debug!("Creating audio capture with device: {}", self.config.audio.device); - match audio::create_capture(&self.config.audio) { - Ok(mut capture) => { - tracing::debug!("Audio capture created, starting..."); - if let Err(e) = capture.start().await { - tracing::error!("Failed to start audio: {}", e); - continue; - } - tracing::debug!("Audio capture started successfully"); - audio_capture = Some(capture); - - // Use EagerRecording state if eager_processing is enabled - if self.config.whisper.eager_processing { - tracing::info!("Using eager input processing"); - state = State::EagerRecording { - started_at: std::time::Instant::now(), - model_override: model_override.clone(), - accumulated_audio: Vec::new(), - chunks_sent: 0, - chunk_results: Vec::new(), - tasks_in_flight: 0, - }; - } else { - state = State::Recording { - started_at: std::time::Instant::now(), - model_override: model_override.clone(), - }; - } - self.update_state("recording"); - self.play_feedback(SoundEvent::RecordingStart); - self.pause_media_players().await; - - // Run pre-recording hook (e.g., enter compositor submap for cancel) - if let Some(cmd) = &self.config.output.pre_recording_command { - if let Err(e) = output::run_hook(cmd, "pre_recording").await { - tracing::warn!("{}", e); - } - } - } - Err(e) => { - tracing::error!("Failed to create audio capture: {}", e); - cleanup_profile_override(); - self.play_feedback(SoundEvent::Error); - } + if !self.begin_recording(&mut state, &mut audio_capture, &transcriber_preloaded, model_override.clone()).await { + cleanup_profile_override(); } } } @@ -2157,53 +2081,7 @@ impl Daemon { transcriber, ).await; } else if state.is_eager_recording() { - // Handle eager recording stop - extract model_override first - let model_override = match &state { - State::EagerRecording { model_override, .. } => model_override.clone(), - _ => None, - }; - - let duration = state.recording_duration().unwrap_or_default(); - tracing::info!("Eager recording stopped ({:.1}s)", duration.as_secs_f32()); - - self.play_feedback(SoundEvent::RecordingStop); - - if self.config.output.notification.on_recording_stop { - send_notification("Recording Stopped", "Transcribing...", self.config.output.notification.show_engine_icon, self.config.engine).await; - } - - // Stop audio capture and get remaining samples - if let Some(mut capture) = audio_capture.take() { - if let Ok(final_samples) = capture.stop().await { - // Add final samples to accumulated audio - if let State::EagerRecording { accumulated_audio, .. } = &mut state { - accumulated_audio.extend(final_samples); - } - } - } - - let transcriber = match self.get_transcriber_for_recording( - model_override.as_deref(), - &transcriber_preloaded, - ).await { - Ok(t) => t, - Err(()) => { - state = State::Idle; - self.update_state("idle"); - continue; - } - }; - - self.update_state("transcribing"); - - if let Some(text) = self.finish_eager_recording(&mut state, transcriber).await { - // Move to outputting state and handle via transcription result flow - state = State::Transcribing { audio: Vec::new() }; - self.handle_transcription_result(&mut state, Ok(Ok(text))).await; - } else { - tracing::debug!("Eager recording produced empty result"); - self.reset_to_idle(&mut state).await; - } + self.stop_eager_recording_and_transcribe(&mut state, &mut audio_capture, &transcriber_preloaded).await; eager_transcriber = None; } } @@ -2214,112 +2092,18 @@ impl Daemon { state.is_idle(), state.is_recording(), model_override, profile_override); if state.is_idle() { - // Write profile override file if a profile modifier was held if let Some(ref profile_name) = profile_override { write_profile_override(profile_name); } - // Start recording tracing::info!("Recording started (toggle mode)"); if self.config.output.notification.on_recording_start { send_notification("Recording Started", "Press hotkey again to stop", self.config.output.notification.show_engine_icon, self.config.engine).await; } - // Prepare model for transcription - if self.config.on_demand_loading() { - // Start model loading in background - match self.config.engine { - crate::config::TranscriptionEngine::Whisper => { - let config = self.config.whisper.clone(); - let config_path = self.config_path.clone(); - let model_to_load = model_override.clone(); - self.model_load_task = Some(tokio::task::spawn_blocking(move || { - let mut temp_manager = ModelManager::new(&config, config_path); - temp_manager.get_transcriber(model_to_load.as_deref()) - })); - } - crate::config::TranscriptionEngine::Parakeet - | crate::config::TranscriptionEngine::Moonshine - | crate::config::TranscriptionEngine::SenseVoice - | crate::config::TranscriptionEngine::Paraformer - | crate::config::TranscriptionEngine::Dolphin - | crate::config::TranscriptionEngine::Omnilingual => { - let config = self.config.clone(); - self.model_load_task = Some(tokio::task::spawn_blocking(move || { - crate::transcribe::create_transcriber(&config).map(Arc::from) - })); - } - } - tracing::debug!("Started background model loading"); - } else { - // Prepare model (spawns subprocess for gpu_isolation mode) - match self.config.engine { - crate::config::TranscriptionEngine::Whisper => { - if let Some(ref mut mm) = self.model_manager { - if let Err(e) = mm.prepare_model(model_override.as_deref()) { - tracing::warn!("Failed to prepare model: {}", e); - } - } - } - crate::config::TranscriptionEngine::Parakeet - | crate::config::TranscriptionEngine::Moonshine - | crate::config::TranscriptionEngine::SenseVoice - | crate::config::TranscriptionEngine::Paraformer - | crate::config::TranscriptionEngine::Dolphin - | crate::config::TranscriptionEngine::Omnilingual => { - if let Some(ref t) = transcriber_preloaded { - let transcriber = t.clone(); - tokio::task::spawn_blocking(move || { - transcriber.prepare(); - }); - } - } - } - } - - match audio::create_capture(&self.config.audio) { - Ok(mut capture) => { - if let Err(e) = capture.start().await { - tracing::error!("Failed to start audio: {}", e); - self.play_feedback(SoundEvent::Error); - continue; - } - audio_capture = Some(capture); - - // Use EagerRecording state if eager_processing is enabled - if self.config.whisper.eager_processing { - tracing::info!("Using eager input processing"); - state = State::EagerRecording { - started_at: std::time::Instant::now(), - model_override: model_override.clone(), - accumulated_audio: Vec::new(), - chunks_sent: 0, - chunk_results: Vec::new(), - tasks_in_flight: 0, - }; - } else { - state = State::Recording { - started_at: std::time::Instant::now(), - model_override: model_override.clone(), - }; - } - self.update_state("recording"); - self.play_feedback(SoundEvent::RecordingStart); - self.pause_media_players().await; - - // Run pre-recording hook (e.g., enter compositor submap for cancel) - if let Some(cmd) = &self.config.output.pre_recording_command { - if let Err(e) = output::run_hook(cmd, "pre_recording").await { - tracing::warn!("{}", e); - } - } - } - Err(e) => { - tracing::error!("Failed to create audio capture: {}", e); - cleanup_profile_override(); - self.play_feedback(SoundEvent::Error); - } + if !self.begin_recording(&mut state, &mut audio_capture, &transcriber_preloaded, model_override.clone()).await { + cleanup_profile_override(); } } else if let State::Recording { model_override: current_model_override, .. } = &state { let transcriber = match self.get_transcriber_for_recording( @@ -2341,51 +2125,7 @@ impl Daemon { transcriber, ).await; } else if state.is_eager_recording() { - // Handle eager recording stop in toggle mode - extract model_override first - let model_override = match &state { - State::EagerRecording { model_override, .. } => model_override.clone(), - _ => None, - }; - - let duration = state.recording_duration().unwrap_or_default(); - tracing::info!("Eager recording stopped ({:.1}s)", duration.as_secs_f32()); - - self.play_feedback(SoundEvent::RecordingStop); - - if self.config.output.notification.on_recording_stop { - send_notification("Recording Stopped", "Transcribing...", self.config.output.notification.show_engine_icon, self.config.engine).await; - } - - // Stop audio capture and get remaining samples - if let Some(mut capture) = audio_capture.take() { - if let Ok(final_samples) = capture.stop().await { - if let State::EagerRecording { accumulated_audio, .. } = &mut state { - accumulated_audio.extend(final_samples); - } - } - } - - let transcriber = match self.get_transcriber_for_recording( - model_override.as_deref(), - &transcriber_preloaded, - ).await { - Ok(t) => t, - Err(()) => { - state = State::Idle; - self.update_state("idle"); - continue; - } - }; - - self.update_state("transcribing"); - - if let Some(text) = self.finish_eager_recording(&mut state, transcriber).await { - state = State::Transcribing { audio: Vec::new() }; - self.handle_transcription_result(&mut state, Ok(Ok(text))).await; - } else { - tracing::debug!("Eager recording produced empty result"); - self.reset_to_idle(&mut state).await; - } + self.stop_eager_recording_and_transcribe(&mut state, &mut audio_capture, &transcriber_preloaded).await; eager_transcriber = None; } } @@ -2654,7 +2394,6 @@ impl Daemon { _ = sigusr1.recv() => { tracing::debug!("Received SIGUSR1 (start recording)"); if state.is_idle() { - // Read model override from file (set by `voxtype record start --model X`) let model_override = read_model_override(); tracing::info!("Recording started (external trigger), model_override = {:?}", model_override); @@ -2662,98 +2401,7 @@ impl Daemon { send_notification("Recording Started", "External trigger", self.config.output.notification.show_engine_icon, self.config.engine).await; } - // Prepare model for transcription - if self.config.on_demand_loading() { - // Start model loading in background - match self.config.engine { - crate::config::TranscriptionEngine::Whisper => { - let config = self.config.whisper.clone(); - let config_path = self.config_path.clone(); - let model_to_load = model_override.clone(); - self.model_load_task = Some(tokio::task::spawn_blocking(move || { - let mut temp_manager = ModelManager::new(&config, config_path); - temp_manager.get_transcriber(model_to_load.as_deref()) - })); - } - crate::config::TranscriptionEngine::Parakeet - | crate::config::TranscriptionEngine::Moonshine - | crate::config::TranscriptionEngine::SenseVoice - | crate::config::TranscriptionEngine::Paraformer - | crate::config::TranscriptionEngine::Dolphin - | crate::config::TranscriptionEngine::Omnilingual => { - let config = self.config.clone(); - self.model_load_task = Some(tokio::task::spawn_blocking(move || { - crate::transcribe::create_transcriber(&config).map(Arc::from) - })); - } - } - } else { - // Prepare model (spawns subprocess for gpu_isolation mode) - match self.config.engine { - crate::config::TranscriptionEngine::Whisper => { - if let Some(ref mut mm) = self.model_manager { - if let Err(e) = mm.prepare_model(model_override.as_deref()) { - tracing::warn!("Failed to prepare model: {}", e); - } - } - } - crate::config::TranscriptionEngine::Parakeet - | crate::config::TranscriptionEngine::Moonshine - | crate::config::TranscriptionEngine::SenseVoice - | crate::config::TranscriptionEngine::Paraformer - | crate::config::TranscriptionEngine::Dolphin - | crate::config::TranscriptionEngine::Omnilingual => { - if let Some(ref t) = transcriber_preloaded { - let transcriber = t.clone(); - tokio::task::spawn_blocking(move || { - transcriber.prepare(); - }); - } - } - } - } - - match audio::create_capture(&self.config.audio) { - Ok(mut capture) => { - if let Err(e) = capture.start().await { - tracing::error!("Failed to start audio: {}", e); - } else { - audio_capture = Some(capture); - - // Use EagerRecording state if eager_processing is enabled - if self.config.whisper.eager_processing { - tracing::info!("Using eager input processing"); - state = State::EagerRecording { - started_at: std::time::Instant::now(), - model_override, - accumulated_audio: Vec::new(), - chunks_sent: 0, - chunk_results: Vec::new(), - tasks_in_flight: 0, - }; - } else { - state = State::Recording { - started_at: std::time::Instant::now(), - model_override, - }; - } - self.update_state("recording"); - self.play_feedback(SoundEvent::RecordingStart); - self.pause_media_players().await; - - // Run pre-recording hook (e.g., enter compositor submap for cancel) - if let Some(cmd) = &self.config.output.pre_recording_command { - if let Err(e) = output::run_hook(cmd, "pre_recording").await { - tracing::warn!("{}", e); - } - } - } - } - Err(e) => { - tracing::error!("Failed to create audio capture: {}", e); - self.play_feedback(SoundEvent::Error); - } - } + self.begin_recording(&mut state, &mut audio_capture, &transcriber_preloaded, model_override).await; } } @@ -2779,51 +2427,7 @@ impl Daemon { transcriber, ).await; } else if state.is_eager_recording() { - // Handle eager recording stop via external trigger - extract model_override first - let model_override = match &state { - State::EagerRecording { model_override, .. } => model_override.clone(), - _ => None, - }; - - let duration = state.recording_duration().unwrap_or_default(); - tracing::info!("Eager recording stopped ({:.1}s)", duration.as_secs_f32()); - - self.play_feedback(SoundEvent::RecordingStop); - - if self.config.output.notification.on_recording_stop { - send_notification("Recording Stopped", "Transcribing...", self.config.output.notification.show_engine_icon, self.config.engine).await; - } - - // Stop audio capture and get remaining samples - if let Some(mut capture) = audio_capture.take() { - if let Ok(final_samples) = capture.stop().await { - if let State::EagerRecording { accumulated_audio, .. } = &mut state { - accumulated_audio.extend(final_samples); - } - } - } - - let transcriber = match self.get_transcriber_for_recording( - model_override.as_deref(), - &transcriber_preloaded, - ).await { - Ok(t) => t, - Err(()) => { - state = State::Idle; - self.update_state("idle"); - continue; - } - }; - - self.update_state("transcribing"); - - if let Some(text) = self.finish_eager_recording(&mut state, transcriber).await { - state = State::Transcribing { audio: Vec::new() }; - self.handle_transcription_result(&mut state, Ok(Ok(text))).await; - } else { - tracing::debug!("Eager recording produced empty result"); - self.reset_to_idle(&mut state).await; - } + self.stop_eager_recording_and_transcribe(&mut state, &mut audio_capture, &transcriber_preloaded).await; eager_transcriber = None; } }