Skip to content

Commit 822f121

Browse files
authored
Merge branch 'dev': 音频采集进程环回改造(ADR-001 Phase 0-3)
feat(audio): 课堂音频采集引入进程环回,与端点环回双源互补(ADR-001 Phase 0-3)
2 parents b242996 + 4006a92 commit 822f121

43 files changed

Lines changed: 4322 additions & 146 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/release.yml‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@ jobs:
5959
cache: 'npm'
6060
cache-dependency-path: client/package-lock.json
6161
- run: cd client && npm ci
62+
# 进程环回原生模块(ADR-001):windows-latest 自带 MSVC,针对 Electron ABI 编译。
63+
# continue-on-error:它是可选增强(运行时未加载到则降级为端点环回),
64+
# 不应因其编译失败而阻断整条发布流水线
65+
- name: Build native process-audio module
66+
continue-on-error: true
67+
run: |
68+
cd client
69+
npm run native:install
70+
npm run native:build
71+
- name: Check native module artifact
72+
shell: bash
73+
run: |
74+
cd client
75+
if [ -f native/process-audio/build/Release/process_audio.node ]; then
76+
echo "进程环回原生模块已就绪"
77+
else
78+
echo "::warning::进程环回原生模块未构建成功,本包仅支持端点环回采集"
79+
fi
6280
# --publish never:发布由下方 release job 统一负责,避免 electron-builder
6381
# 因检测到 tag 而自行尝试发布(github provider 无 GH_TOKEN 会报错)
6482
#

