Skip to content

Commit bc37a96

Browse files
committed
feat(audio): 接入进程环回 Provider 与构建链路(Phase 2,flag 默认关)
ADR-001 Phase 2:原生进程环回接入编排器,特性开关默认关闭,行为与 Phase 0 一致;开启方式为环境变量 ENTROPY_PROCESS_LOOPBACK=1。 - processAudioNative:原生模块加载器。addon 视为可选依赖,未编译/非 Windows/ 版本过低均优雅降级为'能力不可用',加载失败不冒泡到启动路径;能力探测与 加载结果缓存到进程生命周期 - processLoopbackProvider:窗口源 ID 解析 HWND → 匹配原生枚举取进程树根 PID; 采集在主进程完成故不实现 handleRendererChunk - audioCapture:接入能力探测与 flag;新增运行时降级——采集开始后才发生的 进程环回故障(如目标进程退出)无缝切回端点环回 - 构建链路:native:install / native:build 脚本(electron-rebuild 针对 Electron ABI 编译)、electron-builder files 纳入 .node 产物(已有 asarUnpack 解包)、CI 增加编译步骤(continue-on-error + 产物检查告警, 编译失败不阻断发布) 验证:client 473 tests、lint 0 errors、渲染与主进程 tsc 均 0; electron-rebuild 针对 Electron ABI 重建通过。 未纳入本阶段:useAudioRecovery 按源分支提示文案(flag 关闭时现有文案正确), 留待 Phase 3 灰度开启时一并处理。
1 parent b368bf8 commit bc37a96

6 files changed

Lines changed: 319 additions & 4 deletions

File tree

