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
|
||||
next join. Server default comes from env (below).
|
||||
- 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)
|
||||
|
||||
@@ -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).
|
||||
- No media server: N² mesh — fine for small rooms, needs SFU (mediasoup/livekit)
|
||||
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.
|
||||
- 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) {
|
||||
const ctx = S.audioCtx;
|
||||
if (!ctx) return;
|
||||
@@ -191,7 +263,14 @@ function effectiveIce() {
|
||||
}
|
||||
|
||||
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 };
|
||||
return { audio };
|
||||
}
|
||||
@@ -561,6 +640,8 @@ function makePeerRecord(p) {
|
||||
resetAttempts: 0,
|
||||
lastIceNotice: null,
|
||||
icePath: null,
|
||||
qualityTimer: null,
|
||||
lastQualityNotice: 0,
|
||||
els: {},
|
||||
};
|
||||
}
|
||||
@@ -658,16 +739,24 @@ function ensurePC(peer, initiator) {
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
});
|
||||
monitorPeerQuality(peer);
|
||||
}
|
||||
};
|
||||
pc.ontrack = (ev) => onRemoteTrack(peer, ev);
|
||||
|
||||
// send my mic; if I have none, ask to receive theirs
|
||||
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 {
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||
}
|
||||
preferOpus(pc);
|
||||
|
||||
// send my screen share if I am currently sharing when this peer connects
|
||||
if (S.sharing) {
|
||||
@@ -710,6 +799,7 @@ function recreatePeerConnection(peer, { announce }) {
|
||||
send({ type: 'reset', to: peer.id });
|
||||
}
|
||||
if (peer.pc) { try { peer.pc.close(); } catch {} }
|
||||
if (peer.qualityTimer) { clearInterval(peer.qualityTimer); peer.qualityTimer = null; }
|
||||
peer.pc = null;
|
||||
peer.makingOffer = false;
|
||||
peer.ignoreOffer = false;
|
||||
@@ -754,6 +844,7 @@ async function onCandidate(peer, candidate) {
|
||||
|
||||
function destroyPeer(peer) {
|
||||
if (peer.pc) { try { peer.pc.close(); } catch {} peer.pc = null; }
|
||||
if (peer.qualityTimer) { clearInterval(peer.qualityTimer); peer.qualityTimer = null; }
|
||||
detachSpatial(peer);
|
||||
if (peer.audioEl) {
|
||||
try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {}
|
||||
|
||||
Reference in new issue
Block a user