Skip to content

Commit d41773d

Browse files
committed
refactor(audio): 引入 AudioSourceProvider 抽象层(Phase 0,零行为变更)
为 ADR-001 的双源互补铺路:audioCapture 从'直接驱动渲染进程采集'改为 '按策略选源 + 降级 + 统一补时间戳'的编排器,采集实现下沉到 Provider。 - src/lib/capture/audioSourceStrategy.ts:源类型与选源策略纯函数 (主/渲染进程共用——useAudioRecovery 后续需按源分支提示文案),12 例单测 - electron/audio/audioSourceProvider.ts:Provider 接口与音频块契约 - electron/audio/endpointLoopbackProvider.ts:现有端点环回逻辑原样迁入 - audioCapture.ts:编排器,公开 API(start/stop/dispose/handleRendererChunk /isCapturing/config)保持不变,mediaCaptureHandlers 零改动 Phase 0 阶段能力探测恒为'进程环回不可用',选源结果必为端点环回, 行为与重构前完全一致。
1 parent be0f090 commit d41773d

6 files changed

Lines changed: 569 additions & 130 deletions

File tree

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+
}

0 commit comments

Comments
 (0)