Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 29 additions & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -375,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
Expand Down
173 changes: 61 additions & 112 deletions src-tauri/src/managers/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<IAudioEndpointVolume>(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;

/* ──────────────────────────────────────────────────────────────── */
Expand Down Expand Up @@ -148,7 +60,7 @@ pub struct AudioRecordingManager {
recorder: Arc<Mutex<Option<AudioRecorder>>>,
is_open: Arc<Mutex<bool>>,
is_recording: Arc<Mutex<bool>>,
did_mute: Arc<Mutex<bool>>,
did_duck: Arc<Mutex<bool>>,
}

impl AudioRecordingManager {
Expand All @@ -170,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.
Expand Down Expand Up @@ -212,26 +124,52 @@ 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();
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_duck_guard = true;
debug!(
"Audio ducking applied ({}% reduction)",
settings.audio_ducking_amount * 100.0
);
}
Err(e) => {
error!("Failed to apply audio ducking: {}", e);
}
}
}
}

if settings.mute_while_recording && *self.is_open.lock().unwrap() {
set_mute(true);
*did_mute_guard = true;
debug!("Mute applied");
/// Removes audio ducking and restores original volume
pub fn remove_ducking(&self) {
let mut did_duck_guard = self.did_duck.lock().unwrap();
if *did_duck_guard {
match volume_control::restore_volume() {
Ok(()) => {
*did_duck_guard = false;
debug!("Audio ducking removed, volume restored");
}
Err(e) => {
error!("Failed to restore volume: {}", e);
}
}
}
}

/// Removes mute if it was applied
// 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) {
let mut did_mute_guard = self.did_mute.lock().unwrap();
if *did_mute_guard {
set_mute(false);
*did_mute_guard = false;
debug!("Mute removed");
}
self.remove_ducking();
}

pub fn start_microphone_stream(&self) -> Result<(), anyhow::Error> {
Expand All @@ -243,9 +181,14 @@ 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;
// If a previous restore failed, retry before starting new session
let mut did_duck_guard = self.did_duck.lock().unwrap();
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
Expand Down Expand Up @@ -287,11 +230,14 @@ impl AudioRecordingManager {
return;
}

let mut did_mute_guard = self.did_mute.lock().unwrap();
if *did_mute_guard {
set_mute(false);
// Restore volume if ducking was applied
let mut did_duck_guard = self.did_duck.lock().unwrap();
if *did_duck_guard {
match volume_control::restore_volume() {
Ok(()) => *did_duck_guard = false,
Err(e) => error!("Failed to restore volume on stream stop: {}", e),
}
}
*did_mute_guard = false;

if let Some(rec) = self.recorder.lock().unwrap().as_mut() {
// If still recording, stop first.
Expand Down Expand Up @@ -438,6 +384,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();
}
}
}
Expand Down
29 changes: 29 additions & 0 deletions src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -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)
Expand Down
Loading