fix(web): mic-loss detection observes upstream stream, not published clone
The previous Task 4 handler installed onended on the *published* mic track, but that track is a clone of AudioManager's MediaStreamAudioDestinationNode output — destination-node tracks never end on upstream loss, they just go silent. The handler also called setInputDevice for silent-recovery and assumed syncMic would re-publish, but syncMic's dep array does not depend on streamGeneration, so the recovery never republished. This commit moves loss detection into AudioManager (where the upstream getUserMedia track lives) via a new onInputTrackEnded subscription, extracts republishMicrophone from syncMic into a module-level helper that both the normal device-change path and the recovery path call directly, and removes the published-track Microphone branch from RoomEvent.LocalTrackPublished. The plan and Task 9 spec text are updated to match.
This commit is contained in:
@@ -32,6 +32,12 @@ export class AudioManager {
|
|||||||
private rnnoiseReady = false;
|
private rnnoiseReady = false;
|
||||||
private keepAliveOscillator: OscillatorNode | null = null;
|
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() {}
|
private constructor() {}
|
||||||
|
|
||||||
static getInstance(): AudioManager {
|
static getInstance(): AudioManager {
|
||||||
@@ -205,7 +211,17 @@ export class AudioManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.currentStream) {
|
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,
|
// Chrome AEC stays on during screen share — headphone users unaffected,
|
||||||
@@ -231,10 +247,17 @@ export class AudioManager {
|
|||||||
} as any
|
} as any
|
||||||
};
|
};
|
||||||
|
|
||||||
this.currentStream = await navigator.mediaDevices.getUserMedia(constraints);
|
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
this.currentStream = newStream;
|
||||||
this.currentInputDeviceId = deviceId;
|
this.currentInputDeviceId = deviceId;
|
||||||
this.streamGeneration++;
|
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.ctx && this.inputGain) {
|
||||||
if (this.inputSource) {
|
if (this.inputSource) {
|
||||||
this.inputSource.disconnect();
|
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 {
|
private getInputTarget(): AudioNode {
|
||||||
return (this.rnnoiseEnabled && this.rnnoiseNode) ? this.rnnoiseNode : this.inputGain!;
|
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
|
// Force track re-publish so LiveKit picks up the new pipeline
|
||||||
this.streamGeneration++;
|
this.streamGeneration++;
|
||||||
if (this.currentStream) {
|
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;
|
this.currentStream = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,7 +411,10 @@ export class AudioManager {
|
|||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
if (changed && this.currentStream) {
|
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;
|
this.currentStream = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,49 @@ function destroyRoom(room: Room | null): Promise<void> | void {
|
|||||||
return room.disconnect();
|
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<void> {
|
||||||
|
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() {
|
export function useLiveKit() {
|
||||||
const [room, setRoom] = useState<Room | null>(null);
|
const [room, setRoom] = useState<Room | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
@@ -353,36 +396,12 @@ export function useLiveKit() {
|
|||||||
return;
|
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);
|
await audioManager.setInputDevice(inputDeviceId);
|
||||||
audioManager.setInputVolume(inputVolume);
|
audioManager.setInputVolume(inputVolume);
|
||||||
|
await republishMicrophone(r, lastMicGenRef);
|
||||||
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;
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[LiveKit] Failed to sync mic state:', err);
|
console.error('[LiveKit] Failed to sync mic state:', err);
|
||||||
}
|
}
|
||||||
@@ -391,15 +410,70 @@ export function useLiveKit() {
|
|||||||
syncMic();
|
syncMic();
|
||||||
|
|
||||||
// Re-sync when AudioManager resumes
|
// Re-sync when AudioManager resumes
|
||||||
const unsubscribe = AudioManager.getInstance().onResumed(() => {
|
const unsubscribeResume = AudioManager.getInstance().onResumed(() => {
|
||||||
syncMic();
|
syncMic();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribeResume();
|
||||||
};
|
};
|
||||||
}, [isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
|
}, [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.
|
// Hot-swap the camera source when cameraDeviceId changes mid-call.
|
||||||
// Compares against the published track's actual deviceId (getSettings().deviceId)
|
// Compares against the published track's actual deviceId (getSettings().deviceId)
|
||||||
// rather than a memoised previous store value, so the null → explicit-same-device
|
// 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) {
|
// NOTE: Microphone track-loss is handled at the AudioManager layer via
|
||||||
const mst = publication.track?.mediaStreamTrack;
|
// `onInputTrackEnded`, NOT here. The published mic track is a clone of
|
||||||
if (mst) {
|
// a WebAudio destination node and does not end on hardware loss — only
|
||||||
mst.onended = async () => {
|
// the upstream getUserMedia track does. See the `useEffect` that
|
||||||
// The mic track we publish is the *cloned* output of the AudioManager
|
// subscribes to `AudioManager.onInputTrackEnded` above.
|
||||||
// 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');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guardedUpdate();
|
guardedUpdate();
|
||||||
});
|
});
|
||||||
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => {
|
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user