diff --git a/packages/web/src/audio/AudioManager.ts b/packages/web/src/audio/AudioManager.ts index 6ba63b29..96f2f479 100644 --- a/packages/web/src/audio/AudioManager.ts +++ b/packages/web/src/audio/AudioManager.ts @@ -32,6 +32,12 @@ export class AudioManager { private rnnoiseReady = false; private keepAliveOscillator: OscillatorNode | null = null; + // Subscribers notified when the *upstream* getUserMedia track ends unexpectedly + // (hardware unplug, OS-level revoke, system audio service crash). Distinct from + // the published mic track's `onended` — the published track is a clone of the + // WebAudio destination node, which never ends on upstream loss. + private inputTrackEndedListeners: Set<(reason: 'unplug' | 'revoke' | 'unknown') => void> = new Set(); + private constructor() {} static getInstance(): AudioManager { @@ -205,7 +211,17 @@ export class AudioManager { try { if (this.currentStream) { - this.currentStream.getTracks().forEach(t => t.stop()); + // Detach our `onended` handlers BEFORE stopping. `.stop()` synchronously + // queues an `ended` event on each track; by clearing the listener first + // we guarantee the deliberate-replace path never notifies subscribers, + // regardless of microtask/task ordering. + const oldTracks = this.currentStream.getTracks(); + oldTracks.forEach(t => { t.onended = null; }); + oldTracks.forEach(t => t.stop()); + // Drop the reference immediately so any stray handler that survived + // (e.g. attached by external code) sees `currentStream` no longer + // pointing to the old stream and bails out via the identity check. + this.currentStream = null; } // Chrome AEC stays on during screen share — headphone users unaffected, @@ -231,10 +247,17 @@ export class AudioManager { } as any }; - this.currentStream = await navigator.mediaDevices.getUserMedia(constraints); + const newStream = await navigator.mediaDevices.getUserMedia(constraints); + this.currentStream = newStream; this.currentInputDeviceId = deviceId; this.streamGeneration++; + // Attach upstream-loss detection to every track in the new stream. If the + // OS / hardware ends a track (unplug, revoke, audio-service crash), the + // `ended` event fires and we notify subscribers — provided the stream is + // still the active one (identity check guards against later replacements). + this.attachInputEndedListeners(newStream); + if (this.ctx && this.inputGain) { if (this.inputSource) { this.inputSource.disconnect(); @@ -250,6 +273,52 @@ export class AudioManager { } } + /** + * Attaches an `onended` listener to every audio track in the supplied stream. + * The listener identity-checks against `this.currentStream` so it only fires + * for *unexpected* track loss — deliberate replacement clears the listener and + * nulls `currentStream` BEFORE stopping, so neither path can leak a false + * positive into subscribers. + */ + private attachInputEndedListeners(stream: MediaStream): void { + for (const track of stream.getTracks()) { + track.onended = () => { + // Stream has been replaced (deliberate device change, RNNoise toggle, + // setVoiceProcessing reset) — this is not a hardware-loss event. + if (this.currentStream !== stream) return; + // Drop our reference so any subsequent `hasActiveStream()` check + // reflects reality, and so a downstream re-acquire attempt via + // `setInputDevice` does not short-circuit on the stale-but-non-null + // currentStream. + this.currentStream = null; + // Reason classification is the consumer's responsibility — they probe + // `getUserMedia` to distinguish unplug vs revoke vs unavailable. We + // emit `'unknown'` so the type is still informative if a future caller + // wires reason inference at this layer. + const reason: 'unplug' | 'revoke' | 'unknown' = 'unknown'; + // Snapshot listeners before iteration: a subscriber that synchronously + // unsubscribes during notification (e.g. cleanup-on-disconnect) would + // otherwise mutate the set mid-iteration. + const snapshot = Array.from(this.inputTrackEndedListeners); + for (const cb of snapshot) { + try { cb(reason); } catch (err) { + console.error('[AudioManager] inputTrackEnded listener threw:', err); + } + } + }; + } + } + + /** + * Subscribe to upstream input-track-end events. Returns an unsubscribe. + * Callers should treat `reason` as a hint and probe `getUserMedia` themselves + * to distinguish unplug from revoke. + */ + onInputTrackEnded(cb: (reason: 'unplug' | 'revoke' | 'unknown') => void): () => void { + this.inputTrackEndedListeners.add(cb); + return () => { this.inputTrackEndedListeners.delete(cb); }; + } + private getInputTarget(): AudioNode { return (this.rnnoiseEnabled && this.rnnoiseNode) ? this.rnnoiseNode : this.inputGain!; } @@ -307,7 +376,10 @@ export class AudioManager { // Force track re-publish so LiveKit picks up the new pipeline this.streamGeneration++; if (this.currentStream) { - this.currentStream.getTracks().forEach(t => t.stop()); + // Detach listeners before stopping (see `_setInputDeviceImpl`). + const tracks = this.currentStream.getTracks(); + tracks.forEach(t => { t.onended = null; }); + tracks.forEach(t => t.stop()); this.currentStream = null; } } @@ -339,7 +411,10 @@ export class AudioManager { changed = true; } if (changed && this.currentStream) { - this.currentStream.getTracks().forEach(t => t.stop()); + // Detach listeners before stopping (see `_setInputDeviceImpl`). + const tracks = this.currentStream.getTracks(); + tracks.forEach(t => { t.onended = null; }); + tracks.forEach(t => t.stop()); this.currentStream = null; } } diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 8ecdfe40..dc82937a 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -143,6 +143,49 @@ function destroyRoom(room: Room | null): Promise | void { return room.disconnect(); } +/** + * Ensures a fresh microphone track from the AudioManager pipeline is published + * to the supplied room. If the existing publication is already current (live + * MediaStreamTrack matching the latest AudioManager streamGeneration), this is + * a no-op apart from un-muting. Otherwise the stale track is unpublished and a + * cloned destination-node track is published in its place. + * + * Extracted from the syncMic effect so the input-track-loss recovery path can + * call it directly — without relying on syncMic's React dep array catching a + * change that never re-renders the hook. + */ +async function republishMicrophone(r: Room, lastMicGenRef: { current: number }): Promise { + const audioManager = AudioManager.getInstance(); + const currentGen = audioManager.getStreamGeneration(); + + const micPub = r.localParticipant.getTrackPublications() + .find(p => p.source === Track.Source.Microphone); + + if (micPub?.track) { + // Track already published — check if it's still current + if (micPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) { + // Current and live — just unmute if needed + if (micPub.isMuted) { + await r.localParticipant.setMicrophoneEnabled(true); + } + return; + } + // Track is stale (device or constraint change) — replace it + await r.localParticipant.unpublishTrack(micPub.track as LocalAudioTrack); + } + + // Publish fresh track from AudioManager pipeline + const audioTrack = audioManager.getFreshTrack(); + if (!audioTrack) return; + + console.log('[LiveKit] Publishing fresh microphone track (gen:', currentGen, ')'); + await r.localParticipant.publishTrack(audioTrack, { + name: 'microphone', + source: Track.Source.Microphone, + }); + lastMicGenRef.current = currentGen; +} + export function useLiveKit() { const [room, setRoom] = useState(null); const [isConnected, setIsConnected] = useState(false); @@ -353,36 +396,12 @@ export function useLiveKit() { return; } - // Not muted — ensure mic is published and live + // Not muted — ensure the AudioManager pipeline is on the right device + // and at the right volume, then republish if the published track is + // stale or missing. await audioManager.setInputDevice(inputDeviceId); audioManager.setInputVolume(inputVolume); - - const currentGen = audioManager.getStreamGeneration(); - - if (micPub?.track) { - // Track already published — check if it's still current - if (micPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) { - // Current and live — just unmute if needed - if (micPub.isMuted) { - await r.localParticipant.setMicrophoneEnabled(true); - } - return; - } - // Track is stale (device or constraint change) — replace it - await r.localParticipant.unpublishTrack(micPub.track as LocalAudioTrack); - } - - // Publish fresh track from AudioManager pipeline - const audioTrack = audioManager.getFreshTrack(); - if (!audioTrack) return; - - console.log('[LiveKit] Publishing fresh microphone track (gen:', currentGen, ')'); - await r.localParticipant.publishTrack(audioTrack, { - name: 'microphone', - source: Track.Source.Microphone, - }); - lastMicGenRef.current = currentGen; - + await republishMicrophone(r, lastMicGenRef); } catch (err) { console.error('[LiveKit] Failed to sync mic state:', err); } @@ -391,15 +410,70 @@ export function useLiveKit() { syncMic(); // Re-sync when AudioManager resumes - const unsubscribe = AudioManager.getInstance().onResumed(() => { + const unsubscribeResume = AudioManager.getInstance().onResumed(() => { syncMic(); }); return () => { - unsubscribe(); + unsubscribeResume(); }; }, [isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]); + // Subscribe to upstream-input-track-end events from AudioManager whenever a + // room is connected. The published mic track is a clone of a WebAudio + // destination node and never ends on hardware loss; only the upstream + // getUserMedia track does. AudioManager owns that signal — we react to it. + useEffect(() => { + if (!isConnected) return; + const am = AudioManager.getInstance(); + const subscriberRoom = roomRef.current; + + const unsubscribe = am.onInputTrackEnded(async () => { + // Room was replaced or torn down between event emission and handler run. + if (roomRef.current !== subscriberRoom || !subscriberRoom) return; + + const deviceId = useVoiceStore.getState().inputDeviceId; + let copy = 'Microphone unavailable'; + + try { + const probe = await navigator.mediaDevices.getUserMedia({ + audio: deviceId === 'default' ? true : { deviceId: { exact: deviceId } }, + }); + probe.getTracks().forEach(t => t.stop()); + + // Probe succeeded — device is back. Re-acquire and force a republish. + if (roomRef.current !== subscriberRoom) return; + try { + await am.setInputDevice(deviceId); + if (roomRef.current !== subscriberRoom) return; + await republishMicrophone(subscriberRoom, lastMicGenRef); + return; + } catch { + copy = 'Microphone could not be restored'; + } + } catch (err: any) { + if (err?.name === 'NotAllowedError') { + copy = 'Microphone permission was revoked'; + } else if (err?.name === 'NotFoundError') { + if (deviceId !== 'default') { + // The configured device disappeared. Fall back to default — the + // store update triggers syncMic via its dep array, which calls + // republishMicrophone with the freshly acquired default stream. + useVoiceStore.getState().setInputDevice('default'); + copy = 'Microphone disconnected — switched to system default'; + } else { + copy = 'Microphone disconnected'; + } + } + } + + if (roomRef.current !== subscriberRoom) return; + useUIStore.getState().addToast(copy, 'warning'); + }); + + return () => { unsubscribe(); }; + }, [isConnected]); + // Hot-swap the camera source when cameraDeviceId changes mid-call. // Compares against the published track's actual deviceId (getSettings().deviceId) // rather than a memoised previous store value, so the null → explicit-same-device @@ -613,62 +687,11 @@ export function useLiveKit() { }; } } - if (publication.source === Track.Source.Microphone) { - const mst = publication.track?.mediaStreamTrack; - if (mst) { - mst.onended = async () => { - // The mic track we publish is the *cloned* output of the AudioManager - // pipeline (see AudioManager.getFreshTrack). It can end for two - // distinct reasons: - // (a) The underlying upstream getUserMedia track ended (unplug, - // OS revoke). The clone goes too. - // (b) syncMic called unpublishTrack() during a deliberate - // republish (device change, RNNoise toggle). In that case - // the user did NOT lose audio — a fresh track is incoming. - // - // Distinguishing (a) from (b): inspect the AudioManager's current - // upstream stream. If it's null or non-active AND the room is - // still ours, we're in case (a). - if (roomRef.current !== newRoom) return; - const am = AudioManager.getInstance(); - if (am.hasActiveStream()) return; // case (b) — pipeline is alive - - // Probe getUserMedia to distinguish unplug vs revoke vs unavailable. - const deviceId = useVoiceStore.getState().inputDeviceId; - let copy = 'Microphone unavailable'; - try { - const probe = await navigator.mediaDevices.getUserMedia({ - audio: deviceId === 'default' ? true : { deviceId: { exact: deviceId } }, - }); - probe.getTracks().forEach(t => t.stop()); - // Probe succeeded — device is back. Try to re-acquire silently. - if (roomRef.current !== newRoom) return; - try { - await am.setInputDevice(deviceId); - // syncMic effect re-publishes when stream generation bumps. - return; - } catch { - copy = 'Microphone could not be restored'; - } - } catch (err: any) { - if (err?.name === 'NotAllowedError') copy = 'Microphone permission was revoked'; - else if (err?.name === 'NotFoundError') { - // The configured device disappeared. Fall back to default if - // the user wasn't already on it. - if (deviceId !== 'default') { - useVoiceStore.getState().setInputDevice('default'); - copy = 'Microphone disconnected — switched to system default'; - } else { - copy = 'Microphone disconnected'; - } - } - } - - if (roomRef.current !== newRoom) return; - useUIStore.getState().addToast(copy, 'warning'); - }; - } - } + // NOTE: Microphone track-loss is handled at the AudioManager layer via + // `onInputTrackEnded`, NOT here. The published mic track is a clone of + // a WebAudio destination node and does not end on hardware loss — only + // the upstream getUserMedia track does. See the `useEffect` that + // subscribes to `AudioManager.onInputTrackEnded` above. guardedUpdate(); }); newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => {