tune voice quality and monitor media health
This commit is contained in:
1 parent
cb793c7ab6
commit
f2d62ac142
2 files changed
+97
-2
No files matched your search
@@ -20,6 +20,8 @@ PORT=8081 npm start
|
|||||||
- Custom STUN/TURN: gear icon → JSON override in `localStorage`, applied on
|
- Custom STUN/TURN: gear icon → JSON override in `localStorage`, applied on
|
||||||
next join. Server default comes from env (below).
|
next join. Server default comes from env (below).
|
||||||
- Mute (M), deafen (D), per-peer volume, speaking indicators.
|
- Mute (M), deafen (D), per-peer volume, speaking indicators.
|
||||||
|
- Voice capture uses mono 48 kHz Opus-oriented settings, a 64 kbps audio ceiling,
|
||||||
|
and periodic packet-loss, jitter, and RTT quality diagnostics.
|
||||||
|
|
||||||
## ICE configuration (server side)
|
## ICE configuration (server side)
|
||||||
|
|
||||||
@@ -49,5 +51,7 @@ service instead of depending on NAT hairpinning or direct candidate pairs.
|
|||||||
behind the preview ingress (use wss/TLS in front for real deploys).
|
behind the preview ingress (use wss/TLS in front for real deploys).
|
||||||
- No media server: N² mesh — fine for small rooms, needs SFU (mediasoup/livekit)
|
- No media server: N² mesh — fine for small rooms, needs SFU (mediasoup/livekit)
|
||||||
beyond ~6–8 participants.
|
beyond ~6–8 participants.
|
||||||
|
- For larger rooms or consistently lower bandwidth, replace the mesh with an SFU;
|
||||||
|
the current quality tuning improves the P2P path but does not change its N² cost.
|
||||||
- Text channels are decorative. No persistence, no rate limiting.
|
- Text channels are decorative. No persistence, no rate limiting.
|
||||||
- Screen-share negotiation storms on join/leave are expected at small scale.
|
- Screen-share negotiation storms on join/leave are expected at small scale.
|
||||||
+93
-2
@@ -131,6 +131,78 @@ async function selectedIcePath(pc) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tuneAudioSender(sender) {
|
||||||
|
if (!sender?.getParameters || !sender?.setParameters) return;
|
||||||
|
const params = sender.getParameters();
|
||||||
|
if (!params.encodings?.length) params.encodings = [{}];
|
||||||
|
params.encodings[0].maxBitrate = 64000;
|
||||||
|
params.encodings[0].dtx = false;
|
||||||
|
params.encodings[0].priority = 'high';
|
||||||
|
await sender.setParameters(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferOpus(pc) {
|
||||||
|
const capabilities = globalThis.RTCRtpReceiver?.getCapabilities?.('audio');
|
||||||
|
const codecs = capabilities?.codecs || [];
|
||||||
|
const opus = codecs.filter((codec) => codec.mimeType?.toLowerCase() === 'audio/opus');
|
||||||
|
if (!opus.length) return;
|
||||||
|
const ordered = [...opus, ...codecs.filter((codec) => !opus.includes(codec))];
|
||||||
|
for (const transceiver of pc.getTransceivers()) {
|
||||||
|
if (transceiver.receiver?.track?.kind === 'audio' || transceiver.sender?.track?.kind === 'audio') {
|
||||||
|
try { transceiver.setCodecPreferences(ordered); } catch (err) {
|
||||||
|
captureRumIssue('Could not prefer Opus audio codec', 'WARNING', { error: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function monitorPeerQuality(peer) {
|
||||||
|
if (peer.qualityTimer) clearInterval(peer.qualityTimer);
|
||||||
|
peer.qualityTimer = setInterval(async () => {
|
||||||
|
const pc = peer.pc;
|
||||||
|
if (!pc || pc.connectionState !== 'connected' || !pc.getStats) return;
|
||||||
|
try {
|
||||||
|
const stats = await pc.getStats();
|
||||||
|
let inbound;
|
||||||
|
let outbound;
|
||||||
|
let remoteInbound;
|
||||||
|
stats.forEach((report) => {
|
||||||
|
if (report.kind !== 'audio' && report.mediaType !== 'audio') return;
|
||||||
|
if (report.type === 'inbound-rtp') inbound = report;
|
||||||
|
if (report.type === 'outbound-rtp') outbound = report;
|
||||||
|
if (report.type === 'remote-inbound-rtp') remoteInbound = report;
|
||||||
|
});
|
||||||
|
const packets = (inbound?.packetsReceived || 0) + (inbound?.packetsLost || 0);
|
||||||
|
const lossRatio = packets > 20 ? (inbound.packetsLost || 0) / packets : 0;
|
||||||
|
const jitterMs = (inbound?.jitter || 0) * 1000;
|
||||||
|
const rttMs = (remoteInbound?.roundTripTime || 0) * 1000;
|
||||||
|
const issue = lossRatio >= 0.05 || jitterMs >= 80 || rttMs >= 500;
|
||||||
|
if (issue && (!peer.lastQualityNotice || Date.now() - peer.lastQualityNotice > ICE_NOTICE_INTERVAL)) {
|
||||||
|
peer.lastQualityNotice = Date.now();
|
||||||
|
const details = `quality degraded (loss ${Math.round(lossRatio * 100)}%, jitter ${Math.round(jitterMs)}ms${rttMs ? `, RTT ${Math.round(rttMs)}ms` : ''})`;
|
||||||
|
toast(`${peer.name}: ${details}`, 'warn');
|
||||||
|
captureRumIssue('WebRTC audio quality degraded', 'WARNING', {
|
||||||
|
peerId: peer.id,
|
||||||
|
peerName: peer.name,
|
||||||
|
lossRatio,
|
||||||
|
jitterMs,
|
||||||
|
rttMs,
|
||||||
|
packetsReceived: inbound?.packetsReceived,
|
||||||
|
packetsLost: inbound?.packetsLost,
|
||||||
|
bytesReceived: inbound?.bytesReceived,
|
||||||
|
bytesSent: outbound?.bytesSent,
|
||||||
|
path: peer.icePath?.path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
captureRumIssue('Could not inspect WebRTC audio quality', 'WARNING', {
|
||||||
|
peerId: peer.id,
|
||||||
|
error: err?.message || String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
function playRetroCue(name) {
|
function playRetroCue(name) {
|
||||||
const ctx = S.audioCtx;
|
const ctx = S.audioCtx;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
@@ -191,7 +263,14 @@ function effectiveIce() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function microphoneConstraints(deviceId = localStorage.getItem(LS_AUDIO_INPUT)) {
|
function microphoneConstraints(deviceId = localStorage.getItem(LS_AUDIO_INPUT)) {
|
||||||
const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
|
const audio = {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
channelCount: 1,
|
||||||
|
sampleRate: 48000,
|
||||||
|
sampleSize: 16,
|
||||||
|
};
|
||||||
if (deviceId) audio.deviceId = { exact: deviceId };
|
if (deviceId) audio.deviceId = { exact: deviceId };
|
||||||
return { audio };
|
return { audio };
|
||||||
}
|
}
|
||||||
@@ -561,6 +640,8 @@ function makePeerRecord(p) {
|
|||||||
resetAttempts: 0,
|
resetAttempts: 0,
|
||||||
lastIceNotice: null,
|
lastIceNotice: null,
|
||||||
icePath: null,
|
icePath: null,
|
||||||
|
qualityTimer: null,
|
||||||
|
lastQualityNotice: 0,
|
||||||
els: {},
|
els: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -658,16 +739,24 @@ function ensurePC(peer, initiator) {
|
|||||||
error: err?.message || String(err),
|
error: err?.message || String(err),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
monitorPeerQuality(peer);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
pc.ontrack = (ev) => onRemoteTrack(peer, ev);
|
pc.ontrack = (ev) => onRemoteTrack(peer, ev);
|
||||||
|
|
||||||
// send my mic; if I have none, ask to receive theirs
|
// send my mic; if I have none, ask to receive theirs
|
||||||
if (S.micStream) {
|
if (S.micStream) {
|
||||||
for (const track of S.micStream.getAudioTracks()) pc.addTrack(track, S.micStream);
|
for (const track of S.micStream.getAudioTracks()) {
|
||||||
|
const sender = pc.addTrack(track, S.micStream);
|
||||||
|
tuneAudioSender(sender).catch((err) => captureRumIssue('Could not tune Opus audio sender', 'WARNING', {
|
||||||
|
peerId: peer.id,
|
||||||
|
error: err?.message || String(err),
|
||||||
|
}));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||||
}
|
}
|
||||||
|
preferOpus(pc);
|
||||||
|
|
||||||
// send my screen share if I am currently sharing when this peer connects
|
// send my screen share if I am currently sharing when this peer connects
|
||||||
if (S.sharing) {
|
if (S.sharing) {
|
||||||
@@ -710,6 +799,7 @@ function recreatePeerConnection(peer, { announce }) {
|
|||||||
send({ type: 'reset', to: peer.id });
|
send({ type: 'reset', to: peer.id });
|
||||||
}
|
}
|
||||||
if (peer.pc) { try { peer.pc.close(); } catch {} }
|
if (peer.pc) { try { peer.pc.close(); } catch {} }
|
||||||
|
if (peer.qualityTimer) { clearInterval(peer.qualityTimer); peer.qualityTimer = null; }
|
||||||
peer.pc = null;
|
peer.pc = null;
|
||||||
peer.makingOffer = false;
|
peer.makingOffer = false;
|
||||||
peer.ignoreOffer = false;
|
peer.ignoreOffer = false;
|
||||||
@@ -754,6 +844,7 @@ async function onCandidate(peer, candidate) {
|
|||||||
|
|
||||||
function destroyPeer(peer) {
|
function destroyPeer(peer) {
|
||||||
if (peer.pc) { try { peer.pc.close(); } catch {} peer.pc = null; }
|
if (peer.pc) { try { peer.pc.close(); } catch {} peer.pc = null; }
|
||||||
|
if (peer.qualityTimer) { clearInterval(peer.qualityTimer); peer.qualityTimer = null; }
|
||||||
detachSpatial(peer);
|
detachSpatial(peer);
|
||||||
if (peer.audioEl) {
|
if (peer.audioEl) {
|
||||||
try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {}
|
try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {}
|
||||||
|
|||||||
Reference in new issue
Block a user