From 340a9805be7332e273c642c19ba5b648522a7cfc Mon Sep 17 00:00:00 2001 From: crosswk Date: Wed, 29 Jul 2026 16:05:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=95=E9=9F=B3=E6=97=B6=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E3=80=8C=E9=BA=A6=E5=85=8B=E9=A3=8E=E9=9D=99=E9=9F=B3=E3=80=8D?= =?UTF-8?q?=E4=B8=8E=E3=80=8C=E5=A3=B0=E9=9F=B3=E5=81=8F=E5=B0=8F=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E9=BB=98=E8=AE=A4=E9=BA=A6=E5=85=8B=E9=A3=8E=E8=A2=AB?= =?UTF-8?q?=E9=9D=99=E9=9F=B3=E6=98=BE=E7=A4=BA=E7=BA=A2=E8=89=B2=E9=AB=98?= =?UTF-8?q?=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增查询默认采集设备静音状态的命令 get_mic_mute_state(Windows Core Audio,查 eCapture 端点 GetMute),录音开始即时查询 - 默认麦克风被系统静音 → 悬浮窗即时红色高警「麦克风已被静音」;此时只在胶囊里居中显示红字、不画平波形,避免撑破圆角 - 基于音频信号的两档提醒:完全无信号(峰值≈0)→「未检测到声音」,有声音但 RMS 偏低 →「请靠近麦克风」 - 悬浮窗 warning 支持 error(红)/warn(琥珀) 两种级别;有警告时隐藏计时数字以免撑宽胶囊 - 检测到真实说话即清除提醒;选了特定麦克风时静音查询暂退回信号检测(默认设备可靠支持) - 抽出 classifyMicLevel 纯函数并补单元测试;CHANGELOG / 关于页「本次更新」归入 0.1.3 --- CHANGELOG.md | 3 + client/src-tauri/src/commands/audio_mute.rs | 88 +++++++ client/src-tauri/src/main.rs | 1 + .../src/features/update/releaseHighlights.ts | 1 + client/src/overlay/Overlay.tsx | 209 ++++++++------- .../src/services/recorder/OverlayService.ts | 31 ++- .../services/recorder/RecorderOrchestrator.ts | 246 ++++++++++++------ .../recorder/__tests__/helpers.test.ts | 22 ++ client/src/services/recorder/helpers.ts | 22 ++ 9 files changed, 439 insertions(+), 184 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe48ae..c3f9fc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ - 修复数字规范化把口语里的「一点」(表示"稍微、一点点")误当成时间「1 点」的问题:如「调宽一点」不再被改成「调宽 1 点」。整点时间仅在有明确时间信号时才转换(如「下午一点」「一点钟」「十二点」)。 - 修复关闭「AI 整理」后,默认的服务器模式下诊断页仍把「AI 校对」显示为正常的问题:现在无论哪种模式,关闭后都会明确显示为「未开启(极速模式)」。 +### 优化 +- 优化录音时的麦克风提醒,区分「静音」与「声音小」:使用系统默认麦克风且它被系统静音时,录音悬浮窗会即时显示醒目的红色高警「麦克风已被静音」;完全采集不到声音(如选错设备、未授权)时显示「未检测到声音」;有声音但偏小时显示「请靠近麦克风」。此前只有单一的「请靠近麦克风」提示,麦克风被静音时也只提示声音小、不够直观。 + --- ## [0.1.2] - 2026-07-24 diff --git a/client/src-tauri/src/commands/audio_mute.rs b/client/src-tauri/src/commands/audio_mute.rs index 2c314d7..40aaee6 100644 --- a/client/src-tauri/src/commands/audio_mute.rs +++ b/client/src-tauri/src/commands/audio_mute.rs @@ -6,11 +6,23 @@ //! //! 用「保存/恢复原状态」而非「无脑取消静音」,避免录完后把用户原本的静音误开成有声。 +use serde::Serialize; + #[cfg(windows)] use once_cell::sync::Lazy; #[cfg(windows)] use std::sync::Mutex; +/// 查询麦克风静音状态的结果。 +/// - `matched`:是否成功定位到目标设备(默认设备总能定位;指定设备需按名字唯一匹配到)。 +/// 为 false 时前端不应据此判定,应退回基于音频信号的检测。 +/// - `muted`:目标设备在系统层面是否被静音。 +#[derive(Debug, Clone, Serialize)] +pub struct MicMuteState { + pub matched: bool, + pub muted: bool, +} + /// 保存的「静音前的原始状态」。None 表示当前未处于我们施加的静音中。 #[cfg(windows)] static SAVED_MUTE_STATE: Lazy>> = Lazy::new(|| Mutex::new(None)); @@ -123,3 +135,79 @@ pub fn restore_system_output() -> Result { Ok(false) } } + +// ───────────────────────────────────────────────────────────────────────── +// 麦克风静音状态查询 +// ───────────────────────────────────────────────────────────────────────── + +/// 查询采集设备的静音状态。 +/// +/// 当前实现只可靠支持「系统默认麦克风」(`label` 为 None/空):直接查默认采集端点的静音开关。 +/// 若指定了特定设备名(`label` 非空),由于浏览器 deviceId 与系统设备不易可靠对应(按友好名 +/// 匹配在 windows-rs 0.58 上读取属性受限,且同名设备会有歧义),这里返回 `matched=false`, +/// 交由前端基于音频信号的检测兜底,避免误报。后续可在此扩展按名字匹配。 +#[cfg(windows)] +unsafe fn query_mic_mute(label: Option<&str>) -> Result { + // 指定了特定设备 → 暂不做匹配,交给信号检测 + if label.map(str::trim).filter(|s| !s.is_empty()).is_some() { + return Ok(MicMuteState { matched: false, muted: false }); + } + + use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; + use windows::Win32::Media::Audio::{eCapture, eConsole, IMMDeviceEnumerator, MMDeviceEnumerator}; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, CLSCTX_INPROC_SERVER, + COINIT_APARTMENTTHREADED, + }; + + let co_init_result = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + let need_co_uninit = co_init_result.is_ok(); + + let result = (|| -> Result { + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_INPROC_SERVER) + .map_err(|e| format!("CoCreateInstance MMDeviceEnumerator: {}", e))?; + let device = enumerator + .GetDefaultAudioEndpoint(eCapture, eConsole) + .map_err(|e| format!("GetDefaultAudioEndpoint(eCapture): {}", e))?; + let endpoint: IAudioEndpointVolume = device + .Activate(CLSCTX_ALL, None) + .map_err(|e| format!("Activate IAudioEndpointVolume: {}", e))?; + let muted = endpoint + .GetMute() + .map_err(|e| format!("GetMute: {}", e))? + .as_bool(); + Ok(MicMuteState { matched: true, muted }) + })(); + + if need_co_uninit { + CoUninitialize(); + } + + result +} + +/// 查询麦克风是否被系统静音。 +/// `device_label`:前端选中麦克风的名字(enumerateDevices 的 label);传 None/空表示系统默认麦克风。 +/// 返回 `matched=false` 时表示无法可靠定位设备(如选了特定设备但同名多个),前端应退回信号检测。 +#[tauri::command] +pub fn get_mic_mute_state(device_label: Option) -> MicMuteState { + #[cfg(windows)] + unsafe { + match query_mic_mute(device_label.as_deref()) { + Ok(state) => state, + Err(e) => { + crate::commands::system::write_log_line(&format!( + "[RUST] [audio_mute] get_mic_mute_state failed: {}", + e + )); + MicMuteState { matched: false, muted: false } + } + } + } + #[cfg(not(windows))] + { + let _ = device_label; + MicMuteState { matched: false, muted: false } + } +} diff --git a/client/src-tauri/src/main.rs b/client/src-tauri/src/main.rs index 278113a..eec6a44 100644 --- a/client/src-tauri/src/main.rs +++ b/client/src-tauri/src/main.rs @@ -479,6 +479,7 @@ fn main() { // Audio output mute (录音期间静音系统输出,防回采) commands::audio_mute::mute_system_output, commands::audio_mute::restore_system_output, + commands::audio_mute::get_mic_mute_state, // Shortcuts commands::shortcuts::shortcuts_changed, commands::shortcuts::test_shortcut, diff --git a/client/src/features/update/releaseHighlights.ts b/client/src/features/update/releaseHighlights.ts index 9f4389c..cf38527 100644 --- a/client/src/features/update/releaseHighlights.ts +++ b/client/src/features/update/releaseHighlights.ts @@ -16,5 +16,6 @@ export const RELEASE_HIGHLIGHTS: ReleaseHighlights = { '历史记录支持手工修改:可直接编辑整理后的文本,改完点其它地方即自动保存', '修复用「右 Alt」等 Alt 键做「按住说话」时会呼出记事本等程序菜单、导致文本插不进去的问题', '修复数字规范化把口语「一点」误当成时间「1 点」的问题,如「调宽一点」不再变成「调宽 1 点」', + '录音时麦克风提醒更清楚:默认麦克风被静音会即时红色高警「麦克风已被静音」,并区分「未检测到声音」与「请靠近麦克风」', ], } diff --git a/client/src/overlay/Overlay.tsx b/client/src/overlay/Overlay.tsx index f9197a8..c67fe39 100644 --- a/client/src/overlay/Overlay.tsx +++ b/client/src/overlay/Overlay.tsx @@ -17,6 +17,8 @@ interface OverlayPayload { fallbackReason?: string errorMessage?: string warning?: string + /** warning 的严重级别:warn=琥珀(声音小/未检测到),error=红色高警(麦克风已被静音) */ + warningTone?: 'warn' | 'error' toastText?: string /** toast 的语气:info=中性(如切换预设),warn=琥珀色+图标(如未检测到声音) */ toastTone?: 'info' | 'warn' @@ -84,6 +86,7 @@ export default function Overlay() { const [copied, setCopied] = useState(false) const [thinkingDuration, setThinkingDuration] = useState(0) const [warning, setWarning] = useState('') + const [warningTone, setWarningTone] = useState<'warn' | 'error'>('warn') const rootRef = useRef(null) const hideTimerRef = useRef | null>(null) const elapsedSecRef = useRef(0) @@ -141,11 +144,13 @@ export default function Overlay() { if (typeof payload.toastText === 'string') setToastText(payload.toastText) if (payload.toastTone === 'info' || payload.toastTone === 'warn') setToastTone(payload.toastTone) if (typeof payload.warning === 'string') setWarning(payload.warning) + if (payload.warningTone === 'warn' || payload.warningTone === 'error') setWarningTone(payload.warningTone) if (typeof payload.streamingText === 'string') setStreamingText(payload.streamingText) if (typeof payload.streaming === 'boolean') setStreamingOn(payload.streaming) if (payload.state === 'waiting') { setElapsedSec(0) setWarning('') + setWarningTone('warn') setStreamingText('') setStreamingOn(false) setBars((prev) => Array(prev.length).fill(3)) @@ -185,7 +190,7 @@ export default function Overlay() { display: style?.display ?? 'missing', visibility: style?.visibility ?? 'missing', opacity: style?.opacity ?? 'missing', - }).catch(() => {}) + }).catch(() => { }) }) }) } @@ -197,7 +202,7 @@ export default function Overlay() { return } removeOverlayListener = unlisten - void bridge.overlayReady().catch(() => {}) + void bridge.overlayReady().catch(() => { }) }) return () => { @@ -263,11 +268,10 @@ export default function Overlay() { type="button" onClick={handleCopyFallback} title={copied ? '已复制' : '复制文本'} - className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border transition-colors ${ - copied - ? 'border-emerald-400/40 bg-emerald-500/15 text-emerald-200' - : 'border-white/10 bg-white/10 text-white/90 hover:bg-white/20' - }`} + className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border transition-colors ${copied + ? 'border-emerald-400/40 bg-emerald-500/15 text-emerald-200' + : 'border-white/10 bg-white/10 text-white/90 hover:bg-white/20' + }`} > {copied ? : } @@ -325,100 +329,111 @@ export default function Overlay() { /> )} -
- {state === 'waiting' && ( -
- {[0, 1, 2].map((i) => ( -
- ))} -
- )} - - {state === 'listening' && ( - <> -
- {bars.map((height, index) => { - const color = getListeningBarColor(index, bars.length, theme) - return ( -
- ) - })} +
+ {state === 'waiting' && ( +
+ {[0, 1, 2].map((i) => ( +
+ ))}
- {showDuration && ( - - {timerText} - - )} - {warning && ( - {warning} - - )} - - )} - - {state === 'thinking' && ( -
-
-
-
- 处理中 -
- )} - - {state === 'error' && ( -
- {errorMessage || '出错了'} -
- )} - - {state === 'toast' && ( -
- {toastTone === 'warn' ? ( - {toastText} +
) : ( - - {toastText} - - )} -
- )} -
+ <> +
+ {bars.map((height, index) => { + const color = getListeningBarColor(index, bars.length, theme) + return ( +
+ ) + })} +
+ {showDuration && !warning && ( + + {timerText} + + )} + {warning && ( + + {warning} + + )} + + ) + )} + + {state === 'thinking' && ( +
+
+
+
+ 处理中 +
+ )} + + {state === 'error' && ( +
+ {errorMessage || '出错了'} +
+ )} + + {state === 'toast' && ( +
+ {toastTone === 'warn' ? ( + {toastText} + ) : ( + + {toastText} + + )} +
+ )} +
)}
diff --git a/client/src/services/recorder/OverlayService.ts b/client/src/services/recorder/OverlayService.ts index f4aafd8..d01a49f 100644 --- a/client/src/services/recorder/OverlayService.ts +++ b/client/src/services/recorder/OverlayService.ts @@ -35,7 +35,7 @@ export class OverlayService { /** 本次录音是否开启流式实时显示:为真则从录音一开始就显示气泡(占位),中途不再缩放窗口 */ private streamingActive = false - constructor(private readonly getElapsedSec: () => number) {} + constructor(private readonly getElapsedSec: () => number) { } async refreshSettings() { this.theme = normalizeTheme(await getSetting('overlayWaveTheme', 'black-rainbow')) @@ -170,17 +170,43 @@ export class OverlayService { void bridge.updateOverlay({ state: 'listening', warning: this.activeWarning, + warningTone: 'warn', elapsedSec: clampSec(this.getElapsedSec()), ...this.getCommonPayload(), }) } - /** Show low volume warning on the overlay */ + /** Show low volume warning on the overlay(有声音但偏低:请靠近麦克风,琥珀色) */ showLowVolumeWarning() { if (this.activeWarning) return void bridge.updateOverlay({ state: 'listening', warning: '请靠近麦克风', + warningTone: 'warn', + elapsedSec: clampSec(this.getElapsedSec()), + ...this.getCommonPayload(), + }) + } + + /** Show "no signal detected" warning(几乎无信号但未能确认被静音:选错设备/未授权等,琥珀色) */ + showNoSignalWarning() { + if (this.activeWarning) return + void bridge.updateOverlay({ + state: 'listening', + warning: '未检测到声音', + warningTone: 'warn', + elapsedSec: clampSec(this.getElapsedSec()), + ...this.getCommonPayload(), + }) + } + + /** Show "mic is muted" high-alert(系统层面确认被静音:红色高警) */ + showMicMutedAlert() { + if (this.activeWarning) return + void bridge.updateOverlay({ + state: 'listening', + warning: '麦克风已被静音', + warningTone: 'error', elapsedSec: clampSec(this.getElapsedSec()), ...this.getCommonPayload(), }) @@ -192,6 +218,7 @@ export class OverlayService { void bridge.updateOverlay({ state: 'listening', warning: '', + warningTone: 'warn', elapsedSec: clampSec(this.getElapsedSec()), ...this.getCommonPayload(), }) diff --git a/client/src/services/recorder/RecorderOrchestrator.ts b/client/src/services/recorder/RecorderOrchestrator.ts index 7d17782..f5babbe 100644 --- a/client/src/services/recorder/RecorderOrchestrator.ts +++ b/client/src/services/recorder/RecorderOrchestrator.ts @@ -24,6 +24,7 @@ import { normalizeCustomThemeActive, normalizeCustomThemes, } from '../hotwords/model' +import { invoke } from '@tauri-apps/api/core' import { createWaveformBarState, computeBarsFromPCM, resetWaveformBarState } from '../waveform' import { elapsedSecFromPerf } from '../timeModel' import { @@ -51,6 +52,8 @@ import { buildStatsAppId as _buildStatsAppId, isModifierPTTSetting as _isModifierPTTSetting, computeProcessingTimeoutMs as _computeProcessingTimeoutMs, + classifyMicLevel, + type MicLevel, } from './helpers' type StateTransition = @@ -147,16 +150,23 @@ export class RecorderOrchestrator { private captureReadyPromise: Promise | null = null /** 5-minute auto-stop timer for hands-free mode */ private handsFreeAutoStopId: ReturnType | null = null - /** Low volume detection: consecutive silent samples count */ + /** 连续「非正常音量」(静音或偏低)的采样数 */ private consecutiveSilentSamples = 0 - /** 连续正常音量的采样数——用于「持续说话一段时间才清除低音量警告」的迟滞判断,避免瞬时小声音把警告瞬间清掉造成闪烁 */ + /** 当前这段安静里是否出现过「有声音但偏低」的帧(峰值不为零)。 + * 用于区分「完全无信号(可能被静音/选错设备)」与「有声音但太小」—— + * 只有整段安静里从没出现过任何信号,才判定为「未检测到声音」。 */ + private quietRunSawSignal = false + /** 连续正常音量的采样数——用于「持续说话一段时间才清除警告」的迟滞判断,避免瞬时小声音把警告瞬间清掉造成闪烁 */ private consecutiveVoicedSamples = 0 - /** 低音量警告当前是否正在显示 */ - private lowVolumeWarningShown = false - /** 本次录音是否已经听到过一次正常音量的说话。一旦听到过,说明麦克风正常、距离合适, - * 后续静音只是正常停顿思考,不再弹「请靠近麦克风」,避免催促感/压迫感。 */ + /** 悬浮窗当前正在显示的音量类警告 */ + private currentVolumeWarning: 'none' | 'muted' | 'low' = 'none' + /** 本次录音是否已经听到过一次正常音量的说话。一旦听到过,说明麦克风正常, + * 后续安静只是正常停顿思考——只给温和「请靠近麦克风」,绝不再报「未检测到声音」造成惊吓。 */ private hasDetectedVoiceThisSession = false private lastLowVolumeWarnAt = 0 + /** 系统层面确认麦克风被静音(录音开始时查询得到)。为真时悬浮窗显示红色高警, + * 并抑制基于信号的琥珀提醒,直到检测到真实说话(用户中途取消了静音)。 */ + private osMicMuted = false /** Audio stats tracking */ private audioStatsRmsSum = 0 @@ -362,7 +372,7 @@ export class RecorderOrchestrator { this.clearProcessingTimeout() this.overlayService.dispose() stopInsertionTargetTracking() - void stopCapture().catch(() => {}) + void stopCapture().catch(() => { }) this.provider.disconnect() } @@ -556,10 +566,12 @@ export class RecorderOrchestrator { this.currentPromptResolution = null this.cachedProbeResult = null this.consecutiveSilentSamples = 0 + this.quietRunSawSignal = false this.consecutiveVoicedSamples = 0 - this.lowVolumeWarningShown = false + this.currentVolumeWarning = 'none' this.hasDetectedVoiceThisSession = false this.lastLowVolumeWarnAt = 0 + this.osMicMuted = false clearCapturedInsertionTarget() this.recordStartPerf = 0 if (!options?.preserveLateFinalContext) { @@ -683,7 +695,7 @@ export class RecorderOrchestrator { // 停止音频采集 if (this.state === 'recording') { this.overlayService.stopListeningTicker() - void stopCapture().catch(() => {}) + void stopCapture().catch(() => { }) } // 保存音频到历史记录(即使识别失败,也保留音频以便重新识别) @@ -718,7 +730,7 @@ export class RecorderOrchestrator { }) void bridge.emit('history-updated') } - void saveErrorHistory().catch(() => {}) + void saveErrorHistory().catch(() => { }) } this.resetToIdle() @@ -984,10 +996,12 @@ export class RecorderOrchestrator { this.audioStatsSilentFrames = 0 this.audioStatsTotalFrames = 0 this.consecutiveSilentSamples = 0 + this.quietRunSawSignal = false this.consecutiveVoicedSamples = 0 - this.lowVolumeWarningShown = false + this.currentVolumeWarning = 'none' this.hasDetectedVoiceThisSession = false this.lastLowVolumeWarnAt = 0 + this.osMicMuted = false resetWaveformBarState(this.overlayWaveState, this.overlayService.getBarCount(), 3) // Hands-free mode: arm a 5-minute auto-stop timer @@ -1011,22 +1025,22 @@ export class RecorderOrchestrator { const promptOpts = this.currentPromptResolution ? { - systemPrompt: this.cachedAiEnabled ? this.currentPromptResolution.systemPrompt : undefined, - disableAi: !this.cachedAiEnabled, - clientMeta: this.cachedClientRuntimeInfo, - appContext: activeAppContext, - hotwords: this.cachedHotwords.length > 0 ? this.cachedHotwords : undefined, - language: this.cachedLanguage || undefined, - streamingDisplay: this.cachedStreamingDisplay, - } + systemPrompt: this.cachedAiEnabled ? this.currentPromptResolution.systemPrompt : undefined, + disableAi: !this.cachedAiEnabled, + clientMeta: this.cachedClientRuntimeInfo, + appContext: activeAppContext, + hotwords: this.cachedHotwords.length > 0 ? this.cachedHotwords : undefined, + language: this.cachedLanguage || undefined, + streamingDisplay: this.cachedStreamingDisplay, + } : { - disableAi: !this.cachedAiEnabled, - clientMeta: this.cachedClientRuntimeInfo, - appContext: activeAppContext, - hotwords: this.cachedHotwords.length > 0 ? this.cachedHotwords : undefined, - language: this.cachedLanguage || undefined, - streamingDisplay: this.cachedStreamingDisplay, - } + disableAi: !this.cachedAiEnabled, + clientMeta: this.cachedClientRuntimeInfo, + appContext: activeAppContext, + hotwords: this.cachedHotwords.length > 0 ? this.cachedHotwords : undefined, + language: this.cachedLanguage || undefined, + streamingDisplay: this.cachedStreamingDisplay, + } // Wrap the async setup so stopRecording can wait for it let resolveCaptureReady: () => void @@ -1051,67 +1065,38 @@ export class RecorderOrchestrator { }, undefined, (pcmFrame) => { - this.audioSentSamples += pcmFrame.length - const bars = computeBarsFromPCM(pcmFrame, this.overlayWaveState, { - barCount: this.overlayService.getBarCount(), - minHeight: 3, - maxHeight: 18, - }) - this.overlayService.pushListeningBars(bars) + this.audioSentSamples += pcmFrame.length + const bars = computeBarsFromPCM(pcmFrame, this.overlayWaveState, { + barCount: this.overlayService.getBarCount(), + minHeight: 3, + maxHeight: 18, + }) + this.overlayService.pushListeningBars(bars) - // Low volume detection: accumulate silent samples at 16kHz - // 3 seconds = 48000 samples, warn every 5 seconds = 80000 samples - let sum = 0 - for (let i = 0; i < pcmFrame.length; i++) { - sum += pcmFrame[i] * pcmFrame[i] - } - const rms = Math.sqrt(sum / pcmFrame.length) / 32768 - - // Audio stats tracking - this.audioStatsTotalFrames++ - this.audioStatsRmsSum += rms - if (rms > this.audioStatsPeakRms) this.audioStatsPeakRms = rms - // Track peak amplitude (max absolute sample value / 32768) for backend silent detection - for (let j = 0; j < pcmFrame.length; j++) { - const amp = Math.abs(pcmFrame[j]) / 32768 - if (amp > this.audioStatsPeakAmplitude) this.audioStatsPeakAmplitude = amp - } - if (rms < RecorderOrchestrator.SILENCE_RMS_THRESHOLD) this.audioStatsSilentFrames++ - - if (rms < 0.0003) { - this.consecutiveSilentSamples += pcmFrame.length - this.consecutiveVoicedSamples = 0 - // 两级静音阈值,避免过度提醒又不漏真问题: - // - 还没听到过说话(可能设备选错/离太远/静音):静音 2s 就提醒(最有价值)。 - // - 已经听到过说话(设备正常):正常停顿思考不该催,静音 5s 才提醒。 - // 两种情况都每 5s 重弹一次。 - const FIRST_WARN = this.hasDetectedVoiceThisSession ? 80000 : 32000 // 5s / 2s @16kHz - const REWARN_MS = 5000 - if (this.consecutiveSilentSamples >= FIRST_WARN) { - const now = Date.now() - if (now - this.lastLowVolumeWarnAt >= REWARN_MS) { - this.lastLowVolumeWarnAt = now - this.lowVolumeWarningShown = true - addRuntimeEvent('warn', 'recorder', '持续低音量,可能未检测到声音') - this.overlayService.showLowVolumeWarning() - } + // 计算本帧 RMS 与峰值幅度(均归一化到 0..1)。峰值用于区分「完全无信号(可能被静音/ + // 选错设备)」与「有声音但偏低」:静音流是一串 0(峰值≈0),偏低则有波动只是幅度小。 + let sum = 0 + let framePeakRaw = 0 + for (let i = 0; i < pcmFrame.length; i++) { + const s = pcmFrame[i] + sum += s * s + const amp = s < 0 ? -s : s + if (amp > framePeakRaw) framePeakRaw = amp } - } else { - // 需连续 ~0.5s(8000 采样 @16kHz)正常音量才认定“已恢复”,再清警告 + 重置静音累计。 - // 单帧/瞬时小声音不会清掉警告,避免“一闪而逝”;用户真正开口约 0.5s 后自然消失。 - this.consecutiveVoicedSamples += pcmFrame.length - const CLEAR_VOICED = 8000 - if (this.consecutiveVoicedSamples >= CLEAR_VOICED) { - this.hasDetectedVoiceThisSession = true - if (this.lowVolumeWarningShown) { - this.overlayService.clearWarning() - this.lowVolumeWarningShown = false - } - this.consecutiveSilentSamples = 0 - } - } - }, - ), + const rms = Math.sqrt(sum / pcmFrame.length) / 32768 + const framePeak = framePeakRaw / 32768 + + // Audio stats tracking + this.audioStatsTotalFrames++ + this.audioStatsRmsSum += rms + if (rms > this.audioStatsPeakRms) this.audioStatsPeakRms = rms + if (framePeak > this.audioStatsPeakAmplitude) this.audioStatsPeakAmplitude = framePeak + if (rms < RecorderOrchestrator.SILENCE_RMS_THRESHOLD) this.audioStatsSilentFrames++ + + // 两档提醒:静音(未检测到声音)/ 偏低(请靠近麦克风) + this.updateVolumeWarning(classifyMicLevel(rms, framePeak), pcmFrame.length) + }, + ), ]) resolveCaptureReady!() @@ -1133,6 +1118,8 @@ export class RecorderOrchestrator { // 实时读取供应商/WorkspaceId(避免切换供应商后缓存过期,导致非实时模型也弹气泡)。 void this.applyStreamingActive() this.overlayService.startListeningTicker() + // 录音一开始就查一次麦克风是否被系统静音,被静音则悬浮窗即时红色高警(不阻塞录音) + void this.checkMicMuted() // 就绪提示音已触发,稍后再静音系统输出(避免把提示音一起静掉) this.scheduleSystemMuteIfEnabled() armHandsFreeTimer() @@ -1165,6 +1152,95 @@ export class RecorderOrchestrator { } } + /** + * 录音时的音量提醒状态机(两档): + * - muted(峰值≈0,几乎无信号):极可能麦克风被静音 / 选错设备 / 未授权 → 「未检测到声音」 + * - low (有声音但 RMS 偏低):距离远 / 说太小声 → 「请靠近麦克风」 + * - voiced(正常):连续约 0.5s 即认定恢复,清除提醒 + * + * 关键准确性约束:「未检测到声音」只在「从录音开始就没听到过正常说话,且这整段安静里 + * 从未出现过任何信号」时才报——避免把说话后的正常停顿思考误判成设备故障(那种情况只 + * 温和提示「请靠近麦克风」)。计时/迟滞沿用原逻辑:没听到过说话时更早提醒(2s), + * 听到过后放宽(5s);同档每 5s 重弹;连续正常音量 0.5s 才清警告,避免瞬时小声闪烁。 + */ + private updateVolumeWarning(level: MicLevel, sampleCount: number) { + const REWARN_MS = 5000 + const CLEAR_VOICED = 8000 // ~0.5s @16kHz 连续正常音量才清除 + const firstWarn = this.hasDetectedVoiceThisSession ? 80000 : 32000 // 5s / 2s @16kHz + + // 系统已确认被静音:保持红色高警,不让琥珀提醒覆盖它; + // 一旦收到真实说话(用户中途取消了静音)立即恢复正常。 + if (this.osMicMuted) { + if (level !== 'voiced') return + this.osMicMuted = false + this.overlayService.clearWarning() + this.currentVolumeWarning = 'none' + this.consecutiveVoicedSamples = 0 + this.consecutiveSilentSamples = 0 + this.quietRunSawSignal = false + this.hasDetectedVoiceThisSession = true + return + } + + if (level === 'voiced') { + this.consecutiveVoicedSamples += sampleCount + this.consecutiveSilentSamples = 0 + this.quietRunSawSignal = false + if (this.consecutiveVoicedSamples >= CLEAR_VOICED) { + this.hasDetectedVoiceThisSession = true + if (this.currentVolumeWarning !== 'none') { + this.overlayService.clearWarning() + this.currentVolumeWarning = 'none' + } + } + return + } + + // 非正常音量(muted / low):累计安静时长;本段安静里只要出现过任何信号就记下来 + this.consecutiveVoicedSamples = 0 + this.consecutiveSilentSamples += sampleCount + if (level === 'low') this.quietRunSawSignal = true + + if (this.consecutiveSilentSamples < firstWarn) return + + // 只有「从没听到过说话 且 这段安静里完全没有信号」才报「未检测到声音」,其余一律温和「请靠近麦克风」 + const kind: 'muted' | 'low' = + !this.hasDetectedVoiceThisSession && !this.quietRunSawSignal ? 'muted' : 'low' + + const now = Date.now() + // 切换到不同档位立即刷新文案;同档位按节流重弹,避免频繁催促 + if (kind === this.currentVolumeWarning && now - this.lastLowVolumeWarnAt < REWARN_MS) return + this.lastLowVolumeWarnAt = now + this.currentVolumeWarning = kind + if (kind === 'muted') { + addRuntimeEvent('warn', 'recorder', '未检测到麦克风声音(可能选错设备或未授权)') + this.overlayService.showNoSignalWarning() + } else { + addRuntimeEvent('warn', 'recorder', '麦克风音量偏低') + this.overlayService.showLowVolumeWarning() + } + } + + /** + * 录音开始时查询系统麦克风是否被静音;确认被静音则悬浮窗即时红色高警「麦克风已被静音」。 + * 仅在能可靠定位设备时才据此判定(系统默认设备,或按名字唯一匹配到选中设备), + * 否则(如选了特定设备但拿不到名字/同名多个)返回不判定,交给基于信号的琥珀提醒兜底。 + */ + private async checkMicMuted() { + try { + // 目前仅可靠支持系统默认麦克风的静音查询;选了特定设备时交给信号检测兜底(不硬报静音) + if (this.cachedMicId) return + const res = await invoke<{ matched: boolean; muted: boolean }>('get_mic_mute_state', { deviceLabel: null }) + // 查询是异步的,回来时可能已停止录音 / 已听到说话,则不再弹 + if (this.state !== 'recording' || this.hasDetectedVoiceThisSession) return + if (res.matched && res.muted) { + this.osMicMuted = true + addRuntimeEvent('warn', 'recorder', '麦克风已被系统静音') + this.overlayService.showMicMutedAlert() + } + } catch { /* 查询失败不影响录音,交给信号检测兜底 */ } + } + private async stopRecording() { if (this.state !== 'recording') { addRuntimeEvent('info', 'recorder', '停止录音请求已忽略', { state: this.state }) diff --git a/client/src/services/recorder/__tests__/helpers.test.ts b/client/src/services/recorder/__tests__/helpers.test.ts index 80dce50..d8edda8 100644 --- a/client/src/services/recorder/__tests__/helpers.test.ts +++ b/client/src/services/recorder/__tests__/helpers.test.ts @@ -4,8 +4,30 @@ import { buildStatsAppId, isModifierPTTSetting, computeProcessingTimeoutMs, + classifyMicLevel, + MIC_MUTE_PEAK_THRESHOLD, + MIC_LOW_RMS_THRESHOLD, } from '../helpers' +describe('classifyMicLevel', () => { + it('峰值≈0 判为 muted(无信号 / 可能被静音)', () => { + expect(classifyMicLevel(0, 0)).toBe('muted') + // 即使 RMS 因为某些原因不为 0,只要峰值低于阈值仍算无信号 + expect(classifyMicLevel(0.05, MIC_MUTE_PEAK_THRESHOLD - 0.0001)).toBe('muted') + }) + + it('有峰值但 RMS 偏低 判为 low(请靠近麦克风)', () => { + expect(classifyMicLevel(0.004, 0.05)).toBe('low') + // 峰值刚好达标、RMS 低于低音量阈值 + expect(classifyMicLevel(MIC_LOW_RMS_THRESHOLD - 0.0001, MIC_MUTE_PEAK_THRESHOLD)).toBe('low') + }) + + it('RMS 达到正常水平 判为 voiced', () => { + expect(classifyMicLevel(0.03, 0.2)).toBe('voiced') + expect(classifyMicLevel(MIC_LOW_RMS_THRESHOLD, 0.1)).toBe('voiced') + }) +}) + describe('summarizeAppContext', () => { it('null 返回 null', () => { expect(summarizeAppContext(null)).toBeNull() diff --git a/client/src/services/recorder/helpers.ts b/client/src/services/recorder/helpers.ts index ad94eed..c4acd26 100644 --- a/client/src/services/recorder/helpers.ts +++ b/client/src/services/recorder/helpers.ts @@ -37,6 +37,28 @@ export function buildStatsAppId( return String(promptAppId || '').trim() || 'unknown' } +/** 录音时的麦克风音量分级 */ +export type MicLevel = 'muted' | 'low' | 'voiced' + +/** 峰值幅度(0..1)低于此值视为「几乎无信号」——静音流是一串 0,峰值≈0; + * 真实环境即使很安静也会有零星峰值超过它,用峰值比只看 RMS 更能准确区分「被静音/选错设备」。 */ +export const MIC_MUTE_PEAK_THRESHOLD = 0.002 +/** 有信号(峰值达标)但 RMS(0..1)低于此值视为「声音偏低」(离得远 / 说太小声)。 */ +export const MIC_LOW_RMS_THRESHOLD = 0.008 + +/** + * 按本帧的 RMS 与峰值幅度分级: + * - muted :峰值≈0,几乎没有任何信号(极可能麦克风被静音 / 选错设备 / 未授权) + * - low :有波动但整体偏低(距离远 / 声音小) + * - voiced:正常音量 + * 两个入参都应为归一化到 0..1 的值(原始 int16 除以 32768)。 + */ +export function classifyMicLevel(rms: number, framePeak: number): MicLevel { + if (framePeak < MIC_MUTE_PEAK_THRESHOLD) return 'muted' + if (rms < MIC_LOW_RMS_THRESHOLD) return 'low' + return 'voiced' +} + /** 判断 PTT 设置是否为修饰键 */ export function isModifierPTTSetting(pttSetting?: string): boolean { return pttSetting === 'AltLeft'