录音时区分「麦克风静音」与「声音偏小」,默认麦克风被静音显示红色高警

- 新增查询默认采集设备静音状态的命令 get_mic_mute_state(Windows Core Audio,查 eCapture 端点 GetMute),录音开始即时查询
- 默认麦克风被系统静音 → 悬浮窗即时红色高警「麦克风已被静音」;此时只在胶囊里居中显示红字、不画平波形,避免撑破圆角
- 基于音频信号的两档提醒:完全无信号(峰值≈0)→「未检测到声音」,有声音但 RMS 偏低 →「请靠近麦克风」
- 悬浮窗 warning 支持 error(红)/warn(琥珀) 两种级别;有警告时隐藏计时数字以免撑宽胶囊
- 检测到真实说话即清除提醒;选了特定麦克风时静音查询暂退回信号检测(默认设备可靠支持)
- 抽出 classifyMicLevel 纯函数并补单元测试;CHANGELOG / 关于页「本次更新」归入 0.1.3
This commit is contained in:
crosswk committed 2026-07-29 16:05:40 +08:00
1 parent 7d5eade190
commit 340a9805be
9 files changed
+439 -184

No files matched your search

+3
View File
@@ -20,6 +20,9 @@
- 修复数字规范化把口语里的「一点」(表示"稍微、一点点")误当成时间「1 点」的问题:如「调宽一点」不再被改成「调宽 1 点」。整点时间仅在有明确时间信号时才转换(如「下午一点」「一点钟」「十二点」)。
- 修复关闭「AI 整理」后,默认的服务器模式下诊断页仍把「AI 校对」显示为正常的问题:现在无论哪种模式,关闭后都会明确显示为「未开启(极速模式)」。
### 优化
- 优化录音时的麦克风提醒,区分「静音」与「声音小」:使用系统默认麦克风且它被系统静音时,录音悬浮窗会即时显示醒目的红色高警「麦克风已被静音」;完全采集不到声音(如选错设备、未授权)时显示「未检测到声音」;有声音但偏小时显示「请靠近麦克风」。此前只有单一的「请靠近麦克风」提示,麦克风被静音时也只提示声音小、不够直观。
---
## [0.1.2] - 2026-07-24
@@ -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<Mutex<Option<bool>>> = Lazy::new(|| Mutex::new(None));
@@ -123,3 +135,79 @@ pub fn restore_system_output() -> Result<bool, String> {
Ok(false)
}
}
// ─────────────────────────────────────────────────────────────────────────
// 麦克风静音状态查询
// ─────────────────────────────────────────────────────────────────────────
/// 查询采集设备的静音状态。
///
/// 当前实现只可靠支持「系统默认麦克风」(`label` 为 None/空):直接查默认采集端点的静音开关。
/// 若指定了特定设备名(`label` 非空),由于浏览器 deviceId 与系统设备不易可靠对应(按友好名
/// 匹配在 windows-rs 0.58 上读取属性受限,且同名设备会有歧义),这里返回 `matched=false`,
/// 交由前端基于音频信号的检测兜底,避免误报。后续可在此扩展按名字匹配。
#[cfg(windows)]
unsafe fn query_mic_mute(label: Option<&str>) -> Result<MicMuteState, String> {
// 指定了特定设备 → 暂不做匹配,交给信号检测
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<MicMuteState, String> {
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<String>) -> 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 }
}
}
+1
View File
@@ -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,
@@ -16,5 +16,6 @@ export const RELEASE_HIGHLIGHTS: ReleaseHighlights = {
'历史记录支持手工修改:可直接编辑整理后的文本,改完点其它地方即自动保存',
'修复用「右 Alt」等 Alt 键做「按住说话」时会呼出记事本等程序菜单、导致文本插不进去的问题',
'修复数字规范化把口语「一点」误当成时间「1 点」的问题,如「调宽一点」不再变成「调宽 1 点」',
'录音时麦克风提醒更清楚:默认麦克风被静音会即时红色高警「麦克风已被静音」,并区分「未检测到声音」与「请靠近麦克风」',
],
}
+112 -97
View File
@@ -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<HTMLDivElement | null>(null)
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | 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 ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</button>
@@ -325,100 +329,111 @@ export default function Overlay() {
/>
</div>
)}
<div
className="flex items-center rounded-full border px-4 py-2"
style={{
background: 'var(--overlay-bg)',
color: 'var(--overlay-text)',
borderColor: 'var(--overlay-border)',
}}
>
{state === 'waiting' && (
<div className="flex items-center gap-[3px]" style={{ height: '20px' }}>
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-[3px] w-[3px] rounded-full"
style={{
backgroundColor: 'var(--overlay-text-dim)',
animation: `dot-pulse 1s ease-in-out ${i * 0.15}s infinite`,
}}
/>
))}
</div>
)}
{state === 'listening' && (
<>
<div className="flex items-center gap-[2px]" style={{ height: '20px' }}>
{bars.map((height, index) => {
const color = getListeningBarColor(index, bars.length, theme)
return (
<div
key={index}
className="w-[2.5px] rounded-full"
style={{
backgroundColor: color,
boxShadow: 'none',
height: `${Math.min(18, Math.max(3, height))}px`,
opacity: 0.7 + (Math.min(18, height) / 18) * 0.3,
transition: 'height 50ms ease-out, opacity 50ms ease-out',
}}
/>
)
})}
<div
className="flex items-center rounded-full border px-4 py-2"
style={{
background: 'var(--overlay-bg)',
color: 'var(--overlay-text)',
borderColor: 'var(--overlay-border)',
}}
>
{state === 'waiting' && (
<div className="flex items-center gap-[3px]" style={{ height: '20px' }}>
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-[3px] w-[3px] rounded-full"
style={{
backgroundColor: 'var(--overlay-text-dim)',
animation: `dot-pulse 1s ease-in-out ${i * 0.15}s infinite`,
}}
/>
))}
</div>
{showDuration && (
<span
className="ml-1.5 min-w-[24px] text-right font-mono tabular-nums text-xs"
style={{ color: timerColor }}
)}
{state === 'listening' && (
warning && warningTone === 'error' ? (
// 静音高警:波形此时是平的、无意义,直接在胶囊里居中显示红字,
// 既更醒目、也避免波形+长文字撑破固定宽度把胶囊圆角裁掉。
<div
className="flex items-center whitespace-nowrap px-1 text-xs font-semibold text-red-500 animate-pulse"
style={{ height: '20px' }}
>
{timerText}
</span>
)}
{warning && (
<span className="ml-2 whitespace-nowrap text-xs text-amber-400 animate-pulse">
{warning}
</span>
)}
</>
)}
{state === 'thinking' && (
<div className="flex items-center gap-2">
<div className="relative h-1 w-12 overflow-hidden rounded-full bg-white/10">
<div
className="absolute left-0 top-0 h-full rounded-full"
style={{
backgroundColor: thinkingColor,
width: '100%',
transformOrigin: 'left',
animation: `progress-fill ${thinkingDuration}s cubic-bezier(0.4, 0, 0.2, 1) forwards`,
}}
/>
</div>
<span className="text-xs whitespace-nowrap" style={{ color: thinkingColor }}>处理中</span>
</div>
)}
{state === 'error' && (
<div className="flex items-center gap-2">
<span className="text-xs text-red-400">{errorMessage || '出错了'}</span>
</div>
)}
{state === 'toast' && (
<div className="flex items-center gap-2">
{toastTone === 'warn' ? (
<span className="whitespace-nowrap text-xs text-amber-400">{toastText}</span>
</div>
) : (
<span className="whitespace-nowrap text-xs" style={{ color: 'var(--overlay-text)' }}>
{toastText}
</span>
)}
</div>
)}
</div>
<>
<div className="flex items-center gap-[2px]" style={{ height: '20px' }}>
{bars.map((height, index) => {
const color = getListeningBarColor(index, bars.length, theme)
return (
<div
key={index}
className="w-[2.5px] rounded-full"
style={{
backgroundColor: color,
boxShadow: 'none',
height: `${Math.min(18, Math.max(3, height))}px`,
opacity: 0.7 + (Math.min(18, height) / 18) * 0.3,
transition: 'height 50ms ease-out, opacity 50ms ease-out',
}}
/>
)
})}
</div>
{showDuration && !warning && (
<span
className="ml-1.5 min-w-[24px] text-right font-mono tabular-nums text-xs"
style={{ color: timerColor }}
>
{timerText}
</span>
)}
{warning && (
<span className="ml-2 whitespace-nowrap text-xs text-amber-400 animate-pulse">
{warning}
</span>
)}
</>
)
)}
{state === 'thinking' && (
<div className="flex items-center gap-2">
<div className="relative h-1 w-12 overflow-hidden rounded-full bg-white/10">
<div
className="absolute left-0 top-0 h-full rounded-full"
style={{
backgroundColor: thinkingColor,
width: '100%',
transformOrigin: 'left',
animation: `progress-fill ${thinkingDuration}s cubic-bezier(0.4, 0, 0.2, 1) forwards`,
}}
/>
</div>
<span className="text-xs whitespace-nowrap" style={{ color: thinkingColor }}>处理中</span>
</div>
)}
{state === 'error' && (
<div className="flex items-center gap-2">
<span className="text-xs text-red-400">{errorMessage || '出错了'}</span>
</div>
)}
{state === 'toast' && (
<div className="flex items-center gap-2">
{toastTone === 'warn' ? (
<span className="whitespace-nowrap text-xs text-amber-400">{toastText}</span>
) : (
<span className="whitespace-nowrap text-xs" style={{ color: 'var(--overlay-text)' }}>
{toastText}
</span>
)}
</div>
)}
</div>
</div>
)}
</div>
+29 -2
View File
@@ -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(),
})
@@ -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<void> | null = null
/** 5-minute auto-stop timer for hands-free mode */
private handsFreeAutoStopId: ReturnType<typeof setTimeout> | 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 })
@@ -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()
+22
View File
@@ -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'