1616import type { BrowserWindow } from 'electron' ;
1717import { logger } from './logger.js' ;
1818import { EndpointLoopbackProvider , listAudioSources } from './audio/endpointLoopbackProvider.js' ;
19+ import { ProcessLoopbackProvider } from './audio/processLoopbackProvider.js' ;
20+ import { isProcessLoopbackAvailable } from './audio/processAudioNative.js' ;
1921import 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 等调用方无需改动)
3345export { listAudioSources } ;
3446export 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