‎.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: 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+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* 进程环回 Provider(Windows 原生 WASAPI 路径)
3+
*
4+
* @ai-context: 见 ADR-001。与端点环回的关键差异:采集完全在主进程完成
5+
* (原生采集线程 → ThreadSafeFunction → 主进程 JS),不经渲染进程,
6+
* 故不实现 handleRendererChunk;产出的块直接交 sink。
7+
* @ai-context: 目标 PID 解析——desktopCapturer 的 window id 形如
8+
* `window:<HWND>:0`,取 HWND 匹配原生枚举结果得到进程树根。Chromium 顶层
9+
* 窗口本身即归属 browser process,故 rootPid 通常等于 pid;仍走 rootPid
10+
* 以覆盖窗口归属子进程的应用。
11+
* @ai-context: 启动期失败(目标不可采/激活失败)经 onFatal 异步上报,
12+
* 由编排器触发降级到端点环回。
13+
*/
14+
15+
import { logger } from '../logger.js';
16+
import { loadProcessAudioNative, type NativeWindowInfo } from './processAudioNative.js';
17+
import type {
18+
AudioChunkSink,
19+
AudioProviderStartContext,
20+
AudioSourceProvider,
21+
} from './audioSourceProvider.js';
22+
import type { AudioSourceKind } from '../../src/lib/capture/audioSourceStrategy.js';
23+
24+
/** 从 desktopCapturer 的 window id 中解析 HWND;失败返回 null */
25+
export function parseHwndFromSourceId(sourceId: string | null): string | null {
26+
if (!sourceId || !sourceId.startsWith('window:')) return null;
27+
const parts = sourceId.split(':');
28+
if (parts.length < 2 || !parts[1]) return null;
29+
// id 形如 window:395794:0,中间段为十进制 HWND
30+
return parts[1];
31+
}
32+
33+
export class ProcessLoopbackProvider implements AudioSourceProvider {
34+
readonly kind: AudioSourceKind = 'process_loopback';
35+
36+
private readonly sink: AudioChunkSink;
37+
/** 致命错误上报(编排器据此降级) */
38+
private readonly onFatal: (message: string) => void;
39+
private capturing = false;
40+
private disposed = false;
41+
42+
constructor(sink: AudioChunkSink, onFatal: (message: string) => void) {
43+
this.sink = sink;
44+
this.onFatal = onFatal;
45+
}
46+
47+
async start(ctx: AudioProviderStartContext): Promise<void> {
48+
if (this.capturing || this.disposed) return;
49+
50+
const native = loadProcessAudioNative();
51+
if (!native) throw new Error('进程环回原生模块不可用');
52+
53+
const targetPid = this.resolveTargetPid(native.listAudioWindows(), ctx.sourceId);
54+
if (targetPid === null) {
55+
throw new Error('无法解析目标窗口所属进程,请改用系统音频采集');
56+
}
57+
58+
const result = native.startCapture(
59+
{
60+
pid: targetPid,
61+
sampleRate: ctx.options.sampleRate,
62+
channels: ctx.options.channels,
63+
chunkDurationMs: ctx.options.chunkDurationMs,
64+
},
65+
(payload) => {
66+
if (this.disposed) return;
67+
if (payload.error) {
68+
logger.warn(`[ProcessLoopback] 采集错误: ${payload.error}`);
69+
this.onFatal(payload.error);
70+
return;
71+
}
72+
if (!payload.audioBuffer) return;
73+
this.sink({
74+
audioBuffer: payload.audioBuffer,
75+
sampleRate: payload.sampleRate ?? ctx.options.sampleRate,
76+
channels: payload.channels ?? ctx.options.channels,
77+
durationMs: payload.durationMs ?? ctx.options.chunkDurationMs,
78+
});
79+
},
80+
);
81+
82+
if (!result.ok) throw new Error(result.error || '进程环回启动失败');
83+
84+
this.capturing = true;
85+
logger.info(
86+
`[ProcessLoopback] 开始捕获, targetPid=${targetPid}, ` +
87+
`chunkDurationMs=${ctx.options.chunkDurationMs}, ` +
88+
`sampleRate=${ctx.options.sampleRate}, channels=${ctx.options.channels}`,
89+
);
90+
}
91+
92+
/** 由窗口源 ID 定位进程树根 PID */
93+
private resolveTargetPid(windows: NativeWindowInfo[], sourceId: string | null): number | null {
94+
const hwnd = parseHwndFromSourceId(sourceId);
95+
if (!hwnd) return null;
96+
const matched = windows.find((w) => w.hwnd === hwnd);
97+
if (!matched) {
98+
logger.warn(`[ProcessLoopback] 未在窗口列表中找到 HWND=${hwnd}`);
99+
return null;
100+
}
101+
logger.info(
102+
`[ProcessLoopback] 目标窗口="${matched.title}" pid=${matched.pid} ` +
103+
`rootPid=${matched.rootPid} (${matched.rootProcessName})`,
104+
);
105+
return matched.rootPid;
106+
}
107+
108+
stop(): void {
109+
if (!this.capturing) return;
110+
this.capturing = false;
111+
const native = loadProcessAudioNative();
112+
try {
113+
native?.stopCapture();
114+
} catch (err) {
115+
const message = err instanceof Error ? err.message : String(err);
116+
logger.warn(`[ProcessLoopback] stopCapture 异常: ${message}`);
117+
}
118+
logger.info('[ProcessLoopback] 停止捕获');
119+
}
120+
121+
dispose(): void {
122+
this.stop();
123+
this.disposed = true;
124+
}
125+
}

