Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,24 @@ jobs:
cache: 'npm'
cache-dependency-path: client/package-lock.json
- run: cd client && npm ci
# 进程环回原生模块(ADR-001):windows-latest 自带 MSVC,针对 Electron ABI 编译。
# continue-on-error:它是可选增强(运行时未加载到则降级为端点环回),
# 不应因其编译失败而阻断整条发布流水线
- name: Build native process-audio module
continue-on-error: true
run: |
cd client
npm run native:install
npm run native:build
- name: Check native module artifact
shell: bash
run: |
cd client
if [ -f native/process-audio/build/Release/process_audio.node ]; then
echo "进程环回原生模块已就绪"
else
echo "::warning::进程环回原生模块未构建成功,本包仅支持端点环回采集"
fi
# --publish never:发布由下方 release job 统一负责,避免 electron-builder
# 因检测到 tag 而自行尝试发布(github provider 无 GH_TOKEN 会报错)
#
Expand Down
4 changes: 4 additions & 0 deletions client/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ files:
- dist/**/*
- dist-electron/**/*
- app-icon.png
# 进程环回原生模块(ADR-001):仅打包编译产物,不带 src/node_modules。
# 经上方 asarUnpack 解到 app.asar.unpacked/,processAudioNative 按此路径加载;
# 未编译时此匹配为空,应用仍可正常启动(降级为端点环回)
- native/process-audio/build/Release/*.node
# 图标说明:
# app-icon.png 为统一源文件(1024x1024 方形 PNG)。
# electron-builder 会自动将其转换为各平台所需格式:
Expand Down
75 changes: 75 additions & 0 deletions client/electron/audio/audioSourceProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* 音频源 Provider 接口(主进程侧)
*
* @ai-context: 见 ADR-001。采集层抽象的目的是让"如何拿到 PCM"可替换,
* 而下游(VAD / ASR / 幻觉过滤 / 交叉融合)只依赖 AudioChunk 契约不受影响。
* 两类 Provider 的数据流方向相反:端点环回由渲染进程采集后回传主进程
* (需 handleRendererChunk),进程环回在主进程原生采集后直接产出。
*/

import type { BrowserWindow } from 'electron';
import type { AudioSourceKind } from '../../src/lib/capture/audioSourceStrategy.js';

/** 音频采集配置 */
export interface AudioCaptureOptions {
/** 音频块时长(ms),默认 5000 */
chunkDurationMs: number;
/** 采样率,默认 16000 */
sampleRate: number;
/** 声道数,默认 1(单声道) */
channels: number;
}

/** 音频块(Provider → 消费者) */
export interface AudioChunk {
/** PCM Float32 数据 */
audioBuffer: ArrayBuffer;
sampleRate: number;
channels: number;
durationMs: number;
/** 单调递增时间戳 (ms) */
timestamp: number;
}

/** 渲染进程上报的原始音频块(无时间戳,由编排器统一补) */
export interface RendererAudioChunk {
audioBuffer: ArrayBuffer;
sampleRate: number;
channels: number;
durationMs: number;
}

/** Provider 启动上下文 */
export interface AudioProviderStartContext {
/** 绑定的窗口(端点环回需向其下发采集指令) */
window: BrowserWindow;
/** 用户选定的采集源 ID(desktopCapturer 格式),null 表示自动 */
sourceId: string | null;
options: AudioCaptureOptions;
}

/**
* 音频源 Provider。
*
* 实现约定:
* - start 失败必须抛错,由编排器决定是否降级
* - stop / dispose 必须幂等
* - 产出的 chunk 不带时间戳,统一由编排器补(保证跨源时间基准一致)
*/
export interface AudioSourceProvider {
readonly kind: AudioSourceKind;
/** 启动采集;失败抛错 */
start(ctx: AudioProviderStartContext): Promise<void>;
/** 停止采集(幂等) */
stop(): void;
/** 释放资源(幂等) */
dispose(): void;
/**
* 接收渲染进程回传的音频块。
* 仅渲染进程侧采集的 Provider(端点环回 / 麦克风)实现此方法。
*/
handleRendererChunk?(data: RendererAudioChunk): void;
}

