Skip to content

Commit 956aabe

Browse files
committed
feat: auto-recover mic on disconnect and notify user via toast during recording
1 parent 3646dd9 commit 956aabe

2 files changed

Lines changed: 96 additions & 6 deletions

File tree

src/renderer/services/audioCaptureService.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ let micGainNode: GainNode | null = null;
4141
let currentAudioLevel = 0; // 0.0 (silence) to 1.0 (max)
4242
let 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
*/
250317
function 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) {

src/renderer/stores/recordingStore.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,17 @@ export const useRecordingStore = create<RecordingStore>((set, get) => ({
9292
// Step 4: Start audio capture in renderer (with optional mic mixing)
9393
await audioCaptureService.startCapture(get().includeMic, micDeviceId);
9494

95+
// Step 5: Register interruption callback to surface audio issues via toast
96+
audioCaptureService.onAudioInterrupted((type, recovered) => {
97+
if (type === 'mic' && recovered === true) {
98+
toast('Microphone reconnected', 'success', undefined, 3000);
99+
} else if (type === 'mic' && recovered === false) {
100+
toast('Microphone disconnected — recording continues with system audio only', 'error', undefined, 5000);
101+
} else if (type === 'system' && recovered === false) {
102+
toast('System audio lost — restart recording to resume capture', 'error', undefined, 8000);
103+
}
104+
});
105+
95106
// Notify main process that recording is active (close guard)
96107
window.electronAPI.recordingSetState(true);
97108

@@ -134,13 +145,16 @@ export const useRecordingStore = create<RecordingStore>((set, get) => ({
134145
window.electronAPI.recordingSetState(false);
135146

136147
try {
137-
// Step 1: Stop audio capture in renderer
148+
// Step 1: Clear interruption callback before stopping (prevent callbacks during cleanup)
149+
audioCaptureService.onAudioInterrupted(null);
150+
151+
// Step 2: Stop audio capture in renderer
138152
await audioCaptureService.stopCapture();
139153

140-
// Step 2: Tell main process to stop recording (saves WAV)
154+
// Step 3: Tell main process to stop recording (saves WAV)
141155
const audioPath = await window.electronAPI.stopRecording();
142156

143-
// Step 3: Update meeting with audioPath and completion
157+
// Step 4: Update meeting with audioPath and completion
144158
if (meetingId) {
145159
await window.electronAPI.updateMeeting(meetingId, {
146160
endedAt: new Date().toISOString(),
@@ -185,6 +199,7 @@ export const useRecordingStore = create<RecordingStore>((set, get) => ({
185199
set({ isRecording: false, isProcessing: false, processingProgress: null });
186200
window.electronAPI.recordingSetState(false);
187201
try {
202+
audioCaptureService.onAudioInterrupted(null);
188203
await audioCaptureService.stopCapture();
189204
await window.electronAPI.stopRecording();
190205
if (meetingId) {

0 commit comments

Comments
 (0)