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.
92 lines
3.0 KiB
TypeScript
92 lines
3.0 KiB
TypeScript
/**
|
|
* 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';
|
|
}
|