/** Provider 产出音频块的回调 */
export type AudioChunkSink = (chunk: RendererAudioChunk) => void;
119 changes: 119 additions & 0 deletions client/electron/audio/endpointLoopbackProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* 端点环回 Provider(Chromium getDisplayMedia 路径)
*
* @ai-context: 从 audioCapture.ts 原样迁入(Phase 0 纯重构,行为不变)。
* Windows 下系统音频只能在渲染进程取到:主进程登记期望源 → 通知渲染进程
* 调 getDisplayMedia(由 displayMediaHandler 附加 audio:'loopback')→
* 渲染进程切片后经 IPC 回传,故本 Provider 实现 handleRendererChunk。
* @ai-context: 采到的是设备最终混音(含其他应用声音、受主音量影响),
* 这是它与进程环回的本质差异,见 ADR-001。
*/

import { desktopCapturer, type DesktopCapturerSource } from 'electron';
import { logger } from '../logger.js';
import { setPreferredDisplaySource } from '../displayMediaHandler.js';
import type {
AudioChunkSink,
AudioProviderStartContext,
AudioSourceProvider,
RendererAudioChunk,
} from './audioSourceProvider.js';
import type { AudioSourceKind } from '../../src/lib/capture/audioSourceStrategy.js';

/** 音频源信息 */
export interface AudioSourceInfo {
id: string;
name: string;
}

/**
* 列出所有可用的系统音频源
*
* Electron 中系统音频环回(WASAPI Loopback)通过桌面捕获源实现:
* 任意 screen/window 源均可配合 getDisplayMedia 捕获系统音频,
* 因此这里枚举 screen 类型源作为音频采集候选。
*/
export async function listAudioSources(): Promise<AudioSourceInfo[]> {
const sources: DesktopCapturerSource[] = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: 1, height: 1 }, // 枚举不需要缩略图
});

return sources.map((src) => ({
id: src.id,
name: `系统音频 - ${src.name}`,
}));
}

export class EndpointLoopbackProvider implements AudioSourceProvider {
readonly kind: AudioSourceKind = 'endpoint_loopback';

private readonly sink: AudioChunkSink;
private capturing = false;
private disposed = false;
private boundWindow: AudioProviderStartContext['window'] | null = null;

constructor(sink: AudioChunkSink) {
this.sink = sink;
}

async start(ctx: AudioProviderStartContext): Promise<void> {
if (this.capturing || this.disposed) return;

// 解析音频源:未指定时自动取首个屏幕源
let resolvedSourceId = ctx.sourceId;
if (!resolvedSourceId) {
const sources = await listAudioSources();
if (sources.length === 0) {
throw new Error('No audio source available');
}
resolvedSourceId = sources[0].id;
logger.info(`[EndpointLoopback] 自动选择音频源: ${sources[0].name} (${resolvedSourceId})`);
}

// 登记期望源:渲染进程随后调用 getDisplayMedia,主进程 handler 据此授权
// 并附加 audio: 'loopback' 才能拿到真实系统音频(详见 displayMediaHandler.ts)
setPreferredDisplaySource(resolvedSourceId);

this.capturing = true;
this.boundWindow = ctx.window;

logger.info(
`[EndpointLoopback] 开始捕获, sourceId=${resolvedSourceId}, ` +
`chunkDurationMs=${ctx.options.chunkDurationMs}, ` +
`sampleRate=${ctx.options.sampleRate}, channels=${ctx.options.channels}`,
);

// 通知渲染进程开始音频采集
if (!ctx.window.isDestroyed()) {
ctx.window.webContents.send('audio_capture_do_start', {
sourceId: resolvedSourceId,
options: ctx.options,
});
}
}

stop(): void {
if (!this.capturing) return;

this.capturing = false;
logger.info('[EndpointLoopback] 停止捕获');

// 通知渲染进程停止音频采集
if (this.boundWindow && !this.boundWindow.isDestroyed()) {
this.boundWindow.webContents.send('audio_capture_do_stop');
}
this.boundWindow = null;
}

/** 接收渲染进程回传的音频块,转交编排器补时间戳后分发 */
handleRendererChunk(data: RendererAudioChunk): void {
if (!this.capturing || this.disposed) return;
this.sink(data);
}

dispose(): void {
this.stop();
this.disposed = true;
}
}
121 changes: 121 additions & 0 deletions client/electron/audio/processAudioNative.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* 进程环回原生模块加载器
*
* @ai-context: 原生 addon 是可选依赖——未编译、非 Windows、或 Windows 版本
* 过低时必须优雅降级为"能力不可用",绝不能让加载失败冒泡到应用启动路径
* (AGENTS.md 高风险区约定)。故此处用 try/catch 包裹 require 并缓存结果。
* @ai-context: 能力探测(isProcessLoopbackSupported)会真实尝试激活一次
* WASAPI 接口,有几毫秒开销,因此结果缓存到进程生命周期。
*/

