fix(voice): route media on evidence and switch devices without dropping the call

Outgoing voice was gated on the observer's roster copy of the remote user's
voice state, which is signaling gossip. The signal server broadcasts
`user_left` for any socket it declares dead, so a suspended laptop or a flaky
hop wiped that copy and the observer detached its microphone from a peer that
never left the channel - a silent member with no way back through the UI.

`decideVoicePathRouting` now closes a path only on positive evidence: we left
voice, the peer itself reported another channel or none, or the connection is
gone. Missing gossip holds an established path instead. Opening still needs
confirmation, so a guess never starts sending; the same rule gates playback,
camera video, and the microphone a new connection puts in its first offer.
Peers are also asked for their voice state when a connection or data channel
comes up, so a rebuilt path re-confirms itself.

Alongside it, the microphone can be switched mid-call: capture moves to a
device service and rules, the live track is swapped with `replaceTrack` so
the session is never renegotiated, and the speaking indicator follows the new
stream.
This commit is contained in:
2026-08-14 03:19:29 +02:00
parent a83f5aa750
commit 92c2f578e2
33 changed files with 3149 additions and 317 deletions
@@ -0,0 +1,91 @@
/**
* Pure decisions about which audio device to capture from or play to.
*
* The browser device list is unreliable evidence: it is empty before the
* microphone permission is granted and can hold unlabelled placeholder
* entries, so "my device is not in the list" only means the device is gone
* when the list itself is trustworthy.
*/
/** The stored value that means "follow whatever the operating system picked". */
export const SYSTEM_DEFAULT_AUDIO_DEVICE_ID = '';
export interface AudioDeviceSelectionInput {
/** The user's saved choice, or {@link SYSTEM_DEFAULT_AUDIO_DEVICE_ID}. */
readonly selectedDeviceId: string;
/** Device ids the browser currently reports for this device kind. */
readonly availableDeviceIds: readonly string[];
}
export interface AudioDeviceSelectionOutcome {
/** The device id to use now. */
readonly deviceId: string;
/** True only when a real device list proved the saved choice is gone. */
readonly didFallBack: boolean;
}
export interface MicrophoneConstraintsInput {
readonly deviceId: string;
/** Whether the browser's own noise suppression should run (off when RNNoise handles it). */
readonly browserNoiseSuppression: boolean;
}
/**
* Decide which device to use, falling back to the system default when the
* saved choice is provably gone.
*/
export function resolveAudioDeviceSelection(
input: AudioDeviceSelectionInput
): AudioDeviceSelectionOutcome {
const { selectedDeviceId } = input;
if (!selectedDeviceId) {
return { deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID, didFallBack: false };
}
const knownDeviceIds = input.availableDeviceIds.filter((deviceId) => !!deviceId);
if (knownDeviceIds.length === 0) {
return { deviceId: selectedDeviceId, didFallBack: false };
}
if (knownDeviceIds.includes(selectedDeviceId)) {
return { deviceId: selectedDeviceId, didFallBack: false };
}
return { deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID, didFallBack: true };
}
/**
* Build the `getUserMedia` constraints for the microphone.
*
* `deviceId` is `exact`, because a bare id is only a preference the browser may
* ignore - against real hardware it can hand back the current default, so the
* picker appears to do nothing. Callers must handle `OverconstrainedError` by
* retrying with the system default (see `isDeviceUnavailableError`).
*/
export function buildMicrophoneConstraints(
input: MicrophoneConstraintsInput
): MediaStreamConstraints {
const audio: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: input.browserNoiseSuppression,
autoGainControl: true
};
if (input.deviceId) {
audio.deviceId = { exact: input.deviceId };
}
return { audio, video: false };
}
/**
* Whether a `getUserMedia` rejection means "that exact device is not usable",
* as opposed to a denied permission or a missing API, which must keep failing.
*/
export function isDeviceUnavailableError(error: unknown): boolean {
const name = (error as { name?: string } | null)?.name;
return name === 'OverconstrainedError' || name === 'NotFoundError';
}