fix: disable browser DSP on screen share audio and harden ICE stats resolution
Screen share audio was muffled/gated because getDisplayMedia used plain `audio: true`, letting the browser apply voice-optimized DSP (NS, AEC, AGC) to desktop audio. Now passes explicit constraints disabling all processing and requesting stereo capture. Also improves WebRTC stats: three-tier ICE candidate-pair discovery (transport → active-bytes heuristic → legacy fallback), height-based simulcast layer inference, and documents mDNS obfuscation limitation.
This commit is contained in:
@@ -32,6 +32,15 @@ export interface VideoTrackStat {
|
|||||||
simulcastLayer: string | null;
|
simulcastLayer: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network-level WebRTC stats for the active ICE transport.
|
||||||
|
*
|
||||||
|
* `serverAddress`, `protocol`, and `candidateType` may legitimately remain null
|
||||||
|
* on strict LANs where Chrome's mDNS IP obfuscation masks candidate-pair addresses.
|
||||||
|
* When ICE candidates use mDNS hostnames (e.g. "abcd-1234.local") instead of raw IPs,
|
||||||
|
* the browser's stats API returns the obfuscated hostname and we cannot resolve the
|
||||||
|
* underlying address. This is a known WebRTC platform limitation, not a bug in our code.
|
||||||
|
*/
|
||||||
export interface NetworkStats {
|
export interface NetworkStats {
|
||||||
ping: number | null;
|
ping: number | null;
|
||||||
packetLoss: number | null;
|
packetLoss: number | null;
|
||||||
@@ -121,11 +130,14 @@ function discoverPeerConnections(room: any): RTCPeerConnection[] {
|
|||||||
return pcs;
|
return pcs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferSimulcastLayer(width: number | null): string | null {
|
function inferSimulcastLayer(width: number | null, height: number | null): string | null {
|
||||||
if (width === null || width <= 0) return null;
|
if (height !== null && height > 0) {
|
||||||
if (width >= 1920) return 'High';
|
if (height >= 1000) return 'High';
|
||||||
if (width >= 1280) return 'Medium';
|
if (height >= 700) return 'Medium';
|
||||||
return 'Low';
|
return 'Low';
|
||||||
|
}
|
||||||
|
if (width !== null && width > 0) return 'Low';
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hook ──
|
// ── Hook ──
|
||||||
@@ -169,7 +181,6 @@ export function useTrackStats(enabled: boolean): TrackStatsSnapshot | null {
|
|||||||
|
|
||||||
// Global codec map across all PCs
|
// Global codec map across all PCs
|
||||||
const globalCodecMap = new Map<string, string>();
|
const globalCodecMap = new Map<string, string>();
|
||||||
let selectedCandidatePairRemoteId: string | null = null;
|
|
||||||
|
|
||||||
for (const pc of pcs) {
|
for (const pc of pcs) {
|
||||||
let reports: RTCStatsReport;
|
let reports: RTCStatsReport;
|
||||||
@@ -179,39 +190,55 @@ export function useTrackStats(enabled: boolean): TrackStatsSnapshot | null {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pass 1: Collect codecs, find transport's selected candidate pair
|
||||||
|
let selectedPairId: string | null = null;
|
||||||
reports.forEach((report: any) => {
|
reports.forEach((report: any) => {
|
||||||
if (report.type === 'codec') {
|
if (report.type === 'codec') {
|
||||||
globalCodecMap.set(report.id, report.mimeType?.split('/')[1] ?? report.mimeType ?? '');
|
globalCodecMap.set(report.id, report.mimeType?.split('/')[1] ?? report.mimeType ?? '');
|
||||||
}
|
}
|
||||||
|
if (report.type === 'transport' && report.selectedCandidatePairId) {
|
||||||
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
|
selectedPairId = report.selectedCandidatePairId;
|
||||||
if (report.currentRoundTripTime != null) {
|
|
||||||
network.ping = Math.round(report.currentRoundTripTime * 1000);
|
|
||||||
}
|
|
||||||
if (!network.serverAddress && report.remoteCandidateId) {
|
|
||||||
selectedCandidatePairRemoteId = report.remoteCandidateId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (report.type === 'remote-candidate' && !network.serverAddress) {
|
|
||||||
const addr = report.address || report.ip;
|
|
||||||
if (addr) {
|
|
||||||
network.serverAddress = addr;
|
|
||||||
network.protocol = report.protocol ?? null;
|
|
||||||
network.candidateType = report.candidateType ?? null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Safari fallback: look up remote candidate by ID
|
// Pass 2: Resolve active candidate pair (three-tier fallback)
|
||||||
if (!network.serverAddress && selectedCandidatePairRemoteId) {
|
let activePair: any = null;
|
||||||
let reports2: RTCStatsReport;
|
if (selectedPairId) {
|
||||||
try {
|
// Tier 1: Spec-correct — transport points directly to the active pair
|
||||||
reports2 = await pc.getStats();
|
activePair = reports.get(selectedPairId);
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
const remoteCandidate = reports2.get(selectedCandidatePairRemoteId);
|
if (!activePair) {
|
||||||
|
// Tier 2: Active-bytes heuristic — the pair carrying the most data IS
|
||||||
|
// the active transport, regardless of state label or mDNS obfuscation
|
||||||
|
let maxBytes = 0;
|
||||||
|
reports.forEach((report: any) => {
|
||||||
|
if (report.type === 'candidate-pair') {
|
||||||
|
const total = (report.bytesSent ?? 0) + (report.bytesReceived ?? 0);
|
||||||
|
if (total > maxBytes) {
|
||||||
|
maxBytes = total;
|
||||||
|
activePair = report;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!activePair) {
|
||||||
|
// Tier 3: Legacy fallback — first pair with state succeeded or in-progress
|
||||||
|
reports.forEach((report: any) => {
|
||||||
|
if (report.type === 'candidate-pair' && !activePair) {
|
||||||
|
if (report.state === 'succeeded' || report.state === 'in-progress') {
|
||||||
|
activePair = report;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3: Extract network info from active pair
|
||||||
|
if (activePair) {
|
||||||
|
if (activePair.currentRoundTripTime != null) {
|
||||||
|
network.ping = Math.round(activePair.currentRoundTripTime * 1000);
|
||||||
|
}
|
||||||
|
if (!network.serverAddress && activePair.remoteCandidateId) {
|
||||||
|
const remoteCandidate = reports.get(activePair.remoteCandidateId);
|
||||||
if (remoteCandidate) {
|
if (remoteCandidate) {
|
||||||
const addr = remoteCandidate.address || remoteCandidate.ip;
|
const addr = remoteCandidate.address || remoteCandidate.ip;
|
||||||
if (addr) {
|
if (addr) {
|
||||||
@@ -222,6 +249,7 @@ export function useTrackStats(enabled: boolean): TrackStatsSnapshot | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step B: Build TrackIdentityMap ──
|
// ── Step B: Build TrackIdentityMap ──
|
||||||
const identityMap = new Map<string, TrackIdentity>();
|
const identityMap = new Map<string, TrackIdentity>();
|
||||||
@@ -502,7 +530,7 @@ export function useTrackStats(enabled: boolean): TrackStatsSnapshot | null {
|
|||||||
height,
|
height,
|
||||||
fps,
|
fps,
|
||||||
qualityLimitation: null,
|
qualityLimitation: null,
|
||||||
simulcastLayer: inferSimulcastLayer(width),
|
simulcastLayer: inferSimulcastLayer(width, height),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,12 @@ export async function startScreenShare(room: Room): Promise<boolean> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const track = await room.localParticipant.setScreenShareEnabled(true, {
|
const track = await room.localParticipant.setScreenShareEnabled(true, {
|
||||||
audio: true,
|
audio: {
|
||||||
|
echoCancellation: false,
|
||||||
|
noiseSuppression: false,
|
||||||
|
autoGainControl: false,
|
||||||
|
channelCount: 2,
|
||||||
|
},
|
||||||
resolution: { width: opts.capture.width, height: opts.capture.height },
|
resolution: { width: opts.capture.width, height: opts.capture.height },
|
||||||
// @ts-ignore — LiveKit accepts frameRate at capture level
|
// @ts-ignore — LiveKit accepts frameRate at capture level
|
||||||
frameRate: opts.capture.frameRate,
|
frameRate: opts.capture.frameRate,
|
||||||
|
|||||||
Reference in New Issue
Block a user