Merge pull request #274 from sora520/feat/mute-during-recording

feat(recording): mute system output while recording
This commit is contained in:
Chris233 authored and GitHub committed 2026-05-06 00:14:57 +08:00
commit b802dc4154
11 files changed
+354 -12

No files matched your search

+2
View File
@@ -65,6 +65,8 @@ windows = { version = "0.58", features = [
"Win32_Globalization",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_Media_Audio",
"Win32_Media_Audio_Endpoints",
"Win32_System_Com",
"Win32_System_Ole",
"Win32_System_Registry",
@@ -0,0 +1,261 @@
//! Temporary system-output mute while recording.
pub struct AudioMuteGuard {
inner: Option<platform::PlatformMuteGuard>,
}
impl AudioMuteGuard {
pub fn activate() -> Result<Self, String> {
Ok(Self {
inner: Some(platform::activate()?),
})
}
}
impl Drop for AudioMuteGuard {
fn drop(&mut self) {
if let Some(inner) = self.inner.take() {
inner.restore();
}
}
}
#[cfg(target_os = "windows")]
mod platform {
use windows::Win32::Foundation::{BOOL, RPC_E_CHANGED_MODE};
use windows::Win32::Media::Audio::{eConsole, eRender, IMMDeviceEnumerator, MMDeviceEnumerator};
use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume;
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED,
};
pub struct PlatformMuteGuard {
was_muted: bool,
}
pub fn activate() -> Result<PlatformMuteGuard, String> {
let was_muted = endpoint_muted()?;
if !was_muted {
set_endpoint_muted(true)?;
}
Ok(PlatformMuteGuard { was_muted })
}
impl PlatformMuteGuard {
pub fn restore(self) {
if let Err(err) = set_endpoint_muted(self.was_muted) {
log::warn!("[audio-mute] restore endpoint mute failed: {err}");
}
}
}
fn endpoint_muted() -> Result<bool, String> {
with_endpoint_volume(|endpoint| unsafe {
let muted = endpoint
.GetMute()
.map_err(|e| format!("read endpoint mute state failed: {e}"))?;
Ok(muted.as_bool())
})
}
fn set_endpoint_muted(muted: bool) -> Result<(), String> {
with_endpoint_volume(|endpoint| unsafe {
endpoint
.SetMute(BOOL(muted as i32), std::ptr::null())
.map_err(|e| format!("set endpoint mute failed: {e}"))
})
}
fn with_endpoint_volume<T>(
f: impl FnOnce(&IAudioEndpointVolume) -> Result<T, String>,
) -> Result<T, String> {
unsafe {
let hr = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
let uninit_com = hr.is_ok();
if !hr.is_ok() && hr != RPC_E_CHANGED_MODE {
return Err(format!("CoInitializeEx failed: {hr:?}"));
}
let result = (|| {
let enumerator: IMMDeviceEnumerator =
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)
.map_err(|e| format!("create audio device enumerator failed: {e}"))?;
let device = enumerator
.GetDefaultAudioEndpoint(eRender, eConsole)
.map_err(|e| format!("get default render endpoint failed: {e}"))?;
let endpoint: IAudioEndpointVolume = device
.Activate(CLSCTX_ALL, None)
.map_err(|e| format!("activate endpoint volume failed: {e}"))?;
f(&endpoint)
})();
if uninit_com {
CoUninitialize();
}
result
}
}
}
#[cfg(target_os = "macos")]
mod platform {
use std::process::Command;
pub struct PlatformMuteGuard {
was_muted: bool,
}
pub fn activate() -> Result<PlatformMuteGuard, String> {
let was_muted = output_muted()?;
if !was_muted {
set_output_muted(true)?;
}
Ok(PlatformMuteGuard { was_muted })
}
impl PlatformMuteGuard {
pub fn restore(self) {
if let Err(err) = set_output_muted(self.was_muted) {
log::warn!("[audio-mute] restore output mute failed: {err}");
}
}
}
fn output_muted() -> Result<bool, String> {
let output = Command::new("osascript")
.args(["-e", "output muted of (get volume settings)"])
.output()
.map_err(|e| format!("query output mute failed: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&output.stdout).trim() == "true")
}
fn set_output_muted(muted: bool) -> Result<(), String> {
let script = if muted {
"set volume output muted true"
} else {
"set volume output muted false"
};
let output = Command::new("osascript")
.args(["-e", script])
.output()
.map_err(|e| format!("set output mute failed: {e}"))?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
}
#[cfg(target_os = "linux")]
mod platform {
use std::process::Command;
enum Backend {
Wpctl,
Pactl,
}
pub struct PlatformMuteGuard {
backend: Backend,
was_muted: bool,
}
pub fn activate() -> Result<PlatformMuteGuard, String> {
if let Ok(was_muted) = wpctl_output_muted() {
if !was_muted {
wpctl_set_output_muted(true)?;
}
return Ok(PlatformMuteGuard {
backend: Backend::Wpctl,
was_muted,
});
}
let was_muted = pactl_output_muted()?;
if !was_muted {
pactl_set_output_muted(true)?;
}
Ok(PlatformMuteGuard {
backend: Backend::Pactl,
was_muted,
})
}
impl PlatformMuteGuard {
pub fn restore(self) {
let result = match self.backend {
Backend::Wpctl => wpctl_set_output_muted(self.was_muted),
Backend::Pactl => pactl_set_output_muted(self.was_muted),
};
if let Err(err) = result {
log::warn!("[audio-mute] restore output mute failed: {err}");
}
}
}
fn wpctl_output_muted() -> Result<bool, String> {
let output = Command::new("wpctl")
.args(["get-volume", "@DEFAULT_AUDIO_SINK@"])
.output()
.map_err(|e| format!("wpctl get-volume failed: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&output.stdout).contains("[MUTED]"))
}
fn wpctl_set_output_muted(muted: bool) -> Result<(), String> {
let value = if muted { "1" } else { "0" };
let output = Command::new("wpctl")
.args(["set-mute", "@DEFAULT_AUDIO_SINK@", value])
.output()
.map_err(|e| format!("wpctl set-mute failed: {e}"))?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
fn pactl_output_muted() -> Result<bool, String> {
let output = Command::new("pactl")
.args(["get-sink-mute", "@DEFAULT_SINK@"])
.output()
.map_err(|e| format!("pactl get-sink-mute failed: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
Ok(stdout.contains("yes") || stdout.contains("是"))
}
fn pactl_set_output_muted(muted: bool) -> Result<(), String> {
let value = if muted { "1" } else { "0" };
let output = Command::new("pactl")
.args(["set-sink-mute", "@DEFAULT_SINK@", value])
.output()
.map_err(|e| format!("pactl set-sink-mute failed: {e}"))?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
mod platform {
pub struct PlatformMuteGuard;
pub fn activate() -> Result<PlatformMuteGuard, String> {
Err("output mute is not supported on this platform".to_string())
}
impl PlatformMuteGuard {
pub fn restore(self) {}
}
}
+69 -12
View File
@@ -98,6 +98,20 @@ struct SessionState {
front_app: Option<String>,
}
struct SharedRecordingMuteState {
guard: Option<crate::audio_mute::AudioMuteGuard>,
holders: u32,
}
impl SharedRecordingMuteState {
fn new() -> Self {
Self {
guard: None,
holders: 0,
}
}
}
impl Default for SessionState {
fn default() -> Self {
Self {
@@ -132,6 +146,7 @@ struct Inner {
/// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。
local_asr_cache: Arc<crate::asr::local::LocalAsrCache>,
recorder: Mutex<Option<SessionResource<Recorder>>>,
recording_mute: Mutex<SharedRecordingMuteState>,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
hotkey_trigger_held: AtomicBool,
@@ -227,6 +242,7 @@ impl Coordinator {
state: Mutex::new(SessionState::default()),
asr: Mutex::new(None),
recorder: Mutex::new(None),
recording_mute: Mutex::new(SharedRecordingMuteState::new()),
hotkey: Mutex::new(None),
hotkey_status: Mutex::new(HotkeyStatus::default()),
hotkey_trigger_held: AtomicBool::new(false),
@@ -873,6 +889,47 @@ fn store_recorder_for_session(inner: &Arc<Inner>, session_id: u64, recorder: Rec
*inner.recorder.lock() = Some(SessionResource::new(session_id, recorder));
}
fn acquire_recording_mute(inner: &Arc<Inner>, owner: &str) {
if !inner.prefs.get().mute_during_recording {
return;
}
let mut mute = inner.recording_mute.lock();
if mute.holders == 0 {
match crate::audio_mute::AudioMuteGuard::activate() {
Ok(guard) => {
mute.guard = Some(guard);
log::info!("[audio-mute] system output muted for recording");
}
Err(err) => {
log::warn!("[audio-mute] failed to mute output for {owner}: {err}");
return;
}
}
}
mute.holders = mute.holders.saturating_add(1);
log::info!("[audio-mute] acquired by {owner}; holders={}", mute.holders);
}
fn release_recording_mute(inner: &Arc<Inner>, owner: &str) {
let mut mute = inner.recording_mute.lock();
if mute.holders == 0 {
return;
}
mute.holders -= 1;
log::info!("[audio-mute] released by {owner}; holders={}", mute.holders);
if mute.holders == 0 {
mute.guard.take();
log::info!("[audio-mute] system output mute restored after recording");
}
}
fn stop_qa_recorder(inner: &Arc<Inner>) {
if let Some(rec) = inner.qa_recorder.lock().take() {
rec.stop();
release_recording_mute(inner, "qa");
}
}
fn take_recorder_for_session(inner: &Arc<Inner>, session_id: u64) -> Option<Recorder> {
let mut slot = inner.recorder.lock();
take_session_resource(&mut slot, session_id)
@@ -881,6 +938,7 @@ fn take_recorder_for_session(inner: &Arc<Inner>, session_id: u64) -> Option<Reco
fn stop_recorder_for_session(inner: &Arc<Inner>, session_id: u64) {
if let Some(recorder) = take_recorder_for_session(inner, session_id) {
recorder.stop();
release_recording_mute(inner, "dictation");
}
}
@@ -902,6 +960,7 @@ fn stop_recorder_if_pending_start_stop(inner: &Arc<Inner>) {
}
if let Some(rec) = take_recorder_for_session(inner, session_id) {
rec.stop();
release_recording_mute(inner, "dictation");
let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64;
emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None);
log::info!("[coord] stopped recorder while ASR is still connecting");
@@ -1235,6 +1294,7 @@ fn start_recorder_for_starting(
);
});
acquire_recording_mute(inner, "dictation");
match Recorder::start(consumer, level_handler) {
Ok((rec, runtime_errors)) => {
store_recorder_for_session(inner, session_id, rec);
@@ -1254,6 +1314,7 @@ fn start_recorder_for_starting(
None,
);
restore_prepared_windows_ime_session(inner, session_id);
release_recording_mute(inner, "dictation");
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
@@ -1439,6 +1500,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
if let Some(rec) = take_recorder_for_session(inner, current_session_id) {
rec.stop();
release_recording_mute(inner, "dictation");
}
let asr_opt = take_asr_for_session(inner, current_session_id);
@@ -2500,6 +2562,7 @@ async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
);
});
acquire_recording_mute(inner, "qa");
match Recorder::start(consumer, level_handler) {
Ok((rec, runtime_errors)) => {
*inner.qa_recorder.lock() = Some(rec);
@@ -2510,6 +2573,7 @@ async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
Err(e) => {
log::error!("[coord] QA recorder start failed: {e}");
inner.qa_asr.lock().take();
release_recording_mute(inner, "qa");
finish_qa_with_error(inner, format!("录音启动失败: {e}"));
return Err(e.to_string());
}
@@ -2517,9 +2581,7 @@ async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
if let Err(e) = asr.open_session().await {
log::error!("[coord] QA: open ASR session failed: {e}");
if let Some(rec) = inner.qa_recorder.lock().take() {
rec.stop();
}
stop_qa_recorder(inner);
if let Some(asr) = inner.qa_asr.lock().take() {
asr.cancel();
}
@@ -2531,9 +2593,7 @@ async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
if inner.qa_state.lock().cancelled {
log::info!("[coord] QA cancel raced during open_session — aborting begin");
asr.cancel();
if let Some(rec) = inner.qa_recorder.lock().take() {
rec.stop();
}
stop_qa_recorder(inner);
inner.qa_state.lock().phase = QaPhase::Idle;
return Ok(());
}
@@ -2565,9 +2625,7 @@ async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
let _ = app.emit_to("qa", "qa:state", serde_json::json!({ "kind": "loading" }));
}
if let Some(rec) = inner.qa_recorder.lock().take() {
rec.stop();
}
stop_qa_recorder(inner);
let asr = match inner.qa_asr.lock().take() {
Some(a) => a,
@@ -2786,6 +2844,7 @@ async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
/// 浮窗保持可见(v2:错误后用户可以再按 Option 重试);messages 一并送过去
/// 让前端继续渲染历史对话。
fn finish_qa_with_error(inner: &Arc<Inner>, message: String) {
stop_qa_recorder(inner);
if let Some(app) = inner.app.lock().clone() {
let messages = inner.qa_state.lock().messages.clone();
let _ = app.emit_to(
@@ -2836,9 +2895,7 @@ fn cancel_qa_session(inner: &Arc<Inner>) {
// 这个 flag,true 时立即 break 不再 drain HTTP body,避免取消后 LLM 仍烧 token。
// 详见 issue #161。
inner.qa_stream_cancelled.store(true, Ordering::SeqCst);
if let Some(rec) = inner.qa_recorder.lock().take() {
rec.stop();
}
stop_qa_recorder(inner);
if let Some(asr) = inner.qa_asr.lock().take() {
asr.cancel();
}
+1
View File
@@ -11,6 +11,7 @@
//! - commands: Tauri IPC surface
mod asr;
mod audio_mute;
mod commands;
mod coordinator;
mod hotkey;
+4
View File
@@ -117,6 +117,9 @@ pub struct UserPreferences {
pub enabled_modes: Vec<PolishMode>,
pub launch_at_login: bool,
pub show_capsule: bool,
/// 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。
#[serde(default)]
pub mute_during_recording: bool,
pub active_asr_provider: String, // "volcengine" | "apple-speech" | ...
pub active_llm_provider: String, // "ark" | "openai" | ...
/// Windows/Linux 粘贴成功后是否恢复用户原剪贴板。默认 true 跟历史行为一致;
@@ -203,6 +206,7 @@ impl Default for UserPreferences {
],
launch_at_login: false,
show_capsule: true,
mute_during_recording: false,
active_asr_provider: "volcengine".into(),
active_llm_provider: "ark".into(),
restore_clipboard_after_paste: true,
+2
View File
@@ -276,6 +276,8 @@ export const en: typeof zhCN = {
migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it here once. This update changes both the default value and the preference-reading path; if you prefer push-to-talk, switch it back manually.',
capsuleLabel: 'Recording capsule',
capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.',
muteDuringRecordingLabel: 'Mute while recording',
muteDuringRecordingDesc: 'Temporarily mute system output during voice input, then restore the previous mute state when recording stops, is cancelled, or fails.',
restoreClipboardLabel: 'Restore clipboard after insert',
restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.',
allowNonTsfFallbackLabel: 'Allow non-TSF fallback',
+2
View File
@@ -274,6 +274,8 @@ export const zhCN = {
migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。',
capsuleLabel: '录音胶囊',
capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。',
muteDuringRecordingLabel: '录音时静音',
muteDuringRecordingDesc: '开始语音输入时临时静音系统输出,停止、取消或出错后恢复原来的静音状态,避免扬声器声音被麦克风收进去。',
restoreClipboardLabel: '插入后恢复剪贴板',
restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。',
allowNonTsfFallbackLabel: '允许非 TSF 兜底',
+2
View File
@@ -276,6 +276,8 @@ export const zhTW: typeof zhCN = {
migrationNoticeDesc: '如果你之前改過快捷鍵觸發方式,請在這裏手動確認一次。本次更新調整了快捷鍵方式的默認值與讀取邏輯;如果你更習慣按住說話,可以重新切回“按住說話”。',
capsuleLabel: '錄音膠囊',
capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。',
muteDuringRecordingLabel: '錄音時靜音',
muteDuringRecordingDesc: '開始語音輸入時臨時靜音系統輸出,停止、取消或出錯後恢復原來的靜音狀態,避免揚聲器聲音被麥克風收進去。',
restoreClipboardLabel: '插入後恢復剪貼板',
restoreClipboardDesc: '僅 Windows / Linux:粘貼成功後恢復你原來的剪貼板內容(默認開)。關掉就把聽寫文本留在剪貼板,模擬粘貼沒真正落地時可以手動 Ctrl+V 找回。詳見 issue #111。',
allowNonTsfFallbackLabel: '允許非 TSF 兜底',
+1
View File
@@ -45,6 +45,7 @@ const mockSettings: UserPreferences = {
enabledModes: ['raw', 'light', 'structured', 'formal'],
launchAtLogin: false,
showCapsule: true,
muteDuringRecording: false,
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
restoreClipboardAfterPaste: true,
+2
View File
@@ -110,6 +110,8 @@ export interface UserPreferences {
enabledModes: PolishMode[];
launchAtLogin: boolean;
showCapsule: boolean;
/** 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 */
muteDuringRecording: boolean;
activeAsrProvider: string;
activeLlmProvider: string;
/** 仅 Windows/Linux:粘贴成功后是否恢复用户原剪贴板。默认 true。详见 issue #111。 */
+8
View File
@@ -183,6 +183,8 @@ function RecordingSection() {
savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } });
const onShowCapsuleChange = (showCapsule: boolean) =>
savePrefs({ ...prefs, showCapsule });
const onMuteDuringRecordingChange = (muteDuringRecording: boolean) =>
savePrefs({ ...prefs, muteDuringRecording });
const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) =>
savePrefs({ ...prefs, restoreClipboardAfterPaste });
const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) =>
@@ -258,6 +260,12 @@ function RecordingSection() {
<SettingRow label={t('settings.recording.capsuleLabel')} desc={t('settings.recording.capsuleDesc')}>
<Toggle on={prefs.showCapsule} onToggle={onShowCapsuleChange} />
</SettingRow>
<SettingRow
label={t('settings.recording.muteDuringRecordingLabel')}
desc={t('settings.recording.muteDuringRecordingDesc')}
>
<Toggle on={prefs.muteDuringRecording} onToggle={onMuteDuringRecordingChange} />
</SettingRow>
<SettingRow
label={t('settings.recording.restoreClipboardLabel')}
desc={t('settings.recording.restoreClipboardDesc')}