Skip to content

Commit dd996ba

Browse files
committed
fix(classroom): 修复音频捕获静音无回退与仅音频模式误截图
窗口源(方案A)在 Windows 上 getUserMedia 可能成功但返回纯静音轨,不抛错 故异常降级永不触发。新增 useAudioRecovery:静音检测驱动自动回退系统环回、 输出设备不匹配提示、设备变更自动重启捕获。 仅音频模式此前仍启动截图采集且帧 watchdog 会保底重启,导致'帧'计数持续 增长;现按 mode 跳过截图并清空重启回调。 其余:VAD 在 loopback 源跳过无意义的背景噪声校准(数字信号无底噪,不再 显示'正在校准音频阈值'),静音断句 1500ms->800ms 并新增 28s 强制分段以 兼容 GLM-ASR 段长上限;网关健康探测 HEAD->GET 消除 405 误报。
1 parent f7ddf96 commit dd996ba

10 files changed

Lines changed: 626 additions & 22 deletions

File tree

‎client/electron/audioCapture.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
* 5. PCM 数据块通过 IPC 回传主进程,添加单调时间戳后推送给消费者
1010
*
1111
* @ai-context: 系统音频捕获:渲染进程 getDisplayMedia 采集、主进程聚合分块。
12+
*
13+
* TODO(现场课程): 当前仅支持系统音频环回(捕获电脑播放的声音,适配网课场景)。
14+
* 后续「现场课程」需扩展麦克风输入源:listAudioSources 增加枚举
15+
* navigator.mediaDevices 的 audioinput 设备,getUserMedia 直接以 deviceId
16+
* 采集麦克风,并与环回源并列供用户选择(或双路混合)。
1217
*/
1318

