Skip to content

Commit 61d16e8

Browse files
committed
fix(classroom): 优化音频捕获回退与显示媒体处理
- 重构 useClassroomAudio/useAudioRecovery 音频恢复逻辑 - 新增 displayMediaHandler 处理显示媒体请求 - 调整 audioCapture 与主进程音频初始化
1 parent dd996ba commit 61d16e8

5 files changed

Lines changed: 125 additions & 68 deletions

File tree

‎client/electron/audioCapture.ts‎

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import { desktopCapturer, DesktopCapturerSource, BrowserWindow } from 'electron';
2020
import { logger } from './logger';
21+
import { setPreferredDisplaySource } from './displayMediaHandler';
2122

2223
// ================================================================
2324
// 类型定义
@@ -127,18 +128,15 @@ export class AudioCapture {
127128
* 开始音频捕获
128129
*
129130
* 1. 如果未指定 sourceId,自动选择第一个可用的系统音频源
130-
* 2. 方案A:若指定的是窗口源(window:xxx),优先尝试窗口级音频捕获,
131-
* 同时解析一个屏幕环回源作为降级候选(Windows 上窗口源音频支持
132-
* 依赖 Chromium 版本,失败时渲染进程自动回退环回源)
133-
* 3. 向渲染进程发送启动指令(含 sourceId + fallbackSourceId + 配置)
134-
* 4. 渲染进程负责 getUserMedia 和音频切片
131+
* 2. 将期望源登记到 displayMedia handler(渲染进程 getDisplayMedia 时生效)
132+
* 3. 向渲染进程发送启动指令(含 sourceId + 配置)
133+
* 4. 渲染进程负责 getDisplayMedia 与音频切片
135134
*/
136135
async start(win: BrowserWindow, sourceId?: string): Promise<void> {
137136
if (this.capturing || this.disposed) return;
138137

139138
// 解析音频源
140139
let resolvedSourceId = sourceId ?? null;
141-
let fallbackSourceId: string | null = null;
142140
if (!resolvedSourceId) {
143141
const sources = await listAudioSources();
144142
if (sources.length === 0) {
@@ -147,17 +145,12 @@ export class AudioCapture {
147145
}
148146
resolvedSourceId = sources[0].id;
149147
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-
);
159148
}
160149

150+
// 登记期望源:渲染进程随后调用 getDisplayMedia,主进程 handler 据此授权
151+
// 并附加 audio: 'loopback' 才能拿到真实系统音频(详见 displayMediaHandler.ts)
152+
setPreferredDisplaySource(resolvedSourceId);
153+
161154
this.capturing = true;
162155
this.boundWin = win;
163156

@@ -171,7 +164,6 @@ export class AudioCapture {
171164
if (!win.isDestroyed()) {
172165
win.webContents.send('audio_capture_do_start', {
173166
sourceId: resolvedSourceId,
174-
fallbackSourceId,
175167
options: this.options,
176168
});
177169
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Electron 系统音频环回请求处理器(getDisplayMedia 桥)
3+
*
4+
* @ai-context: Windows 系统音频捕获的唯一可靠路径。Electron 30+ / Chromium 中,
5+
* 渲染进程用 getUserMedia({ audio: { chromeMediaSource: 'desktop' } }) 单独请求
6+
* 音频轨已不返回真实音频——会拿到一条恒为数字零的静音轨(RMS 0.00,不抛错),
7+
* 这是"监听 0 句 / 时间轴全静默"的根因。正确做法是主进程注册
8+
* setDisplayMediaRequestHandler 并在 callback 中返回 audio: 'loopback',
9+
* 由渲染进程调用 getDisplayMedia({ video: true, audio: true }) 触发。
10+
* @ai-context: audio: 'loopback' 捕获系统输出混音(含本应用自身声音);
11+
* 若需静音本地播放改用 'loopbackWithMute'。handler 全局唯一,重复注册会覆盖。
12+
*
13+
* TODO(现场课程): 麦克风输入不走 displayMedia 路径,直接以 deviceId 调用
14+
* getUserMedia({ audio: { deviceId } }) 即可,无需本模块参与。
15+
*/
16+
17+
import { session, desktopCapturer } from 'electron';
18+
import { logger } from './logger';
19+
20+
/**
21+
* 期望捕获的视频源 ID(window:xxx / screen:xxx)。
22+
* 仅用于让 loopback 请求绑定到目标窗口所在屏幕;音频始终是系统混音。
23+
*/
24+
let preferredSourceId: string | null = null;
25+
26+
/** 设置本次采集期望的源 ID(由 AudioCapture.start 调用),null 表示用首个屏幕源 */
27+
export function setPreferredDisplaySource(sourceId: string | null): void {
28+
preferredSourceId = sourceId;
29+
}
30+
31+
/**
32+
* 注册 displayMedia 请求处理器(须在 app.whenReady 后调用一次)
33+
*
34+
* 渲染进程调用 navigator.mediaDevices.getDisplayMedia() 时进入此 handler,
35+
* 我们直接指定源并附加 loopback 音频,跳过系统选择器(无需用户二次确认)。
36+
*/
37+
export function registerDisplayMediaHandler(): void {
38+
session.defaultSession.setDisplayMediaRequestHandler(
39+
(_request, callback) => {
40+
void (async () => {
41+
try {
42+
const sources = await desktopCapturer.getSources({
43+
types: ['screen', 'window'],
44+
thumbnailSize: { width: 1, height: 1 },
45+
});
46+
if (sources.length === 0) {
47+
logger.error('[DisplayMedia] 无可用捕获源,拒绝请求');
48+
// 空对象表示拒绝授权(Electron 约定)
49+
callback({});
50+
return;
51+
}
52+
// 优先匹配期望源;未命中则退回首个屏幕源(屏幕源对 loopback 最稳定)
53+
const matched = preferredSourceId
54+
? sources.find((s) => s.id === preferredSourceId)
55+
: undefined;
56+
const screenSource = sources.find((s) => s.id.startsWith('screen:'));
57+
const chosen = matched ?? screenSource ?? sources[0];
58+
59+
logger.info(
60+
`[DisplayMedia] 授予捕获: source=${chosen.name} (${chosen.id}), audio=loopback`,
61+
);
62+
callback({ video: chosen, audio: 'loopback' });
63+
} catch (err) {
64+
logger.error('[DisplayMedia] 处理捕获请求失败:', err);
65+
callback({});
66+
}
67+
})();
68+
},
69+
// 使用应用内指定源而非 Windows 系统选择器,保持无感采集体验
70+
{ useSystemPicker: false },
71+
);
72+
logger.info('[DisplayMedia] setDisplayMediaRequestHandler 已注册(audio: loopback)');
73+
}

‎client/electron/main.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { resolveDbPath } from './db/storageConfig.js';
3333
import { registerMigrationHandlers } from './db/migration.js';
3434
import { loadEnvironment } from './envLoader.js';
3535
import { installCspPolicy } from './cspPolicy.js';
36+
import { registerDisplayMediaHandler } from './displayMediaHandler.js';
3637
import { registerDbIpcHandlers } from './db/dbIpcHandlers.js';
3738
import { registerStorageIpcHandlers } from './storageIpcHandlers.js';
3839
import { registerKeyframeScheme, registerKeyframeIpcHandlers } from './ipc/keyframeStorage.js';
@@ -123,6 +124,10 @@ if (!gotTheLock) {
123124
// SEC-005: CSP 安全策略注入(详见 cspPolicy.ts)
124125
installCspPolicy(isDevMode());
125126

127+
// 系统音频环回捕获授权(详见 displayMediaHandler.ts)
128+
// 必须在渲染进程发起 getDisplayMedia 之前注册,否则请求会被默认拒绝
129+
registerDisplayMediaHandler();
130+
126131
// 加载持久化的 AI 网关地址(在注册 handler 之前,确保 handler 可用正确的 URL)
127132
await loadPersistedGatewayUrl();
128133
logger.info(`[AI] Gateway URL resolved: ${gatewayUrl()}`);

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

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
/**
2-
* 课堂音频自动恢复 hook(静音诊断 / 窗口源回退 / 设备变更重启)
2+
* 课堂音频自动恢复 hook(静音诊断 / 设备变更重启)
33
*
4-
* @ai-context: 从 useClassroomAudio 拆出的独立恢复层。三条恢复路径:
5-
* ①窗口源静音回退——Windows 上窗口源(window:xxx)getUserMedia 可能"成功但
6-
* 给出纯静音轨"(不抛错,异常降级不会触发),SilenceTracker 检出连续静音后
7-
* 自动 stop→start 以环回源重启(每会话仅回退一次);
8-
* ②输出设备不匹配提示——已在环回源仍持续静音,说明视频声音输出到了
9-
* 非默认设备,提示用户核对默认输出设备;
10-
* ③设备变更重启——默认输出设备切换后环回仍绑定旧设备,devicechange 时
11-
* 自动重启重新绑定。
12-
* @ai-context: 仅依赖 refs 的稳定回调,重启经 restartingRef 互斥防止并发。
4+
* @ai-context: 从 useClassroomAudio 拆出的独立恢复层。两条恢复路径:
5+
* ①输出设备不匹配诊断——系统环回只录默认输出设备,视频声音若输出到其他
6+
* 设备(HDMI/蓝牙)会表现为"音频块正常但持续静音",检出后提示用户核对;
7+
* ②设备变更重启——默认输出设备切换后环回仍绑定旧设备,devicechange 时
8+
* 自动 stop→start 重新绑定。
9+
* @ai-context: 仅依赖 refs 的稳定回调,重启经 restartingRef 互斥防并发。
1310
*/
1411
import { useEffect, useRef, useCallback } from 'react';
1512
import type { CaptureMode, SessionStatus } from '@/lib/capture';
@@ -26,19 +23,16 @@ const RESTART_CLEANUP_DELAY_MS = 500;
2623
interface UseAudioRecoveryOptions {
2724
status: SessionStatus;
2825
mode: CaptureMode;
29-
/** 方案A:会话选中窗口的源 ID(回退环回后置空) */
26+
/** 会话选中窗口的源 ID,重启时沿用同一源 */
3027
audioSourceId?: string | null;
3128
onNotify: (type: 'warning' | 'error', message: string) => void;
3229
}
3330

3431
export function useAudioRecovery({ status, mode, audioSourceId, onNotify }: UseAudioRecoveryOptions) {
3532
const notifyRef = useRef(onNotify);
3633
notifyRef.current = onNotify;
37-
/** 当前生效的音频源(窗口源回退环回后变为 undefined) */
3834
const effectiveSourceRef = useRef<string | undefined>(audioSourceId ?? undefined);
3935
const silenceTrackerRef = useRef(new SilenceTracker());
40-
/** 本会话是否已执行过窗口源→环回回退 */
41-
const fellBackRef = useRef(false);
4236
const restartingRef = useRef(false);
4337

4438
const audioEnabled = status === 'capturing' && (mode === 'audio' || mode === 'mixed');
@@ -47,7 +41,6 @@ export function useAudioRecovery({ status, mode, audioSourceId, onNotify }: UseA
4741
useEffect(() => {
4842
if (status !== 'capturing') return;
4943
effectiveSourceRef.current = audioSourceId ?? undefined;
50-
fellBackRef.current = false;
5144
silenceTrackerRef.current.reset();
5245
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅会话开始时快照 audioSourceId
5346
}, [status]);
@@ -73,31 +66,19 @@ export function useAudioRecovery({ status, mode, audioSourceId, onNotify }: UseA
7366
}
7467
}, []);
7568

76-
// 静音诊断:窗口源先自动回退环回;已是环回仍静音则提示核对输出设备
69+
// 静音诊断:音频块正常但持续无声 → 提示核对系统默认输出设备
7770
useEffect(() => {
7871
if (!audioEnabled || !window.electronAPI) return;
7972
const off = window.electronAPI.on('audio_capture_chunk', (...args: unknown[]) => {
8073
const chunk = args[0] as { audioBuffer: ArrayBuffer };
8174
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-
}
9475
void getDefaultOutputDeviceLabel().then((label) => {
9576
notifyRef.current('warning',
96-
`持续收到静音音频:请确认视频声音正在播放,且输出到系统默认设备${label ? `「${label}」` : ''}(音频捕获只能录到默认输出设备的声音)`);
77+
`持续收到静音音频:请确认视频声音正在播放,且输出到系统默认设备${label ? `「${label}」` : ''}(系统音频捕获只能录到默认输出设备的声音)`);
9778
});
9879
});
9980
return off;
100-
}, [audioEnabled, restartCapture]);
81+
}, [audioEnabled]);
10182

10283
// 设备变更自动重启:重新绑定新的默认输出设备
10384
useEffect(() => {

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

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ const CHUNK_GAP_TIMEOUT_MS = 10000;
2828

2929
interface AudioStartPayload {
3030
sourceId: string;
31-
/** 方案A 窗口源不可用时的环回降级候选(主进程解析) */
32-
fallbackSourceId?: string | null;
3331
options: { sampleRate: number; channels: number; chunkDurationMs: number };
3432
}
3533

@@ -47,27 +45,35 @@ interface UseClassroomAudioOptions {
4745
}
4846

4947
/**
50-
* 以桌面源打开系统/窗口音频流。
51-
* 方案A 防御:窗口源(window:xxx)音频捕获在部分 Windows/Chromium 环境
52-
* 不受支持(NotFoundError/NotAllowedError),失败时自动回退环回降级候选。
48+
* 打开系统音频环回流。
49+
*
50+
* 必须走 getDisplayMedia 且同时请求 video —— 主进程的
51+
* setDisplayMediaRequestHandler 会在授权时附加 audio: 'loopback',
52+
* 这是 Windows 下拿到真实系统音频的唯一可靠路径。
53+
* 旧的 getUserMedia({ audio: { chromeMediaSource: 'desktop' } }) 在
54+
* Electron 30+ 会静默返回恒为数字零的静音轨(RMS 0.00),不可再用。
55+
* 拿到流后立即停掉视频轨,仅保留音频,避免屏幕捕获的额外开销。
5356
*/
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,
57+
async function openDesktopAudioStream(): Promise<MediaStream> {
58+
const stream = await navigator.mediaDevices.getDisplayMedia({
59+
video: true,
60+
audio: true,
6061
});
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);
62+
const audioTracks = stream.getAudioTracks();
63+
if (audioTracks.length === 0) {
64+
stream.getTracks().forEach((t) => t.stop());
65+
throw new Error('系统音频轨缺失:displayMedia 未返回 loopback 音频');
7066
}
67+
// 视频轨仅用于触发 loopback 授权,立即停止释放屏幕捕获资源
68+
stream.getVideoTracks().forEach((t) => {
69+
t.stop();
70+
stream.removeTrack(t);
71+
});
72+
console.info(
73+
`[useClassroomCapture] 系统音频环回已获取: track="${audioTracks[0].label}", ` +
74+
`enabled=${audioTracks[0].enabled}, muted=${audioTracks[0].muted}`,
75+
);
76+
return stream;
7177
}
7278

7379
export function useClassroomAudio({ captureManager, status, mode, onNotify }: UseClassroomAudioOptions) {
@@ -152,8 +158,8 @@ export function useClassroomAudio({ captureManager, status, mode, onNotify }: Us
152158
const payload = args[0] as AudioStartPayload;
153159
(async () => {
154160
try {
155-
// 方案A:优先窗口源直采,失败自动回退环回降级候选
156-
const stream = await openDesktopAudioStream(payload);
161+
// 系统音频环回(主进程 handler 附加 audio: 'loopback')
162+
const stream = await openDesktopAudioStream();
157163
const audioCtx = new AudioContext({ sampleRate: payload.options.sampleRate });
158164
// 关键修复:非用户手势调用栈中创建的 AudioContext 默认 suspended,
159165
// 必须显式 resume(),否则 onaudioprocess 永不触发(0 音频块)。

0 commit comments

Comments
 (0)