@@ -41,6 +41,10 @@ let micGainNode: GainNode | null = null;
4141let currentAudioLevel = 0 ; // 0.0 (silence) to 1.0 (max)
4242let audioLevelCallback : ( ( level : number ) => void ) | null = null ;
4343
44+ // Track health monitoring
45+ let audioInterruptedCallback : ( ( type : 'mic' | 'system' , recovered : boolean ) => void ) | null = null ;
46+ let currentMicDeviceId : string | undefined = undefined ;
47+
4448/**
4549 * Calculate RMS (root-mean-square) level of Float32 audio samples.
4650 * Returns a value between 0.0 (silence) and 1.0 (max).
@@ -166,6 +170,17 @@ export async function startCapture(includeMic: boolean = true, micDeviceId?: str
166170 throw new Error ( 'No audio tracks in captured stream.' ) ;
167171 }
168172
173+ // Watch for system audio track ending unexpectedly (e.g., screen share stopped)
174+ // Recovery is NOT attempted — getDisplayMedia requires user interaction via picker dialog
175+ const systemTrack = audioTracks [ 0 ] ;
176+ systemTrack . onended = ( ) => {
177+ // Bail if recording already stopped
178+ if ( ! audioContext ) return ;
179+
180+ console . error ( '[audioCaptureService] System audio track ended unexpectedly — cannot auto-recover.' ) ;
181+ if ( audioInterruptedCallback ) audioInterruptedCallback ( 'system' , false ) ;
182+ } ;
183+
169184 // Step 5: Create AudioContext at 16kHz -- browser handles resampling from 48kHz
170185 audioContext = new AudioContext ( { sampleRate : SAMPLE_RATE } ) ;
171186
@@ -181,15 +196,56 @@ export async function startCapture(includeMic: boolean = true, micDeviceId?: str
181196 systemGainNode . connect ( processorNode ) ;
182197
183198 // Step 7: Optionally add microphone input
199+ currentMicDeviceId = micDeviceId ;
184200 if ( includeMic ) {
185- micStream = await acquireMicStream ( micDeviceId ) ;
201+ micStream = await acquireMicStream ( currentMicDeviceId ) ;
186202 if ( micStream ) {
187203 micSourceNode = audioContext . createMediaStreamSource ( micStream ) ;
188204 micGainNode = audioContext . createGain ( ) ;
189205 micGainNode . gain . value = 1.0 ;
190206 // Connect mic: source → gain → processor (sums with system audio)
191207 micSourceNode . connect ( micGainNode ) ;
192208 micGainNode . connect ( processorNode ) ;
209+
210+ // Watch for mic track ending unexpectedly (e.g., device disconnected)
211+ const micTrack = micStream . getAudioTracks ( ) [ 0 ] ;
212+ if ( micTrack ) {
213+ micTrack . onended = async ( ) => {
214+ // Bail if recording already stopped
215+ if ( ! audioContext ) return ;
216+
217+ console . warn ( '[audioCaptureService] Mic track ended unexpectedly — attempting recovery...' ) ;
218+
219+ // Disconnect old mic nodes before re-wiring
220+ if ( micSourceNode ) {
221+ micSourceNode . disconnect ( ) ;
222+ micSourceNode = null ;
223+ }
224+ if ( micGainNode ) {
225+ micGainNode . disconnect ( ) ;
226+ micGainNode = null ;
227+ }
228+ if ( micStream ) {
229+ micStream . getTracks ( ) . forEach ( ( t ) => t . stop ( ) ) ;
230+ micStream = null ;
231+ }
232+
233+ const recoveredStream = await acquireMicStream ( currentMicDeviceId ) ;
234+ if ( recoveredStream && audioContext && micGainNode === null ) {
235+ micStream = recoveredStream ;
236+ micSourceNode = audioContext . createMediaStreamSource ( micStream ) ;
237+ micGainNode = audioContext . createGain ( ) ;
238+ micGainNode . gain . value = 1.0 ;
239+ micSourceNode . connect ( micGainNode ) ;
240+ micGainNode . connect ( processorNode ! ) ;
241+ console . info ( '[audioCaptureService] Mic recovered successfully.' ) ;
242+ if ( audioInterruptedCallback ) audioInterruptedCallback ( 'mic' , true ) ;
243+ } else {
244+ console . warn ( '[audioCaptureService] Mic recovery failed — continuing with system audio only.' ) ;
245+ if ( audioInterruptedCallback ) audioInterruptedCallback ( 'mic' , false ) ;
246+ }
247+ } ;
248+ }
193249 }
194250 }
195251
@@ -244,12 +300,25 @@ export function onAudioLevel(callback: ((level: number) => void) | null): void {
244300 audioLevelCallback = callback ;
245301}
246302
303+ /**
304+ * Set a callback to receive track interruption notifications during capture.
305+ * Fired when a mic or system audio track ends unexpectedly.
306+ * - type: 'mic' | 'system' — which track was lost
307+ * - recovered: true if mic was successfully re-acquired; always false for system audio
308+ * Pass null to remove.
309+ */
310+ export function onAudioInterrupted ( callback : ( ( type : 'mic' | 'system' , recovered : boolean ) => void ) | null ) : void {
311+ audioInterruptedCallback = callback ;
312+ }
313+
247314/**
248315 * Internal cleanup -- disconnect nodes, stop tracks, close context.
249316 */
250317function cleanup ( ) : void {
251318 currentAudioLevel = 0 ;
252319 audioLevelCallback = null ;
320+ audioInterruptedCallback = null ;
321+ currentMicDeviceId = undefined ;
253322
254323 if ( processorNode ) {
255324 processorNode . disconnect ( ) ;
@@ -266,7 +335,10 @@ function cleanup(): void {
266335 micSourceNode = null ;
267336 }
268337 if ( micStream ) {
269- micStream . getTracks ( ) . forEach ( ( track ) => track . stop ( ) ) ;
338+ micStream . getTracks ( ) . forEach ( ( track ) => {
339+ track . onended = null ;
340+ track . stop ( ) ;
341+ } ) ;
270342 micStream = null ;
271343 }
272344 // Clean up system audio resources
@@ -279,7 +351,10 @@ function cleanup(): void {
279351 sourceNode = null ;
280352 }
281353 if ( mediaStream ) {
282- mediaStream . getTracks ( ) . forEach ( ( track ) => track . stop ( ) ) ;
354+ mediaStream . getTracks ( ) . forEach ( ( track ) => {
355+ track . onended = null ;
356+ track . stop ( ) ;
357+ } ) ;
283358 mediaStream = null ;
284359 }
285360 if ( audioContext ) {
0 commit comments