‎client/electron/audioCapture.ts‎

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import type { BrowserWindow } from 'electron';
1717
import { logger } from './logger.js';
1818
import { EndpointLoopbackProvider, listAudioSources } from './audio/endpointLoopbackProvider.js';
19+
import { ProcessLoopbackProvider } from './audio/processLoopbackProvider.js';
20+
import { isProcessLoopbackAvailable } from './audio/processAudioNative.js';
1921
import type {
2022
AudioCaptureOptions,
2123
AudioChunk,
@@ -29,6 +31,16 @@ import {
2931
type AudioSourcePreference,
3032
} from '../src/lib/capture/audioSourceStrategy.js';
3133

34+
/**
35+
* 进程环回特性开关(Phase 2:默认关)。
36+
*
37+
* 开启方式:环境变量 ENTROPY_PROCESS_LOOPBACK=1。
38+
* Phase 3 经内测灰度后改为默认开并改由设置页控制。
39+
*/
40+
function isProcessLoopbackEnabled(): boolean {
41+
return process.env.ENTROPY_PROCESS_LOOPBACK === '1';
42+
}
43+
3244
// 保持既有导出路径不变(mediaCaptureHandlers 等调用方无需改动)
3345
export { listAudioSources };
3446
export type { AudioCaptureOptions, AudioChunk } from './audio/audioSourceProvider.js';
@@ -74,6 +86,9 @@ export class AudioCapture {
7486
private provider: AudioSourceProvider | null = null;
7587
/** 本次采集的选源决策(供日志/会话元数据归因) */
7688
private decision: AudioSourceDecision | null = null;
89+
/** 绑定的窗口与源 ID(运行时降级需重建 Provider) */
90+
private boundWindow: BrowserWindow | null = null;
91+
private boundSourceId: string | null = null;
7792

7893
constructor(
7994
options: Partial<AudioCaptureOptions>,
@@ -117,9 +132,10 @@ export class AudioCapture {
117132
if (this.capturing || this.disposed) return;
118133

119134
const resolvedSourceId = sourceId ?? null;
135+
// 特性开关关闭时能力恒为不可用,选源必为端点环回(行为与 Phase 0 一致)
136+
const processAvailable = isProcessLoopbackEnabled() && isProcessLoopbackAvailable();
120137
this.decision = selectAudioSource({
121-
// Phase 0:进程环回尚未接入,能力探测恒为不可用(行为与重构前一致)
122-
capabilities: { processLoopbackAvailable: false },
138+
capabilities: { processLoopbackAvailable: processAvailable },
123139
sourceId: resolvedSourceId,
124140
preference: extras?.preference,
125141
microphone: extras?.microphone,
@@ -155,6 +171,8 @@ export class AudioCapture {
155171
win: BrowserWindow,
156172
sourceId: string | null,
157173
): Promise<void> {
174+
this.boundWindow = win;
175+
this.boundSourceId = sourceId;
158176
this.provider?.dispose();
159177
this.provider = this.createProvider(kind);
160178
await this.provider.start({ window: win, sourceId, options: this.options });
@@ -167,14 +185,39 @@ export class AudioCapture {
167185
case 'endpoint_loopback':
168186
return new EndpointLoopbackProvider(sink);
169187
case 'process_loopback':
170-
// Phase 2 接入;Phase 0 阶段选源不会产生该分支
171-
throw new Error('进程环回 Provider 尚未接入');
188+
// 采集中发生的致命错误(如目标进程退出)触发运行时降级
189+
return new ProcessLoopbackProvider(sink, (message) => {
190+
void this.degradeToEndpoint(message);
191+
});
172192
case 'microphone':
173193
// TODO(现场课程): MicrophoneProvider 待实现
174194
throw new Error('麦克风 Provider 尚未实现');
175195
}
176196
}
177197

198+
/**
199+
* 运行时降级:采集已开始后才发生的进程环回故障,无缝切到端点环回。
200+
* 失败时仅记日志:此时已脱离 start 调用栈,抛错无人接收,
201+
* 且上层 watchdog(useClassroomAudio)会在 15s 内提示用户。
202+
*/
203+
private async degradeToEndpoint(reason: string): Promise<void> {
204+
if (this.disposed || !this.capturing) return;
205+
if (this.provider?.kind !== 'process_loopback') return;
206+
if (!this.boundWindow || this.boundWindow.isDestroyed()) return;
207+
208+
logger.warn(`[AudioCapture] 进程环回运行中故障(${reason}),切换到端点环回`);
209+
this.decision = {
210+
kind: 'endpoint_loopback',
211+
reason: `进程环回运行中故障后降级:${reason}`,
212+
fallback: null,
213+
};
214+
try {
215+
await this.startWithKind('endpoint_loopback', this.boundWindow, this.boundSourceId);
216+
} catch (err) {
217+
logger.error('[AudioCapture] 降级到端点环回失败', err);
218+
}
219+
}
220+
178221
/** 统一补时间戳后向消费者分发 */
179222
private emitChunk(data: RendererAudioChunk): void {
180223
if (this.disposed) return;
@@ -208,6 +251,8 @@ export class AudioCapture {
208251
this.stop();
209252
this.provider?.dispose();
210253
this.provider = null;
254+
this.boundWindow = null;
255+
this.boundSourceId = null;
211256
this.disposed = true;
212257
logger.info('[AudioCapture] 已销毁');
213258
}

0 commit comments

Comments
 (0)