From d3a640bf5b0035517c9004eaa478d76d699b2a7c Mon Sep 17 00:00:00 2001 From: Praise Adesokan Date: Sat, 3 Jan 2026 21:31:13 +0100 Subject: [PATCH 1/7] feat: replace mute with configurable audio ducking Adds flexible volume reduction during recording instead of binary mute. Users can now choose reduction amount from 0% (no change) to 100% (full mute). - Add volume_control.rs with native APIs (CoreAudio/Windows COM/Linux CLI) - New settings: audio_ducking_enabled, audio_ducking_amount - Auto-migrate existing mute_while_recording users - Add AudioDucking.tsx settings component - Linux: preserve boosted volumes above 100% (PipeWire/PulseAudio) --- bun.lock | 1 + src-tauri/Cargo.lock | 30 +- src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 3 + src-tauri/src/managers/audio.rs | 143 ++----- src-tauri/src/settings.rs | 29 ++ src-tauri/src/shortcut.rs | 21 + src-tauri/src/volume_control.rs | 397 ++++++++++++++++++ src/bindings.ts | 18 +- src/components/settings/AudioDucking.tsx | 58 +++ .../settings/debug/DebugSettings.tsx | 2 - .../settings/general/GeneralSettings.tsx | 2 + src/i18n/locales/en/translation.json | 8 + src/stores/settingsStore.ts | 4 + 14 files changed, 612 insertions(+), 105 deletions(-) create mode 100644 src-tauri/src/volume_control.rs create mode 100644 src/components/settings/AudioDucking.tsx diff --git a/bun.lock b/bun.lock index 4e594aa7c..d1aeb091b 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "handy-app", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 13ec07cc6..c46fcfd32 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -478,6 +478,24 @@ dependencies = [ "which", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.108", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1060,6 +1078,15 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen 0.72.1", +] + [[package]] name = "cpal" version = "0.16.0" @@ -2382,6 +2409,7 @@ version = "0.6.10" dependencies = [ "anyhow", "chrono", + "coreaudio-sys", "cpal", "enigo", "env_filter", @@ -7651,7 +7679,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76bab42b2c319e3a1e0280137c59368072348d3277873c7588b6466a127dca58" dependencies = [ - "bindgen", + "bindgen 0.69.5", "cfg-if", "cmake", "fs_extra", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5d3568b67..ce44f63f8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -93,6 +93,7 @@ windows = { version = "0.61.3", features = [ [target.'cfg(target_os = "macos")'.dependencies] tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } +coreaudio-sys = "0.2" [profile.release] lto = true diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6472167d6..ab7c15b1f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ mod signal_handle; mod tray; mod tray_i18n; mod utils; +mod volume_control; use specta_typescript::{BigIntExportBehavior, Typescript}; use tauri_specta::{collect_commands, Builder}; @@ -261,6 +262,8 @@ pub fn run() { shortcut::suspend_binding, shortcut::resume_binding, shortcut::change_mute_while_recording_setting, + shortcut::change_audio_ducking_enabled_setting, + shortcut::change_audio_ducking_amount_setting, shortcut::change_append_trailing_space_setting, shortcut::change_app_language_setting, shortcut::change_update_checks_setting, diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 0add01fcf..8926592b5 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -2,100 +2,12 @@ use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, use crate::helpers::clamshell; use crate::settings::{get_settings, AppSettings}; use crate::utils; +use crate::volume_control; use log::{debug, error, info}; use std::sync::{Arc, Mutex}; use std::time::Instant; use tauri::Manager; -fn set_mute(mute: bool) { - // Expected behavior: - // - Windows: works on most systems using standard audio drivers. - // - Linux: works on many systems (PipeWire, PulseAudio, ALSA), - // but some distros may lack the tools used. - // - macOS: works on most standard setups via AppleScript. - // If unsupported, fails silently. - - #[cfg(target_os = "windows")] - { - unsafe { - use windows::Win32::{ - Media::Audio::{ - eMultimedia, eRender, Endpoints::IAudioEndpointVolume, IMMDeviceEnumerator, - MMDeviceEnumerator, - }, - System::Com::{CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED}, - }; - - macro_rules! unwrap_or_return { - ($expr:expr) => { - match $expr { - Ok(val) => val, - Err(_) => return, - } - }; - } - - // Initialize the COM library for this thread. - // If already initialized (e.g., by another library like Tauri), this does nothing. - let _ = CoInitializeEx(None, COINIT_MULTITHREADED); - - let all_devices: IMMDeviceEnumerator = - unwrap_or_return!(CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)); - let default_device = - unwrap_or_return!(all_devices.GetDefaultAudioEndpoint(eRender, eMultimedia)); - let volume_interface = unwrap_or_return!( - default_device.Activate::(CLSCTX_ALL, None) - ); - - let _ = volume_interface.SetMute(mute, std::ptr::null()); - } - } - - #[cfg(target_os = "linux")] - { - use std::process::Command; - - let mute_val = if mute { "1" } else { "0" }; - let amixer_state = if mute { "mute" } else { "unmute" }; - - // Try multiple backends to increase compatibility - // 1. PipeWire (wpctl) - if Command::new("wpctl") - .args(["set-mute", "@DEFAULT_AUDIO_SINK@", mute_val]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - { - return; - } - - // 2. PulseAudio (pactl) - if Command::new("pactl") - .args(["set-sink-mute", "@DEFAULT_SINK@", mute_val]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - { - return; - } - - // 3. ALSA (amixer) - let _ = Command::new("amixer") - .args(["set", "Master", amixer_state]) - .output(); - } - - #[cfg(target_os = "macos")] - { - use std::process::Command; - let script = format!( - "set volume output muted {}", - if mute { "true" } else { "false" } - ); - let _ = Command::new("osascript").args(["-e", &script]).output(); - } -} - const WHISPER_SAMPLE_RATE: usize = 16000; /* ──────────────────────────────────────────────────────────────── */ @@ -212,28 +124,54 @@ impl AudioRecordingManager { /* ---------- microphone life-cycle -------------------------------------- */ - /// Applies mute if mute_while_recording is enabled and stream is open - pub fn apply_mute(&self) { + /// Applies audio ducking if enabled in settings and stream is open + pub fn apply_ducking(&self) { let settings = get_settings(&self.app_handle); let mut did_mute_guard = self.did_mute.lock().unwrap(); - if settings.mute_while_recording && *self.is_open.lock().unwrap() { - set_mute(true); - *did_mute_guard = true; - debug!("Mute applied"); + if settings.audio_ducking_enabled && *self.is_open.lock().unwrap() { + match volume_control::apply_ducking(settings.audio_ducking_amount) { + Ok(()) => { + *did_mute_guard = true; + debug!( + "Audio ducking applied ({}% reduction)", + settings.audio_ducking_amount * 100.0 + ); + } + Err(e) => { + error!("Failed to apply audio ducking: {}", e); + } + } } } - /// Removes mute if it was applied - pub fn remove_mute(&self) { + /// Removes audio ducking and restores original volume + pub fn remove_ducking(&self) { let mut did_mute_guard = self.did_mute.lock().unwrap(); if *did_mute_guard { - set_mute(false); - *did_mute_guard = false; - debug!("Mute removed"); + match volume_control::restore_volume() { + Ok(()) => { + *did_mute_guard = false; + debug!("Audio ducking removed, volume restored"); + } + Err(e) => { + error!("Failed to restore volume: {}", e); + } + } } } + // Keep old methods as aliases for backward compatibility + #[allow(dead_code)] + pub fn apply_mute(&self) { + self.apply_ducking(); + } + + #[allow(dead_code)] + pub fn remove_mute(&self) { + self.remove_ducking(); + } + pub fn start_microphone_stream(&self) -> Result<(), anyhow::Error> { let mut open_flag = self.is_open.lock().unwrap(); if *open_flag { @@ -287,9 +225,12 @@ impl AudioRecordingManager { return; } + // Restore volume if ducking was applied let mut did_mute_guard = self.did_mute.lock().unwrap(); if *did_mute_guard { - set_mute(false); + if let Err(e) = volume_control::restore_volume() { + error!("Failed to restore volume on stream stop: {}", e); + } } *did_mute_guard = false; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 1bd0d718e..944bd6938 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -293,6 +293,14 @@ pub struct AppSettings { pub append_trailing_space: bool, #[serde(default = "default_app_language")] pub app_language: String, + #[serde(default)] + pub audio_ducking_enabled: bool, + #[serde(default = "default_audio_ducking_amount")] + pub audio_ducking_amount: f32, +} + +fn default_audio_ducking_amount() -> f32 { + 1.0 // Full mute by default (0.0 = no change, 1.0 = full mute) } fn default_model() -> String { @@ -581,6 +589,8 @@ pub fn get_default_settings() -> AppSettings { mute_while_recording: false, append_trailing_space: false, app_language: default_app_language(), + audio_ducking_enabled: false, + audio_ducking_amount: default_audio_ducking_amount(), } } @@ -655,9 +665,28 @@ pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings { store.set("settings", serde_json::to_value(&settings).unwrap()); } + // Migrate old mute_while_recording to new audio_ducking system + if migrate_mute_to_ducking(&mut settings) { + store.set("settings", serde_json::to_value(&settings).unwrap()); + } + settings } +/// Migrate legacy mute_while_recording to new audio_ducking settings +fn migrate_mute_to_ducking(settings: &mut AppSettings) -> bool { + // If mute_while_recording was enabled but audio_ducking is not, + // migrate to the new system + if settings.mute_while_recording && !settings.audio_ducking_enabled { + debug!("Migrating mute_while_recording to audio_ducking"); + settings.audio_ducking_enabled = true; + settings.audio_ducking_amount = 1.0; // Full mute + settings.mute_while_recording = false; // Clear the old setting + return true; + } + false +} + pub fn get_settings(app: &AppHandle) -> AppSettings { let store = app .store(SETTINGS_STORE_PATH) diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index f983b0b91..bc927c599 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -601,6 +601,27 @@ pub fn change_mute_while_recording_setting(app: AppHandle, enabled: bool) -> Res Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn change_audio_ducking_enabled_setting(app: AppHandle, enabled: bool) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.audio_ducking_enabled = enabled; + settings::write_settings(&app, settings); + + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub fn change_audio_ducking_amount_setting(app: AppHandle, amount: f32) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + // Clamp between 0.0 and 1.0 + settings.audio_ducking_amount = amount.clamp(0.0, 1.0); + settings::write_settings(&app, settings); + + Ok(()) +} + #[tauri::command] #[specta::specta] pub fn change_append_trailing_space_setting(app: AppHandle, enabled: bool) -> Result<(), String> { diff --git a/src-tauri/src/volume_control.rs b/src-tauri/src/volume_control.rs new file mode 100644 index 000000000..5bb3567a9 --- /dev/null +++ b/src-tauri/src/volume_control.rs @@ -0,0 +1,397 @@ +use log::debug; +use once_cell::sync::Lazy; +use std::sync::Mutex; + +/// Stores the original volume before ducking was applied +static ORIGINAL_VOLUME: Lazy>> = Lazy::new(|| Mutex::new(None)); + +/// Get the current system volume +/// macOS/Windows: returns 0.0 - 1.0 range +/// Linux: may return values above 1.0 (PipeWire/PulseAudio boosted volumes) +pub fn get_volume() -> Result { + #[cfg(target_os = "macos")] + { + macos::get_volume() + } + + #[cfg(target_os = "windows")] + { + windows::get_volume() + } + + #[cfg(target_os = "linux")] + { + linux::get_volume() + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + Err("Unsupported platform".into()) + } +} + +/// Set the system volume +/// macOS/Windows: 0.0 - 1.0 range +/// Linux: 0.0 - 1.5+ range (PipeWire/PulseAudio support boosted volumes) +pub fn set_volume(level: f32) -> Result<(), String> { + // Linux supports volumes above 1.0, others don't + #[cfg(target_os = "linux")] + let level = level.max(0.0); // Only clamp minimum on Linux + + #[cfg(not(target_os = "linux"))] + let level = level.clamp(0.0, 1.0); + + #[cfg(target_os = "macos")] + { + macos::set_volume(level) + } + + #[cfg(target_os = "windows")] + { + windows::set_volume(level) + } + + #[cfg(target_os = "linux")] + { + linux::set_volume(level) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + Err("Unsupported platform".into()) + } +} + +/// Apply audio ducking - stores original volume and reduces to target level +/// ducking_amount: 0.0 = no change, 1.0 = full mute +pub fn apply_ducking(ducking_amount: f32) -> Result<(), String> { + let ducking_amount = ducking_amount.clamp(0.0, 1.0); + + // No ducking needed + if ducking_amount == 0.0 { + return Ok(()); + } + + // Get current volume + let current_volume = get_volume()?; + + // Store original volume if not already stored + let mut original = ORIGINAL_VOLUME.lock().map_err(|e| e.to_string())?; + if original.is_none() { + *original = Some(current_volume); + debug!("Stored original volume: {}", current_volume); + } + + // Calculate target volume: original * (1 - ducking_amount) + let original_vol = original.unwrap_or(current_volume); + let target_volume = original_vol * (1.0 - ducking_amount); + + debug!( + "Applying ducking: {} -> {} ({}% reduction)", + original_vol, + target_volume, + ducking_amount * 100.0 + ); + + set_volume(target_volume) +} + +/// Restore the original volume after ducking +pub fn restore_volume() -> Result<(), String> { + let mut original = ORIGINAL_VOLUME.lock().map_err(|e| e.to_string())?; + + if let Some(vol) = original.take() { + debug!("Restoring original volume: {}", vol); + set_volume(vol) + } else { + debug!("No original volume to restore"); + Ok(()) + } +} + +/// Check if ducking is currently active +#[allow(dead_code)] +pub fn is_ducking_active() -> bool { + ORIGINAL_VOLUME + .lock() + .map(|guard| guard.is_some()) + .unwrap_or(false) +} + +// ───────────────────────────────────────────────────────────────────────────── +// macOS implementation using CoreAudio +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(target_os = "macos")] +mod macos { + use coreaudio_sys::*; + use std::mem; + use std::ptr; + + fn get_default_output_device() -> Result { + unsafe { + let mut device_id: AudioDeviceID = 0; + let mut size = mem::size_of::() as u32; + + let address = AudioObjectPropertyAddress { + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain, + }; + + let status = AudioObjectGetPropertyData( + kAudioObjectSystemObject, + &address, + 0, + ptr::null(), + &mut size, + &mut device_id as *mut _ as *mut _, + ); + + if status == 0 { + Ok(device_id) + } else { + Err(format!( + "Failed to get default output device (status: {})", + status + )) + } + } + } + + pub fn get_volume() -> Result { + let device_id = get_default_output_device()?; + + unsafe { + let mut volume: f32 = 0.0; + let mut size = mem::size_of::() as u32; + + // Try VirtualMainVolume first (newer API) + let address = AudioObjectPropertyAddress { + mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain, + }; + + let status = AudioObjectGetPropertyData( + device_id, + &address, + 0, + ptr::null(), + &mut size, + &mut volume as *mut _ as *mut _, + ); + + if status == 0 { + Ok(volume) + } else { + // Fallback to older API + let address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyVolumeScalar, + mScope: kAudioDevicePropertyScopeOutput, + mElement: 1, // Master channel + }; + + let status = AudioObjectGetPropertyData( + device_id, + &address, + 0, + ptr::null(), + &mut size, + &mut volume as *mut _ as *mut _, + ); + + if status == 0 { + Ok(volume) + } else { + Err(format!("Failed to get volume (status: {})", status)) + } + } + } + } + + pub fn set_volume(level: f32) -> Result<(), String> { + let device_id = get_default_output_device()?; + + unsafe { + // Try VirtualMainVolume first (newer API) + let address = AudioObjectPropertyAddress { + mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain, + }; + + let status = AudioObjectSetPropertyData( + device_id, + &address, + 0, + ptr::null(), + mem::size_of::() as u32, + &level as *const _ as *const _, + ); + + if status == 0 { + Ok(()) + } else { + // Fallback to older API + let address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyVolumeScalar, + mScope: kAudioDevicePropertyScopeOutput, + mElement: 1, // Master channel + }; + + let status = AudioObjectSetPropertyData( + device_id, + &address, + 0, + ptr::null(), + mem::size_of::() as u32, + &level as *const _ as *const _, + ); + + if status == 0 { + Ok(()) + } else { + Err(format!("Failed to set volume (status: {})", status)) + } + } + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Windows implementation using COM API +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(target_os = "windows")] +mod windows { + use windows::Win32::{ + Media::Audio::{ + eMultimedia, eRender, Endpoints::IAudioEndpointVolume, IMMDeviceEnumerator, + MMDeviceEnumerator, + }, + System::Com::{CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED}, + }; + + fn get_volume_interface() -> Result { + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) + .map_err(|e| format!("Failed to create device enumerator: {}", e))?; + + let device = enumerator + .GetDefaultAudioEndpoint(eRender, eMultimedia) + .map_err(|e| format!("Failed to get default audio endpoint: {}", e))?; + + device + .Activate::(CLSCTX_ALL, None) + .map_err(|e| format!("Failed to activate volume interface: {}", e)) + } + } + + pub fn get_volume() -> Result { + unsafe { + let volume_interface = get_volume_interface()?; + volume_interface + .GetMasterVolumeLevelScalar() + .map_err(|e| format!("Failed to get volume: {}", e)) + } + } + + pub fn set_volume(level: f32) -> Result<(), String> { + unsafe { + let volume_interface = get_volume_interface()?; + volume_interface + .SetMasterVolumeLevelScalar(level, std::ptr::null()) + .map_err(|e| format!("Failed to set volume: {}", e)) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Linux implementation using wpctl/pactl +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(target_os = "linux")] +mod linux { + use std::process::Command; + + pub fn get_volume() -> Result { + // Try wpctl first (PipeWire) + if let Ok(output) = Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + // Parse "Volume: 0.50" or "Volume: 0.50 [MUTED]" format + if let Some(vol_str) = stdout.split_whitespace().nth(1) { + if let Ok(vol) = vol_str.parse::() { + // Don't clamp - PipeWire/PulseAudio support volumes above 1.0 (boosted) + return Ok(vol); + } + } + } + } + + // Try pactl (PulseAudio) + if let Ok(output) = Command::new("pactl") + .args(["get-sink-volume", "@DEFAULT_SINK@"]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + // Parse percentage from output like "Volume: front-left: 65536 / 100% / 0.00 dB" + for part in stdout.split_whitespace() { + if part.ends_with('%') { + if let Ok(pct) = part.trim_end_matches('%').parse::() { + // Don't clamp - PulseAudio supports volumes above 100% + return Ok(pct / 100.0); + } + } + } + } + } + + Err("Could not get system volume (wpctl/pactl not available)".into()) + } + + pub fn set_volume(level: f32) -> Result<(), String> { + let level_str = format!("{:.2}", level); + let percentage = format!("{}%", (level * 100.0) as i32); + + // Try wpctl first (PipeWire) + if Command::new("wpctl") + .args(["set-volume", "@DEFAULT_AUDIO_SINK@", &level_str]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return Ok(()); + } + + // Try pactl (PulseAudio) + if Command::new("pactl") + .args(["set-sink-volume", "@DEFAULT_SINK@", &percentage]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return Ok(()); + } + + // Try amixer (ALSA) + if Command::new("amixer") + .args(["set", "Master", &percentage]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return Ok(()); + } + + Err("Could not set system volume (wpctl/pactl/amixer not available)".into()) + } +} diff --git a/src/bindings.ts b/src/bindings.ts index 2877b1bf9..33eff4c57 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -244,6 +244,22 @@ async changeMuteWhileRecordingSetting(enabled: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_audio_ducking_enabled_setting", { enabled }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async changeAudioDuckingAmountSetting(amount: number) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_audio_ducking_amount_setting", { amount }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async changeAppendTrailingSpaceSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_append_trailing_space_setting", { enabled }) }; @@ -628,7 +644,7 @@ async isLaptop() : Promise> { /** user-defined types **/ -export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string } +export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; audio_ducking_enabled?: boolean; audio_ducking_amount?: number } export type AudioDevice = { index: string; name: string; is_default: boolean } export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null } export type ClipboardHandling = "dont_modify" | "copy_to_clipboard" diff --git a/src/components/settings/AudioDucking.tsx b/src/components/settings/AudioDucking.tsx new file mode 100644 index 000000000..51cc6c71e --- /dev/null +++ b/src/components/settings/AudioDucking.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; +import { Slider } from "../ui/Slider"; +import { useSettings } from "../../hooks/useSettings"; + +interface AudioDuckingProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const AudioDucking: React.FC = React.memo( + ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); + const { getSetting, updateSetting, isUpdating } = useSettings(); + + const duckingEnabled = getSetting("audio_ducking_enabled") ?? false; + const duckingAmount = getSetting("audio_ducking_amount") ?? 1.0; + + return ( + <> + + updateSetting("audio_ducking_enabled", enabled) + } + isUpdating={isUpdating("audio_ducking_enabled")} + label={t("settings.sound.audioDucking.label")} + description={t("settings.sound.audioDucking.description")} + descriptionMode={descriptionMode} + grouped={grouped} + /> + {duckingEnabled && ( + + updateSetting("audio_ducking_amount", value) + } + min={0} + max={1} + step={0.1} + label={t("settings.sound.audioDucking.reductionLabel")} + description={t("settings.sound.audioDucking.reductionDescription")} + descriptionMode={descriptionMode} + grouped={grouped} + formatValue={(v) => + v === 1 + ? t("settings.sound.audioDucking.muted") + : v === 0 + ? t("settings.sound.audioDucking.noChange") + : `${Math.round(v * 100)}%` + } + /> + )} + + ); + } +); diff --git a/src/components/settings/debug/DebugSettings.tsx b/src/components/settings/debug/DebugSettings.tsx index 4914010e0..5c21188a3 100644 --- a/src/components/settings/debug/DebugSettings.tsx +++ b/src/components/settings/debug/DebugSettings.tsx @@ -9,7 +9,6 @@ import { HistoryLimit } from "../HistoryLimit"; import { AlwaysOnMicrophone } from "../AlwaysOnMicrophone"; import { SoundPicker } from "../SoundPicker"; import { PostProcessingToggle } from "../PostProcessingToggle"; -import { MuteWhileRecording } from "../MuteWhileRecording"; import { AppendTrailingSpace } from "../AppendTrailingSpace"; import { RecordingRetentionPeriodSelector } from "../RecordingRetentionPeriod"; import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector"; @@ -42,7 +41,6 @@ export const DebugSettings: React.FC = () => { - {/* Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration */} {!isLinux && ( diff --git a/src/components/settings/general/GeneralSettings.tsx b/src/components/settings/general/GeneralSettings.tsx index b3a0ad678..107144a80 100644 --- a/src/components/settings/general/GeneralSettings.tsx +++ b/src/components/settings/general/GeneralSettings.tsx @@ -7,6 +7,7 @@ import { SettingsGroup } from "../../ui/SettingsGroup"; import { OutputDeviceSelector } from "../OutputDeviceSelector"; import { PushToTalk } from "../PushToTalk"; import { AudioFeedback } from "../AudioFeedback"; +import { AudioDucking } from "../AudioDucking"; import { useSettings } from "../../../hooks/useSettings"; import { VolumeSlider } from "../VolumeSlider"; @@ -29,6 +30,7 @@ export const GeneralSettings: React.FC = () => { disabled={!audioFeedbackEnabled} /> + ); diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 2208560f5..ceaac7992 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -136,6 +136,14 @@ "volume": { "title": "Volume", "description": "Adjust the volume of audio feedback sounds" + }, + "audioDucking": { + "label": "Reduce System Volume", + "description": "Automatically lower system audio while recording", + "reductionLabel": "Reduction Amount", + "reductionDescription": "How much to reduce the system volume (0% = no change, 100% = mute)", + "muted": "Muted", + "noChange": "No change" } }, "advanced": { diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index f35a6e956..3673b82e7 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -121,6 +121,10 @@ const settingUpdaters: { commands.setPostProcessSelectedPrompt(value as string), mute_while_recording: (value) => commands.changeMuteWhileRecordingSetting(value as boolean), + audio_ducking_enabled: (value) => + commands.changeAudioDuckingEnabledSetting(value as boolean), + audio_ducking_amount: (value) => + commands.changeAudioDuckingAmountSetting(value as number), append_trailing_space: (value) => commands.changeAppendTrailingSpaceSetting(value as boolean), log_level: (value) => commands.setLogLevel(value as any), From bc87ea48d15cb796cd4ad086c51d76cc7b3ac8e6 Mon Sep 17 00:00:00 2001 From: Praise Adesokan Date: Tue, 6 Jan 2026 16:41:24 +0100 Subject: [PATCH 2/7] fix: cleanup audio ducking migration and add i18n - Remove dead MuteWhileRecording.tsx component - Redirect legacy mute_while_recording API to new ducking system - Rename did_mute to did_duck for code clarity - Add audioDucking translations for all 9 locales --- src-tauri/src/managers/audio.rs | 24 +++++++-------- src-tauri/src/shortcut.rs | 8 ++++- .../settings/MuteWhileRecording.tsx | 29 ------------------- src/i18n/locales/de/translation.json | 8 +++++ src/i18n/locales/es/translation.json | 8 +++++ src/i18n/locales/fr/translation.json | 8 +++++ src/i18n/locales/it/translation.json | 8 +++++ src/i18n/locales/ja/translation.json | 8 +++++ src/i18n/locales/pl/translation.json | 8 +++++ src/i18n/locales/ru/translation.json | 8 +++++ src/i18n/locales/vi/translation.json | 8 +++++ src/i18n/locales/zh/translation.json | 8 +++++ 12 files changed, 91 insertions(+), 42 deletions(-) delete mode 100644 src/components/settings/MuteWhileRecording.tsx diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 8926592b5..b1fde0987 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -60,7 +60,7 @@ pub struct AudioRecordingManager { recorder: Arc>>, is_open: Arc>, is_recording: Arc>, - did_mute: Arc>, + did_duck: Arc>, } impl AudioRecordingManager { @@ -82,7 +82,7 @@ impl AudioRecordingManager { recorder: Arc::new(Mutex::new(None)), is_open: Arc::new(Mutex::new(false)), is_recording: Arc::new(Mutex::new(false)), - did_mute: Arc::new(Mutex::new(false)), + did_duck: Arc::new(Mutex::new(false)), }; // Always-on? Open immediately. @@ -127,12 +127,12 @@ impl AudioRecordingManager { /// Applies audio ducking if enabled in settings and stream is open pub fn apply_ducking(&self) { let settings = get_settings(&self.app_handle); - let mut did_mute_guard = self.did_mute.lock().unwrap(); + let mut did_duck_guard = self.did_duck.lock().unwrap(); if settings.audio_ducking_enabled && *self.is_open.lock().unwrap() { match volume_control::apply_ducking(settings.audio_ducking_amount) { Ok(()) => { - *did_mute_guard = true; + *did_duck_guard = true; debug!( "Audio ducking applied ({}% reduction)", settings.audio_ducking_amount * 100.0 @@ -147,11 +147,11 @@ impl AudioRecordingManager { /// Removes audio ducking and restores original volume pub fn remove_ducking(&self) { - let mut did_mute_guard = self.did_mute.lock().unwrap(); - if *did_mute_guard { + let mut did_duck_guard = self.did_duck.lock().unwrap(); + if *did_duck_guard { match volume_control::restore_volume() { Ok(()) => { - *did_mute_guard = false; + *did_duck_guard = false; debug!("Audio ducking removed, volume restored"); } Err(e) => { @@ -182,8 +182,8 @@ impl AudioRecordingManager { let start_time = Instant::now(); // Don't mute immediately - caller will handle muting after audio feedback - let mut did_mute_guard = self.did_mute.lock().unwrap(); - *did_mute_guard = false; + let mut did_duck_guard = self.did_duck.lock().unwrap(); + *did_duck_guard = false; let vad_path = self .app_handle @@ -226,13 +226,13 @@ impl AudioRecordingManager { } // Restore volume if ducking was applied - let mut did_mute_guard = self.did_mute.lock().unwrap(); - if *did_mute_guard { + let mut did_duck_guard = self.did_duck.lock().unwrap(); + if *did_duck_guard { if let Err(e) = volume_control::restore_volume() { error!("Failed to restore volume on stream stop: {}", e); } } - *did_mute_guard = false; + *did_duck_guard = false; if let Some(rec) = self.recorder.lock().unwrap().as_mut() { // If still recording, stop first. diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index bc927c599..096fc3b50 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -591,11 +591,17 @@ pub fn set_post_process_selected_prompt(app: AppHandle, id: String) -> Result<() Ok(()) } +/// Deprecated: Use `change_audio_ducking_enabled_setting` instead. +/// This command now redirects to the audio ducking system for backward compatibility. #[tauri::command] #[specta::specta] pub fn change_mute_while_recording_setting(app: AppHandle, enabled: bool) -> Result<(), String> { + // Redirect to new audio ducking system let mut settings = settings::get_settings(&app); - settings.mute_while_recording = enabled; + settings.audio_ducking_enabled = enabled; + if enabled { + settings.audio_ducking_amount = 1.0; // Full mute for backward compat + } settings::write_settings(&app, settings); Ok(()) diff --git a/src/components/settings/MuteWhileRecording.tsx b/src/components/settings/MuteWhileRecording.tsx deleted file mode 100644 index b3e815421..000000000 --- a/src/components/settings/MuteWhileRecording.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { ToggleSwitch } from "../ui/ToggleSwitch"; -import { useSettings } from "../../hooks/useSettings"; - -interface MuteWhileRecordingToggleProps { - descriptionMode?: "inline" | "tooltip"; - grouped?: boolean; -} - -export const MuteWhileRecording: React.FC = - React.memo(({ descriptionMode = "tooltip", grouped = false }) => { - const { t } = useTranslation(); - const { getSetting, updateSetting, isUpdating } = useSettings(); - - const muteEnabled = getSetting("mute_while_recording") ?? false; - - return ( - updateSetting("mute_while_recording", enabled)} - isUpdating={isUpdating("mute_while_recording")} - label={t("settings.debug.muteWhileRecording.label")} - description={t("settings.debug.muteWhileRecording.description")} - descriptionMode={descriptionMode} - grouped={grouped} - /> - ); - }); diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 394d28ef1..515d12ea4 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -136,6 +136,14 @@ "volume": { "title": "Lautstärke", "description": "Lautstärke der Audio-Feedback-Töne anpassen" + }, + "audioDucking": { + "label": "Systemlautstärke Reduzieren", + "description": "Systemlautstärke während der Aufnahme automatisch verringern", + "reductionLabel": "Reduzierungsmenge", + "reductionDescription": "Wie stark die Systemlautstärke reduziert werden soll (0% = keine Änderung, 100% = stumm)", + "muted": "Stumm", + "noChange": "Keine Änderung" } }, "advanced": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 6f11d318b..d027bb11b 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -136,6 +136,14 @@ "volume": { "title": "Volumen", "description": "Ajusta el volumen de los sonidos de retroalimentación de audio" + }, + "audioDucking": { + "label": "Reducir Volumen del Sistema", + "description": "Reducir automáticamente el audio del sistema durante la grabación", + "reductionLabel": "Cantidad de Reducción", + "reductionDescription": "Cuánto reducir el volumen del sistema (0% = sin cambio, 100% = silenciar)", + "muted": "Silenciado", + "noChange": "Sin cambio" } }, "advanced": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index ce08be076..82b9658e7 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -137,6 +137,14 @@ "volume": { "title": "Volume", "description": "Ajuster le volume du signal sonore" + }, + "audioDucking": { + "label": "Réduire le Volume Système", + "description": "Réduire automatiquement le volume du système pendant l'enregistrement", + "reductionLabel": "Niveau de Réduction", + "reductionDescription": "Réduction du volume système (0% = aucun changement, 100% = muet)", + "muted": "Muet", + "noChange": "Aucun changement" } }, "advanced": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 4f7d2bd07..cdad9459b 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -136,6 +136,14 @@ "volume": { "title": "Volume", "description": "Regola il volume del feedback audio" + }, + "audioDucking": { + "label": "Riduci Volume di Sistema", + "description": "Riduci automaticamente il volume del sistema durante la registrazione", + "reductionLabel": "Quantità di Riduzione", + "reductionDescription": "Quanto ridurre il volume del sistema (0% = nessun cambiamento, 100% = muto)", + "muted": "Muto", + "noChange": "Nessun cambiamento" } }, "advanced": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 5e4c827cc..c7268df55 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -136,6 +136,14 @@ "volume": { "title": "音量", "description": "音声フィードバックの音量を調整" + }, + "audioDucking": { + "label": "システム音量を下げる", + "description": "録音中にシステム音量を自動的に下げる", + "reductionLabel": "減少量", + "reductionDescription": "システム音量をどれだけ下げるか(0% = 変更なし、100% = ミュート)", + "muted": "ミュート", + "noChange": "変更なし" } }, "advanced": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index c8866bbe3..ac91898f6 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -136,6 +136,14 @@ "volume": { "title": "Głośność", "description": "Dostosuj głośność dźwięków informacyjnych" + }, + "audioDucking": { + "label": "Zmniejsz Głośność Systemową", + "description": "Automatycznie zmniejsz głośność systemu podczas nagrywania", + "reductionLabel": "Poziom Redukcji", + "reductionDescription": "O ile zmniejszyć głośność systemu (0% = bez zmian, 100% = wyciszenie)", + "muted": "Wyciszony", + "noChange": "Bez zmian" } }, "advanced": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 54a794509..092ebfa82 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -136,6 +136,14 @@ "volume": { "title": "Объем", "description": "Отрегулируйте громкость звуков звуковой обратной связи" + }, + "audioDucking": { + "label": "Уменьшить Громкость Системы", + "description": "Автоматически уменьшать громкость системы во время записи", + "reductionLabel": "Уровень Снижения", + "reductionDescription": "Насколько снизить громкость системы (0% = без изменений, 100% = выключить звук)", + "muted": "Без звука", + "noChange": "Без изменений" } }, "advanced": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 27541f4e3..f2e4fba4a 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -137,6 +137,14 @@ "volume": { "title": "Âm lượng", "description": "Điều chỉnh âm lượng của âm thanh phản hồi" + }, + "audioDucking": { + "label": "Giảm Âm Lượng Hệ Thống", + "description": "Tự động giảm âm lượng hệ thống khi đang ghi âm", + "reductionLabel": "Mức Giảm", + "reductionDescription": "Giảm âm lượng hệ thống bao nhiêu (0% = không thay đổi, 100% = tắt tiếng)", + "muted": "Đã tắt tiếng", + "noChange": "Không thay đổi" } }, "advanced": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index dada770f9..e7e003d3d 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -136,6 +136,14 @@ "volume": { "title": "音量", "description": "调整音频反馈的音量" + }, + "audioDucking": { + "label": "降低系统音量", + "description": "录音时自动降低系统音量", + "reductionLabel": "降低程度", + "reductionDescription": "降低系统音量的程度(0% = 无变化,100% = 静音)", + "muted": "静音", + "noChange": "无变化" } }, "advanced": { From 9d1dab6fafc7bc2456c7bd4ee41b9e2c8dc8b61d Mon Sep 17 00:00:00 2001 From: Praise Adesokan Date: Tue, 6 Jan 2026 16:47:33 +0100 Subject: [PATCH 3/7] fix: add crash recovery for audio ducking If the app crashes while volume is ducked, the original volume is now persisted to a temp file and restored on next startup. --- src-tauri/src/lib.rs | 3 ++ src-tauri/src/volume_control.rs | 70 ++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ab7c15b1f..2070fe8b8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -378,6 +378,9 @@ pub fn run() { FILE_LOG_LEVEL.store(file_log_level.to_level_filter() as u8, Ordering::Relaxed); let app_handle = app.handle().clone(); + // Recover volume if previous session crashed while ducking was active + volume_control::recover_volume_on_startup(); + initialize_core_logic(&app_handle); // Show main window only if not starting hidden diff --git a/src-tauri/src/volume_control.rs b/src-tauri/src/volume_control.rs index 5bb3567a9..5b9ae6512 100644 --- a/src-tauri/src/volume_control.rs +++ b/src-tauri/src/volume_control.rs @@ -1,10 +1,69 @@ -use log::debug; +use log::{debug, info, warn}; use once_cell::sync::Lazy; +use std::fs; +use std::io::Write; +use std::path::PathBuf; use std::sync::Mutex; /// Stores the original volume before ducking was applied static ORIGINAL_VOLUME: Lazy>> = Lazy::new(|| Mutex::new(None)); +/// Get the path to the volume recovery file +/// Uses a temp directory location that persists across app restarts +fn get_recovery_file_path() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push("handy_volume_recovery.txt"); + path +} + +/// Persist the original volume to disk for crash recovery +fn persist_volume(volume: f32) { + let path = get_recovery_file_path(); + if let Ok(mut file) = fs::File::create(&path) { + let _ = writeln!(file, "{}", volume); + debug!("Persisted original volume {} to {:?}", volume, path); + } +} + +/// Clear the persisted volume file (called after successful restore) +fn clear_persisted_volume() { + let path = get_recovery_file_path(); + if path.exists() { + let _ = fs::remove_file(&path); + debug!("Cleared volume recovery file"); + } +} + +/// Load persisted volume from disk (if exists) +fn load_persisted_volume() -> Option { + let path = get_recovery_file_path(); + if let Ok(contents) = fs::read_to_string(&path) { + contents.trim().parse().ok() + } else { + None + } +} + +/// Recover volume on app startup if a previous session crashed while ducking was active. +/// Call this once during app initialization. +pub fn recover_volume_on_startup() { + if let Some(volume) = load_persisted_volume() { + info!( + "Found unrestored volume from previous session: {}%. Restoring...", + (volume * 100.0) as i32 + ); + match set_volume(volume) { + Ok(()) => { + info!("Successfully restored volume to {}%", (volume * 100.0) as i32); + clear_persisted_volume(); + } + Err(e) => { + warn!("Failed to restore volume: {}. You may need to manually set your volume to {}%", e, (volume * 100.0) as i32); + } + } + } +} + /// Get the current system volume /// macOS/Windows: returns 0.0 - 1.0 range /// Linux: may return values above 1.0 (PipeWire/PulseAudio boosted volumes) @@ -79,6 +138,8 @@ pub fn apply_ducking(ducking_amount: f32) -> Result<(), String> { let mut original = ORIGINAL_VOLUME.lock().map_err(|e| e.to_string())?; if original.is_none() { *original = Some(current_volume); + // Persist to disk for crash recovery + persist_volume(current_volume); debug!("Stored original volume: {}", current_volume); } @@ -102,7 +163,12 @@ pub fn restore_volume() -> Result<(), String> { if let Some(vol) = original.take() { debug!("Restoring original volume: {}", vol); - set_volume(vol) + let result = set_volume(vol); + // Clear persisted file on successful restore + if result.is_ok() { + clear_persisted_volume(); + } + result } else { debug!("No original volume to restore"); Ok(()) From cb4022c75ec263715b96f3be2ebcbbcf6d6cce86 Mon Sep 17 00:00:00 2001 From: Praise Adesokan Date: Tue, 6 Jan 2026 17:09:38 +0100 Subject: [PATCH 4/7] fix: address volume ducking edge cases - Fix cancel_recording not restoring volume in always-on mode - Preserve original volume state if restore fails (allows retry) - Only persist recovery file after successful volume change --- src-tauri/src/managers/audio.rs | 9 ++++++--- src-tauri/src/volume_control.rs | 20 ++++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index b1fde0987..a7aa73845 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -228,11 +228,11 @@ impl AudioRecordingManager { // Restore volume if ducking was applied let mut did_duck_guard = self.did_duck.lock().unwrap(); if *did_duck_guard { - if let Err(e) = volume_control::restore_volume() { - error!("Failed to restore volume on stream stop: {}", e); + match volume_control::restore_volume() { + Ok(()) => *did_duck_guard = false, + Err(e) => error!("Failed to restore volume on stream stop: {}", e), } } - *did_duck_guard = false; if let Some(rec) = self.recorder.lock().unwrap().as_mut() { // If still recording, stop first. @@ -379,6 +379,9 @@ impl AudioRecordingManager { // In on-demand mode turn the mic off again if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { self.stop_microphone_stream(); + } else { + // In always-on mode, stream stays open but we still need to restore volume + self.remove_ducking(); } } } diff --git a/src-tauri/src/volume_control.rs b/src-tauri/src/volume_control.rs index 5b9ae6512..7fb3125e2 100644 --- a/src-tauri/src/volume_control.rs +++ b/src-tauri/src/volume_control.rs @@ -136,10 +136,9 @@ pub fn apply_ducking(ducking_amount: f32) -> Result<(), String> { // Store original volume if not already stored let mut original = ORIGINAL_VOLUME.lock().map_err(|e| e.to_string())?; - if original.is_none() { + let is_first_duck = original.is_none(); + if is_first_duck { *original = Some(current_volume); - // Persist to disk for crash recovery - persist_volume(current_volume); debug!("Stored original volume: {}", current_volume); } @@ -154,18 +153,27 @@ pub fn apply_ducking(ducking_amount: f32) -> Result<(), String> { ducking_amount * 100.0 ); - set_volume(target_volume) + let result = set_volume(target_volume); + + // Only persist after successful volume change to avoid false recovery + if result.is_ok() && is_first_duck { + persist_volume(current_volume); + } + + result } /// Restore the original volume after ducking pub fn restore_volume() -> Result<(), String> { let mut original = ORIGINAL_VOLUME.lock().map_err(|e| e.to_string())?; - if let Some(vol) = original.take() { + if let Some(&vol) = original.as_ref() { debug!("Restoring original volume: {}", vol); let result = set_volume(vol); - // Clear persisted file on successful restore + + // Only clear state on successful restore - allows retry on failure if result.is_ok() { + *original = None; clear_persisted_volume(); } result From dcb9cca00f489a943033ec2a6ad1a5aed6530d18 Mon Sep 17 00:00:00 2001 From: Praise Adesokan Date: Tue, 6 Jan 2026 17:15:01 +0100 Subject: [PATCH 5/7] fix: retry volume restore on new recording start If a previous volume restore failed, attempt to restore again when starting a new recording instead of dropping the retry. --- src-tauri/src/managers/audio.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index a7aa73845..58dee0baf 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -181,9 +181,14 @@ impl AudioRecordingManager { let start_time = Instant::now(); - // Don't mute immediately - caller will handle muting after audio feedback + // If a previous restore failed, retry before starting new session let mut did_duck_guard = self.did_duck.lock().unwrap(); - *did_duck_guard = false; + if *did_duck_guard { + debug!("Retrying volume restore from previous failed attempt"); + if volume_control::restore_volume().is_ok() { + *did_duck_guard = false; + } + } let vad_path = self .app_handle From ca192030fa1355c08922e846a60156dc505a5bf8 Mon Sep 17 00:00:00 2001 From: Praise Adesokan Date: Tue, 6 Jan 2026 20:53:55 +0100 Subject: [PATCH 6/7] chore: regenerate bindings with deprecation comment --- src/bindings.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/bindings.ts b/src/bindings.ts index 33eff4c57..048c5a2e6 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -236,6 +236,10 @@ async resumeBinding(id: string) : Promise> { else return { status: "error", error: e as any }; } }, +/** + * Deprecated: Use `change_audio_ducking_enabled_setting` instead. + * This command now redirects to the audio ducking system for backward compatibility. + */ async changeMuteWhileRecordingSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_mute_while_recording_setting", { enabled }) }; From 0aef73f636cfa83aba5f086ae2b8b45955463ac8 Mon Sep 17 00:00:00 2001 From: Praise Adesokan Date: Tue, 6 Jan 2026 20:55:29 +0100 Subject: [PATCH 7/7] style: apply formatting --- src-tauri/src/volume_control.rs | 11 +++++++++-- src/components/settings/AudioDucking.tsx | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/volume_control.rs b/src-tauri/src/volume_control.rs index 7fb3125e2..b86363f8b 100644 --- a/src-tauri/src/volume_control.rs +++ b/src-tauri/src/volume_control.rs @@ -54,11 +54,18 @@ pub fn recover_volume_on_startup() { ); match set_volume(volume) { Ok(()) => { - info!("Successfully restored volume to {}%", (volume * 100.0) as i32); + info!( + "Successfully restored volume to {}%", + (volume * 100.0) as i32 + ); clear_persisted_volume(); } Err(e) => { - warn!("Failed to restore volume: {}. You may need to manually set your volume to {}%", e, (volume * 100.0) as i32); + warn!( + "Failed to restore volume: {}. You may need to manually set your volume to {}%", + e, + (volume * 100.0) as i32 + ); } } } diff --git a/src/components/settings/AudioDucking.tsx b/src/components/settings/AudioDucking.tsx index 51cc6c71e..af7267de7 100644 --- a/src/components/settings/AudioDucking.tsx +++ b/src/components/settings/AudioDucking.tsx @@ -54,5 +54,5 @@ export const AudioDucking: React.FC = React.memo( )} ); - } + }, );