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