import * as path from 'path';
import { app } from 'electron';
import { logger } from '../logger.js';

/** 原生模块导出的窗口信息 */
export interface NativeWindowInfo {
hwnd: string;
pid: number;
rootPid: number;
title: string;
processName: string;
rootProcessName: string;
}

/** 原生模块流式回调的负载:音频块或错误 */
export interface NativeStreamPayload {
audioBuffer?: ArrayBuffer;
sampleRate?: number;
channels?: number;
durationMs?: number;
error?: string;
}

/** 原生模块导出接口 */
export interface ProcessAudioNative {
listAudioWindows(): NativeWindowInfo[];
resolveRootPid(pid: number): number;
isProcessLoopbackSupported(): boolean;
startCapture(
options: { pid: number; sampleRate: number; channels: number; chunkDurationMs: number },
callback: (payload: NativeStreamPayload) => void,
): { ok: boolean; error: string };
stopCapture(): boolean;
}

/** 加载状态缓存(undefined 表示尚未尝试加载) */
let cached: ProcessAudioNative | null | undefined;
let supportedCache: boolean | undefined;

/** 候选路径:开发态走源码目录构建产物,打包后走 asarUnpack 解出的目录 */
function candidatePaths(): string[] {
const fileName = 'process_audio.node';
const appPath = app.getAppPath();
return [
// 打包后:asarUnpack 将 .node 解到 app.asar.unpacked 下
path.join(appPath.replace('app.asar', 'app.asar.unpacked'), 'native', 'process-audio', 'build', 'Release', fileName),
// 开发态:client/native/process-audio/build/Release
path.join(appPath, 'native', 'process-audio', 'build', 'Release', fileName),
];
}

/**
* 加载原生模块;不可用时返回 null(不抛错)。
*/
export function loadProcessAudioNative(): ProcessAudioNative | null {
if (cached !== undefined) return cached;

if (process.platform !== 'win32') {
logger.info('[ProcessAudio] 非 Windows 平台,进程环回不可用');
cached = null;
return cached;
}

// 主进程编译目标为 CommonJS,直接用全局 require 动态加载 .node
for (const candidate of candidatePaths()) {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const native = require(candidate) as ProcessAudioNative;
if (typeof native.startCapture === 'function') {
logger.info(`[ProcessAudio] 原生模块已加载: ${candidate}`);
cached = native;
return cached;
}
} catch {
// 继续尝试下一个候选路径
}
}

logger.info('[ProcessAudio] 原生模块未找到(未编译或未随包分发),进程环回不可用');
cached = null;
return cached;
}

/**
* 进程环回是否可用(模块已加载 + 系统 API 可用)。
* 结果缓存到进程生命周期。
*/
export function isProcessLoopbackAvailable(): boolean {
if (supportedCache !== undefined) return supportedCache;

const native = loadProcessAudioNative();
if (!native) {
supportedCache = false;
return supportedCache;
}
try {
supportedCache = native.isProcessLoopbackSupported();
logger.info(`[ProcessAudio] 系统能力探测: ${supportedCache ? '支持' : '不支持'}(需 Windows 10 2004+)`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn(`[ProcessAudio] 能力探测异常,视为不可用: ${message}`);
supportedCache = false;
}
return supportedCache;
}

/** 仅测试用:重置缓存 */
export function resetProcessAudioCacheForTest(): void {
cached = undefined;
supportedCache = undefined;
}
Loading
Loading