1419
import { desktopCapturer, DesktopCapturerSource, BrowserWindow } from 'electron';
@@ -122,14 +127,18 @@ export class AudioCapture {
122127
* 开始音频捕获
123128
*
124129
* 1. 如果未指定 sourceId,自动选择第一个可用的系统音频源
125-
* 2. 向渲染进程发送启动指令(含 sourceId + 配置)
126-
* 3. 渲染进程负责 getUserMedia 和音频切片
130+
* 2. 方案A:若指定的是窗口源(window:xxx),优先尝试窗口级音频捕获,
131+
* 同时解析一个屏幕环回源作为降级候选(Windows 上窗口源音频支持
132+
* 依赖 Chromium 版本,失败时渲染进程自动回退环回源)
133+
* 3. 向渲染进程发送启动指令(含 sourceId + fallbackSourceId + 配置)
134+
* 4. 渲染进程负责 getUserMedia 和音频切片
127135
*/
128136
async start(win: BrowserWindow, sourceId?: string): Promise<void> {
129137
if (this.capturing || this.disposed) return;
130138

131139
// 解析音频源
132140
let resolvedSourceId = sourceId ?? null;
141+
let fallbackSourceId: string | null = null;
133142
if (!resolvedSourceId) {
134143
const sources = await listAudioSources();
135144
if (sources.length === 0) {
@@ -138,6 +147,15 @@ export class AudioCapture {
138147
}
139148
resolvedSourceId = sources[0].id;
140149
logger.info(`[AudioCapture] 自动选择音频源: ${sources[0].name} (${resolvedSourceId})`);
150+
} else if (resolvedSourceId.startsWith('window:')) {
151+
// 方案A:窗口源直采目标应用(如 B站客户端/浏览器)的音频,
152+
// 预解析屏幕环回源作为降级候选,防御窗口级捕获不受支持的环境
153+
const sources = await listAudioSources();
154+
fallbackSourceId = sources[0]?.id ?? null;
155+
logger.info(
156+
`[AudioCapture] 方案A 窗口源音频捕获: ${resolvedSourceId}, ` +
157+
`降级候选: ${fallbackSourceId ?? '无'}`,
158+
);
141159
}
142160

143161
this.capturing = true;
@@ -153,6 +171,7 @@ export class AudioCapture {
153171
if (!win.isDestroyed()) {
154172
win.webContents.send('audio_capture_do_start', {
155173
sourceId: resolvedSourceId,
174+
fallbackSourceId,
156175
options: this.options,
157176
});
158177
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* 课堂音频自动恢复 hook(静音诊断 / 窗口源回退 / 设备变更重启)
3+
*
4+
* @ai-context: 从 useClassroomAudio 拆出的独立恢复层。三条恢复路径:
5+
* ①窗口源静音回退——Windows 上窗口源(window:xxx)getUserMedia 可能"成功但
6+
* 给出纯静音轨"(不抛错,异常降级不会触发),SilenceTracker 检出连续静音后
7+
* 自动 stop→start 以环回源重启(每会话仅回退一次);
8+
* ②输出设备不匹配提示——已在环回源仍持续静音,说明视频声音输出到了
9+
* 非默认设备,提示用户核对默认输出设备;
10+
* ③设备变更重启——默认输出设备切换后环回仍绑定旧设备,devicechange 时
11+
* 自动重启重新绑定。
12+
* @ai-context: 仅依赖 refs 的稳定回调,重启经 restartingRef 互斥防止并发。
13+
*/
14+
import { useEffect, useRef, useCallback } from 'react';
15+
import type { CaptureMode, SessionStatus } from '@/lib/capture';
16+
import {
17+
computeChunkRms, SilenceTracker,
18+
getDefaultOutputDeviceLabel, subscribeDeviceChange,
19+
} from '@/lib/audio/outputDeviceMonitor';
20+
21+
/** 与 useSessionControl 启动参数保持一致的音频采集配置 */
22+
const AUDIO_START_OPTIONS = { chunkDurationMs: 5000, sampleRate: 16000, channels: 1 };
23+
/** 重启前等待渲染端管道清理完成的时间 */
24+
const RESTART_CLEANUP_DELAY_MS = 500;
25+
26+
interface UseAudioRecoveryOptions {
27+
status: SessionStatus;
28+
mode: CaptureMode;
29+
/** 方案A:会话选中窗口的源 ID(回退环回后置空) */
30+
audioSourceId?: string | null;
31+
onNotify: (type: 'warning' | 'error', message: string) => void;
32+
}
33+
34+
export function useAudioRecovery({ status, mode, audioSourceId, onNotify }: UseAudioRecoveryOptions) {
35+
const notifyRef = useRef(onNotify);
36+
notifyRef.current = onNotify;
37+
/** 当前生效的音频源(窗口源回退环回后变为 undefined) */
38+
const effectiveSourceRef = useRef<string | undefined>(audioSourceId ?? undefined);
39+
const silenceTrackerRef = useRef(new SilenceTracker());
40+
/** 本会话是否已执行过窗口源→环回回退 */
41+
const fellBackRef = useRef(false);
42+
const restartingRef = useRef(false);
43+
44+
const audioEnabled = status === 'capturing' && (mode === 'audio' || mode === 'mixed');
45+
46+
// 会话开始时重置恢复状态(audioSourceId 取会话启动瞬间的快照)
47+
useEffect(() => {
48+
if (status !== 'capturing') return;
49+
effectiveSourceRef.current = audioSourceId ?? undefined;
50+
fellBackRef.current = false;
51+
silenceTrackerRef.current.reset();
52+
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅会话开始时快照 audioSourceId
53+
}, [status]);
54+
55+
/** stop→start 重启音频捕获(互斥),成功返回 true */
56+
const restartCapture = useCallback(async (sourceId?: string): Promise<boolean> => {
57+
if (!window.electronAPI || restartingRef.current) return false;
58+
restartingRef.current = true;
59+
try {
60+
await window.electronAPI.invoke('audio_capture_stop');
61+
await new Promise((r) => setTimeout(r, RESTART_CLEANUP_DELAY_MS));
62+
const result = await window.electronAPI.invoke('audio_capture_start', {
63+
...AUDIO_START_OPTIONS, sourceId,
64+
}) as { success: boolean; error?: string };
65+
if (result.success) silenceTrackerRef.current.reset();
66+
else console.warn('[useAudioRecovery] 音频捕获重启失败:', result.error);
67+
return result.success;
68+
} catch (err) {
69+
console.error('[useAudioRecovery] 音频捕获重启异常:', err);
70+
return false;
71+
} finally {
72+
restartingRef.current = false;
73+
}
74+
}, []);
75+
76+
// 静音诊断:窗口源先自动回退环回;已是环回仍静音则提示核对输出设备
77+
useEffect(() => {
78+
if (!audioEnabled || !window.electronAPI) return;
79+
const off = window.electronAPI.on('audio_capture_chunk', (...args: unknown[]) => {
80+
const chunk = args[0] as { audioBuffer: ArrayBuffer };
81+
if (!silenceTrackerRef.current.push(computeChunkRms(chunk.audioBuffer))) return;
82+
83+
const src = effectiveSourceRef.current;
84+
if (src?.startsWith('window:') && !fellBackRef.current) {
85+
// 方案A 漏洞补偿:窗口源"成功但静音"不抛错,只能靠静音检测触发回退
86+
fellBackRef.current = true;
87+
effectiveSourceRef.current = undefined;
88+
console.warn('[useAudioRecovery] 窗口音频源持续静音,自动回退系统环回');
89+
void restartCapture(undefined).then((ok) => {
90+
if (ok) notifyRef.current('warning', '窗口音频源持续静音,已自动切换为系统环回捕获');
91+
});
92+
return;
93+
}
94+
void getDefaultOutputDeviceLabel().then((label) => {
95+
notifyRef.current('warning',
96+
`持续收到静音音频:请确认视频声音正在播放,且输出到系统默认设备${label ? `「${label}」` : ''}(音频捕获只能录到默认输出设备的声音)`);
97+
});
98+
});
99+
return off;
100+
}, [audioEnabled, restartCapture]);
101+
102+
// 设备变更自动重启:重新绑定新的默认输出设备
103+
useEffect(() => {
104+
if (!audioEnabled || !window.electronAPI) return;
105+
const unsubscribe = subscribeDeviceChange(() => {
106+
console.info('[useAudioRecovery] 检测到音频设备变更,自动重启音频捕获');
107+
void restartCapture(effectiveSourceRef.current).then((ok) => {
108+
if (ok) notifyRef.current('warning', '检测到音频输出设备变更,已自动重新绑定音频捕获');
109+
else notifyRef.current('error', '音频设备变更后重启捕获失败,请停止后重新开始采集');
110+
});
111+
});
112+
return unsubscribe;
113+
}, [audioEnabled, restartCapture]);
114+
}

‎client/src/features/classroom/hooks/useClassroomAudio.ts‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
* 累积到 chunkDurationMs 再整块发送,保证 VAD/ASR 拿到完整音频段。
1111
* @ai-context: 健康 watchdog 区分两种故障——"开始后 15s 从未收到音频块"
1212
* (管道未启动)与"曾正常但中断 >10s"(设备变更/被抢占),各自独立提示。
13+
* @ai-context: 静音诊断/窗口源回退/设备变更自动重启见 useAudioRecovery。
14+
*
15+
* TODO(现场课程): 当前音频源固定为系统环回/窗口源(网课场景)。现场课程需
16+
* 支持麦克风输入:getUserMedia({ audio: { deviceId } }) 直采 audioinput 设备,
17+
* 并启用 VADMarker 的背景噪声校准(见 vadMarker.ts 同名 TODO)。
1318
*/
1419
import { useState, useEffect, useRef } from 'react';
1520
import type { AudioChunkData, CaptureMode, SessionStatus, CaptureManager } from '@/lib/capture';
@@ -23,6 +28,8 @@ const CHUNK_GAP_TIMEOUT_MS = 10000;
2328

2429
interface AudioStartPayload {
2530
sourceId: string;
31+
/** 方案A 窗口源不可用时的环回降级候选(主进程解析) */
32+
fallbackSourceId?: string | null;
2633
options: { sampleRate: number; channels: number; chunkDurationMs: number };
2734
}
2835

@@ -39,6 +46,30 @@ interface UseClassroomAudioOptions {
3946
onNotify: (type: 'warning' | 'error', message: string) => void;
4047
}
4148

49+
/**
50+
* 以桌面源打开系统/窗口音频流。
51+
* 方案A 防御:窗口源(window:xxx)音频捕获在部分 Windows/Chromium 环境
52+
* 不受支持(NotFoundError/NotAllowedError),失败时自动回退环回降级候选。
53+
*/
54+
async function openDesktopAudioStream(payload: AudioStartPayload): Promise<MediaStream> {
55+
const request = (id: string) => navigator.mediaDevices.getUserMedia({
56+
audio: {
57+
chromeMediaSource: 'desktop',
58+
chromeMediaSourceId: id,
59+
} as MediaTrackConstraintSet,
60+
});
61+
try {
62+
return await request(payload.sourceId);
63+
} catch (err) {
64+
if (!payload.fallbackSourceId) throw err;
65+
console.warn(
66+
`[useClassroomCapture] 窗口源音频捕获失败(${payload.sourceId}),回退系统环回:`,
67+
err,
68+
);
69+
return request(payload.fallbackSourceId);
70+
}
71+
}
72+
4273
export function useClassroomAudio({ captureManager, status, mode, onNotify }: UseClassroomAudioOptions) {
4374
const [audioHealth, setAudioHealth] = useState<AudioHealth>({
4475
lastChunkTime: 0, chunkCount: 0, isHealthy: true,
@@ -121,12 +152,8 @@ export function useClassroomAudio({ captureManager, status, mode, onNotify }: Us
121152
const payload = args[0] as AudioStartPayload;
122153
(async () => {
123154
try {
124-
const stream = await navigator.mediaDevices.getUserMedia({
125-
audio: {
126-
chromeMediaSource: 'desktop',
127-
chromeMediaSourceId: payload.sourceId,
128-
} as MediaTrackConstraintSet,
129-
});
155+
// 方案A:优先窗口源直采,失败自动回退环回降级候选
156+
const stream = await openDesktopAudioStream(payload);
130157
const audioCtx = new AudioContext({ sampleRate: payload.options.sampleRate });
131158
// 关键修复:非用户手势调用栈中创建的 AudioContext 默认 suspended,
132159
// 必须显式 resume(),否则 onaudioprocess 永不触发(0 音频块)。

‎client/src/features/classroom/hooks/useClassroomCapture.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
} from '@/lib/capture';
2222
import { useClassroomEvents } from './useClassroomEvents';
2323
import { useClassroomAudio } from './useClassroomAudio';
24+
import { useAudioRecovery } from './useAudioRecovery';
2425
import { useWindowWatcher } from './useWindowWatcher';
2526
import { useSessionControl } from './useSessionControl';
2627
import { useClassroomAnalysis } from './useClassroomAnalysis';
@@ -59,7 +60,8 @@ export function useClassroomCapture() {
5960
const { selectedWindow } = windowWatcher;
6061

6162
useEffect(() => {
62-
if (!window.electronAPI || !selectedWindow) {
63+
// 仅音频模式无截图采集,清空重启回调防止帧 watchdog 误重启截图
64+
if (!window.electronAPI || !selectedWindow || mode === 'audio') {
6365
frameRestartRef.current = null;
6466
return;
6567
}
@@ -77,7 +79,7 @@ export function useClassroomCapture() {
7779
console.error('[useClassroomCapture] 保底重启失败:', err);
7880
}
7981
};
80-
}, [selectedWindow, config.screenshotInterval, status]);
82+
}, [selectedWindow, config.screenshotInterval, status, mode]);
8183

8284
// CaptureManager 单例
8385
const captureManager = useMemo(
@@ -104,6 +106,11 @@ export function useClassroomCapture() {
104106
captureManager, status, mode, onNotify: notify,
105107
});
106108

109+
// 音频自动恢复:静音诊断 / 窗口源回退环回 / 设备变更重启
110+
useAudioRecovery({
111+
status, mode, audioSourceId: selectedWindow?.id, onNotify: notify,
112+
});
113+
107114
const analysis = useClassroomAnalysis({
108115
language: config.language,
109116
smartBundle: events.smartBundle,

‎client/src/features/classroom/hooks/useSessionControl.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,9 @@ export function useSessionControl({
6767
const probeGateway = useCallback(async () => {
6868
try {
6969
const gatewayUrl = requireGatewayUrl();
70+
// GET 而非 HEAD:网关中间件对 HEAD 返回 405,会导致每次启动误报"网关不可用"
7071
const healthResp = await fetch(`${gatewayUrl}/health`, {
71-
method: 'HEAD',
72+
method: 'GET',
7273
signal: AbortSignal.timeout(5000),
7374
});
7475
if (!healthResp.ok) {
@@ -106,10 +107,15 @@ export function useSessionControl({
106107
}
107108

108109
const audioEnabled = mode === 'audio' || mode === 'mixed';
109-
await window.electronAPI.invoke('screen_capture_start', {
110-
windowId: selectedWindow.id,
111-
interval: config.screenshotInterval,
112-
});
110+
const visionEnabled = mode !== 'audio';
111+
// 仅音频模式不启动截图采集:无视觉需求时截图纯属资源浪费,
112+
// 且会让"帧"计数在音频模式下持续增长造成困惑
113+
if (visionEnabled) {
114+
await window.electronAPI.invoke('screen_capture_start', {
115+
windowId: selectedWindow.id,
116+
interval: config.screenshotInterval,
117+
});
118+
}
113119
await captureManager.startSession({
114120
windowId: selectedWindow.id,
115121
windowTitle: selectedWindow.title,
@@ -124,8 +130,11 @@ export function useSessionControl({
124130

125131
if (audioEnabled) {
126132
try {
133+
// 方案A:优先以选中窗口为音频源(直采 B站客户端/浏览器等目标应用声音),
134+
// 窗口级捕获不受支持时主进程会下发环回降级候选,由渲染端自动回退
127135
const audioResult = await window.electronAPI.invoke('audio_capture_start', {
128136
chunkDurationMs: 5000, sampleRate: 16000, channels: 1,
137+
sourceId: selectedWindow.id,
129138
}) as IPCAudioStartResult;
130139
if (!audioResult.success) {
131140
console.warn('[useClassroomCapture] Audio start failed:', audioResult.error);

0 commit comments

Comments
 (0)