‎client/electron-builder.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ files:
2525
- dist/**/*
2626
- dist-electron/**/*
2727
- app-icon.png
28+
# 进程环回原生模块(ADR-001):仅打包编译产物,不带 src/node_modules。
29+
# 经上方 asarUnpack 解到 app.asar.unpacked/,processAudioNative 按此路径加载;
30+
# 未编译时此匹配为空,应用仍可正常启动(降级为端点环回)
31+
- native/process-audio/build/Release/*.node
2832
# 图标说明:
2933
# app-icon.png 为统一源文件(1024x1024 方形 PNG)。
3034
# electron-builder 会自动将其转换为各平台所需格式:
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* 音频源 Provider 接口(主进程侧)
3+
*
4+
* @ai-context: 见 ADR-001。采集层抽象的目的是让"如何拿到 PCM"可替换,
5+
* 而下游(VAD / ASR / 幻觉过滤 / 交叉融合)只依赖 AudioChunk 契约不受影响。
6+
* 两类 Provider 的数据流方向相反:端点环回由渲染进程采集后回传主进程
7+
* (需 handleRendererChunk),进程环回在主进程原生采集后直接产出。
8+
*/
9+
10+
import type { BrowserWindow } from 'electron';
11+
import type { AudioSourceKind } from '../../src/lib/capture/audioSourceStrategy.js';
12+
13+
/** 音频采集配置 */
14+
export interface AudioCaptureOptions {
15+
/** 音频块时长(ms),默认 5000 */
16+
chunkDurationMs: number;
17+
/** 采样率,默认 16000 */
18+
sampleRate: number;
19+
/** 声道数,默认 1(单声道) */
20+
channels: number;
21+
}
22+
23+
/** 音频块(Provider → 消费者) */
24+
export interface AudioChunk {
25+
/** PCM Float32 数据 */
26+
audioBuffer: ArrayBuffer;
27+
sampleRate: number;
28+
channels: number;
29+
durationMs: number;
30+
/** 单调递增时间戳 (ms) */
31+
timestamp: number;
32+
}
33+
34+
/** 渲染进程上报的原始音频块(无时间戳,由编排器统一补) */
35+
export interface RendererAudioChunk {
36+
audioBuffer: ArrayBuffer;
37+
sampleRate: number;
38+
channels: number;
39+
durationMs: number;
40+
}
41+
42+
/** Provider 启动上下文 */
43+
export interface AudioProviderStartContext {
44+
/** 绑定的窗口(端点环回需向其下发采集指令) */
45+
window: BrowserWindow;
46+
/** 用户选定的采集源 ID(desktopCapturer 格式),null 表示自动 */
47+
sourceId: string | null;
48+
options: AudioCaptureOptions;
49+
}
50+
51+
/**
52+
* 音频源 Provider。
53+
*
54+
* 实现约定:
55+
* - start 失败必须抛错,由编排器决定是否降级
56+
* - stop / dispose 必须幂等
57+
* - 产出的 chunk 不带时间戳,统一由编排器补(保证跨源时间基准一致)
58+
*/
59+
export interface AudioSourceProvider {
60+
readonly kind: AudioSourceKind;
61+
/** 启动采集;失败抛错 */
62+
start(ctx: AudioProviderStartContext): Promise<void>;
63+
/** 停止采集(幂等) */
64+
stop(): void;
65+
/** 释放资源(幂等) */
66+
dispose(): void;
67+
/**
68+
* 接收渲染进程回传的音频块。
69+
* 仅渲染进程侧采集的 Provider(端点环回 / 麦克风)实现此方法。
70+
*/
71+
handleRendererChunk?(data: RendererAudioChunk): void;
72+
}
73+
74+
/** Provider 产出音频块的回调 */
75+
export type AudioChunkSink = (chunk: RendererAudioChunk) => void;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* 端点环回 Provider(Chromium getDisplayMedia 路径)
3+
*
4+
* @ai-context: 从 audioCapture.ts 原样迁入(Phase 0 纯重构,行为不变)。
5+
* Windows 下系统音频只能在渲染进程取到:主进程登记期望源 → 通知渲染进程
6+
* 调 getDisplayMedia(由 displayMediaHandler 附加 audio:'loopback')→
7+
* 渲染进程切片后经 IPC 回传,故本 Provider 实现 handleRendererChunk。
8+
* @ai-context: 采到的是设备最终混音(含其他应用声音、受主音量影响),
9+
* 这是它与进程环回的本质差异,见 ADR-001。
10+
*/
11+
12+
import { desktopCapturer, type DesktopCapturerSource } from 'electron';
13+
import { logger } from '../logger.js';
14+
import { setPreferredDisplaySource } from '../displayMediaHandler.js';
15+
import type {
16+
AudioChunkSink,
17+
AudioProviderStartContext,
18+
AudioSourceProvider,
19+
RendererAudioChunk,
20+
} from './audioSourceProvider.js';
21+
import type { AudioSourceKind } from '../../src/lib/capture/audioSourceStrategy.js';
22+
23+
/** 音频源信息 */
24+
export interface AudioSourceInfo {
25+
id: string;
26+
name: string;
27+
}
28+
29+
/**
30+
* 列出所有可用的系统音频源
31+
*
32+
* Electron 中系统音频环回(WASAPI Loopback)通过桌面捕获源实现:
33+
* 任意 screen/window 源均可配合 getDisplayMedia 捕获系统音频,
34+
* 因此这里枚举 screen 类型源作为音频采集候选。
35+
*/
36+
export async function listAudioSources(): Promise<AudioSourceInfo[]> {
37+
const sources: DesktopCapturerSource[] = await desktopCapturer.getSources({
38+
types: ['screen'],
39+
thumbnailSize: { width: 1, height: 1 }, // 枚举不需要缩略图
40+
});
41+
42+
return sources.map((src) => ({
43+
id: src.id,
44+
name: `系统音频 - ${src.name}`,
45+
}));
46+
}
47+
48+
export class EndpointLoopbackProvider implements AudioSourceProvider {
49+
readonly kind: AudioSourceKind = 'endpoint_loopback';
50+
51+
private readonly sink: AudioChunkSink;
52+
private capturing = false;
53+
private disposed = false;
54+
private boundWindow: AudioProviderStartContext['window'] | null = null;
55+
56+
constructor(sink: AudioChunkSink) {
57+
this.sink = sink;
58+
}
59+
60+
async start(ctx: AudioProviderStartContext): Promise<void> {
61+
if (this.capturing || this.disposed) return;
62+
63+
// 解析音频源:未指定时自动取首个屏幕源
64+
let resolvedSourceId = ctx.sourceId;
65+
if (!resolvedSourceId) {
66+
const sources = await listAudioSources();
67+
if (sources.length === 0) {
68+
throw new Error('No audio source available');
69+
}
70+
resolvedSourceId = sources[0].id;
71+
logger.info(`[EndpointLoopback] 自动选择音频源: ${sources[0].name} (${resolvedSourceId})`);
72+
}
73+
74+
// 登记期望源:渲染进程随后调用 getDisplayMedia,主进程 handler 据此授权
75+
// 并附加 audio: 'loopback' 才能拿到真实系统音频(详见 displayMediaHandler.ts)
76+
setPreferredDisplaySource(resolvedSourceId);
77+
78+
this.capturing = true;
79+
this.boundWindow = ctx.window;
80+
81+
logger.info(
82+
`[EndpointLoopback] 开始捕获, sourceId=${resolvedSourceId}, ` +
83+
`chunkDurationMs=${ctx.options.chunkDurationMs}, ` +
84+
`sampleRate=${ctx.options.sampleRate}, channels=${ctx.options.channels}`,
85+
);
86+
87+
// 通知渲染进程开始音频采集
88+
if (!ctx.window.isDestroyed()) {
89+
ctx.window.webContents.send('audio_capture_do_start', {
90+
sourceId: resolvedSourceId,
91+
options: ctx.options,
92+
});
93+
}
94+
}
95+
96+
stop(): void {
97+
if (!this.capturing) return;
98+
99+
this.capturing = false;
100+
logger.info('[EndpointLoopback] 停止捕获');
101+
102+
// 通知渲染进程停止音频采集
103+
if (this.boundWindow && !this.boundWindow.isDestroyed()) {
104+
this.boundWindow.webContents.send('audio_capture_do_stop');
105+
}
106+
this.boundWindow = null;
107+
}
108+
109+
/** 接收渲染进程回传的音频块,转交编排器补时间戳后分发 */
110+
handleRendererChunk(data: RendererAudioChunk): void {
111+
if (!this.capturing || this.disposed) return;
112+
this.sink(data);
113+
}
114+
115+
dispose(): void {
116+
this.stop();
117+
this.disposed = true;
118+
}
119+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* 进程环回原生模块加载器
3+
*
4+
* @ai-context: 原生 addon 是可选依赖——未编译、非 Windows、或 Windows 版本
5+
* 过低时必须优雅降级为"能力不可用",绝不能让加载失败冒泡到应用启动路径
6+
* (AGENTS.md 高风险区约定)。故此处用 try/catch 包裹 require 并缓存结果。
7+
* @ai-context: 能力探测(isProcessLoopbackSupported)会真实尝试激活一次
8+
* WASAPI 接口,有几毫秒开销,因此结果缓存到进程生命周期。
9+
*/
10+
11+
import * as path from 'path';
12+
import { app } from 'electron';
13+
import { logger } from '../logger.js';
14+
15+
/** 原生模块导出的窗口信息 */
16+
export interface NativeWindowInfo {
17+
hwnd: string;
18+
pid: number;
19+
rootPid: number;
20+
title: string;
21+
processName: string;
22+
rootProcessName: string;
23+
}
24+
25+
/** 原生模块流式回调的负载:音频块或错误 */
26+
export interface NativeStreamPayload {
27+
audioBuffer?: ArrayBuffer;
28+
sampleRate?: number;
29+
channels?: number;
30+
durationMs?: number;
31+
error?: string;
32+
}
33+
34+
/** 原生模块导出接口 */
35+
export interface ProcessAudioNative {
36+
listAudioWindows(): NativeWindowInfo[];
37+
resolveRootPid(pid: number): number;
38+
isProcessLoopbackSupported(): boolean;
39+
startCapture(
40+
options: { pid: number; sampleRate: number; channels: number; chunkDurationMs: number },
41+
callback: (payload: NativeStreamPayload) => void,
42+
): { ok: boolean; error: string };
43+
stopCapture(): boolean;
44+
}
45+
46+
/** 加载状态缓存(undefined 表示尚未尝试加载) */
47+
let cached: ProcessAudioNative | null | undefined;
48+
let supportedCache: boolean | undefined;
49+
50+
/** 候选路径:开发态走源码目录构建产物,打包后走 asarUnpack 解出的目录 */
51+
function candidatePaths(): string[] {
52+
const fileName = 'process_audio.node';
53+
const appPath = app.getAppPath();
54+
return [
55+
// 打包后:asarUnpack 将 .node 解到 app.asar.unpacked 下
56+
path.join(appPath.replace('app.asar', 'app.asar.unpacked'), 'native', 'process-audio', 'build', 'Release', fileName),
57+
// 开发态:client/native/process-audio/build/Release
58+
path.join(appPath, 'native', 'process-audio', 'build', 'Release', fileName),
59+
];
60+
}
61+
62+
/**
63+
* 加载原生模块;不可用时返回 null(不抛错)。
64+
*/
65+
export function loadProcessAudioNative(): ProcessAudioNative | null {
66+
if (cached !== undefined) return cached;
67+
68+
if (process.platform !== 'win32') {
69+
logger.info('[ProcessAudio] 非 Windows 平台,进程环回不可用');
70+
cached = null;
71+
return cached;
72+
}
73+
74+
// 主进程编译目标为 CommonJS,直接用全局 require 动态加载 .node
75+
for (const candidate of candidatePaths()) {
76+
try {
77+
// eslint-disable-next-line @typescript-eslint/no-var-requires
78+
const native = require(candidate) as ProcessAudioNative;
79+
if (typeof native.startCapture === 'function') {
80+
logger.info(`[ProcessAudio] 原生模块已加载: ${candidate}`);
81+
cached = native;
82+
return cached;
83+
}
84+
} catch {
85+
// 继续尝试下一个候选路径
86+
}
87+
}
88+
89+
logger.info('[ProcessAudio] 原生模块未找到(未编译或未随包分发),进程环回不可用');
90+
cached = null;
91+
return cached;
92+
}
93+
94+
/**
95+
* 进程环回是否可用(模块已加载 + 系统 API 可用)。
96+
* 结果缓存到进程生命周期。
97+
*/
98+
export function isProcessLoopbackAvailable(): boolean {
99+
if (supportedCache !== undefined) return supportedCache;
100+
101+
const native = loadProcessAudioNative();
102+
if (!native) {
103+
supportedCache = false;
104+
return supportedCache;
105+
}
106+
try {
107+
supportedCache = native.isProcessLoopbackSupported();
108+
logger.info(`[ProcessAudio] 系统能力探测: ${supportedCache ? '支持' : '不支持'}(需 Windows 10 2004+)`);
109+
} catch (err) {
110+
const message = err instanceof Error ? err.message : String(err);
111+
logger.warn(`[ProcessAudio] 能力探测异常,视为不可用: ${message}`);
112+
supportedCache = false;
113+
}
114+
return supportedCache;
115+
}
116+
117+
/** 仅测试用:重置缓存 */
118+
export function resetProcessAudioCacheForTest(): void {
119+
cached = undefined;
120+
supportedCache = undefined;
121+
}

0 commit comments

Comments
 (0)