Merge pull request #274 from sora520/feat/mute-during-recording
feat(recording): mute system output while recording
This commit is contained in:
11 files changed
+354
-12
No files matched your search
@@ -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) {}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! - commands: Tauri IPC surface
|
||||
|
||||
mod asr;
|
||||
mod audio_mute;
|
||||
mod commands;
|
||||
mod coordinator;
|
||||
mod hotkey;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -274,6 +274,8 @@ export const zhCN = {
|
||||
migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。',
|
||||
capsuleLabel: '录音胶囊',
|
||||
capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。',
|
||||
muteDuringRecordingLabel: '录音时静音',
|
||||
muteDuringRecordingDesc: '开始语音输入时临时静音系统输出,停止、取消或出错后恢复原来的静音状态,避免扬声器声音被麦克风收进去。',
|
||||
restoreClipboardLabel: '插入后恢复剪贴板',
|
||||
restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。',
|
||||
allowNonTsfFallbackLabel: '允许非 TSF 兜底',
|
||||
|
||||
@@ -276,6 +276,8 @@ export const zhTW: typeof zhCN = {
|
||||
migrationNoticeDesc: '如果你之前改過快捷鍵觸發方式,請在這裏手動確認一次。本次更新調整了快捷鍵方式的默認值與讀取邏輯;如果你更習慣按住說話,可以重新切回“按住說話”。',
|
||||
capsuleLabel: '錄音膠囊',
|
||||
capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。',
|
||||
muteDuringRecordingLabel: '錄音時靜音',
|
||||
muteDuringRecordingDesc: '開始語音輸入時臨時靜音系統輸出,停止、取消或出錯後恢復原來的靜音狀態,避免揚聲器聲音被麥克風收進去。',
|
||||
restoreClipboardLabel: '插入後恢復剪貼板',
|
||||
restoreClipboardDesc: '僅 Windows / Linux:粘貼成功後恢復你原來的剪貼板內容(默認開)。關掉就把聽寫文本留在剪貼板,模擬粘貼沒真正落地時可以手動 Ctrl+V 找回。詳見 issue #111。',
|
||||
allowNonTsfFallbackLabel: '允許非 TSF 兜底',
|
||||
|
||||
@@ -45,6 +45,7 @@ const mockSettings: UserPreferences = {
|
||||
enabledModes: ['raw', 'light', 'structured', 'formal'],
|
||||
launchAtLogin: false,
|
||||
showCapsule: true,
|
||||
muteDuringRecording: false,
|
||||
activeAsrProvider: 'volcengine',
|
||||
activeLlmProvider: 'ark',
|
||||
restoreClipboardAfterPaste: true,
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface UserPreferences {
|
||||
enabledModes: PolishMode[];
|
||||
launchAtLogin: boolean;
|
||||
showCapsule: boolean;
|
||||
/** 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 */
|
||||
muteDuringRecording: boolean;
|
||||
activeAsrProvider: string;
|
||||
activeLlmProvider: string;
|
||||
/** 仅 Windows/Linux:粘贴成功后是否恢复用户原剪贴板。默认 true。详见 issue #111。 */
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in new issue
Block a user