refactor: per-track WebRTC diagnostic engine replacing flat stats model
Extract stats polling into useTrackStats hook with per-sender/receiver stats via RTCRtpSender.getStats(), delta-based FPS (fixes 25fps overwrite bug with camera+screenshare), and track-to-source matching via LiveKit TrackPublication identity. ConnectionInfoPopover becomes a pure display component with Network/Audio/Video sections. Remove soft-launch diagnostic loop from useLiveKit.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { Track } from 'livekit-client';
|
import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
|
|
||||||
interface ConnectionInfoPopoverProps {
|
interface ConnectionInfoPopoverProps {
|
||||||
@@ -7,77 +7,9 @@ interface ConnectionInfoPopoverProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectionStats {
|
function formatBitrate(kbps: number): string {
|
||||||
ping: number | null;
|
if (kbps < 1000) return `${Math.round(kbps)} kbps`;
|
||||||
packetLoss: number | null;
|
return `${(kbps / 1000).toFixed(kbps >= 10000 ? 0 : 1)} Mbps`;
|
||||||
jitter: number | null;
|
|
||||||
audioUp: number | null;
|
|
||||||
audioDown: number | null;
|
|
||||||
videoUp: number | null;
|
|
||||||
videoDown: number | null;
|
|
||||||
audioCodec: string | null;
|
|
||||||
videoCodec: string | null;
|
|
||||||
resolution: string | null;
|
|
||||||
fps: number | null;
|
|
||||||
qualityLimitation: string | null;
|
|
||||||
serverAddress: string | null;
|
|
||||||
protocol: string | null;
|
|
||||||
candidateType: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PrevSample {
|
|
||||||
bytes: number;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover all unique RTCPeerConnections from the LiveKit Room engine.
|
|
||||||
* Different livekit-client versions expose the PC at different internal paths.
|
|
||||||
* We collect all of them and deduplicate, so the stats loop can process
|
|
||||||
* outbound-rtp and inbound-rtp reports regardless of transport architecture
|
|
||||||
* (split publisher/subscriber vs unified-plan single PC).
|
|
||||||
*/
|
|
||||||
function discoverPeerConnections(room: any): RTCPeerConnection[] {
|
|
||||||
const engine = room?.engine;
|
|
||||||
if (!engine) return [];
|
|
||||||
|
|
||||||
const pcs: RTCPeerConnection[] = [];
|
|
||||||
const seen = new WeakSet<object>();
|
|
||||||
|
|
||||||
const tryAdd = (val: any) => {
|
|
||||||
if (val && typeof val.getStats === 'function' && !seen.has(val)) {
|
|
||||||
seen.add(val);
|
|
||||||
pcs.push(val);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Current livekit-client (1.x+): engine.pcManager.{publisher,subscriber}.pc
|
|
||||||
tryAdd(engine.pcManager?.publisher?.pc);
|
|
||||||
tryAdd(engine.pcManager?.subscriber?.pc);
|
|
||||||
// Private backing field fallback
|
|
||||||
tryAdd(engine.pcManager?.publisher?._pc);
|
|
||||||
tryAdd(engine.pcManager?.subscriber?._pc);
|
|
||||||
// Older livekit-client paths
|
|
||||||
tryAdd(engine.publisher?.pc);
|
|
||||||
tryAdd(engine.subscriber?.pc);
|
|
||||||
// Unified-plan single PC
|
|
||||||
tryAdd(engine.pc);
|
|
||||||
tryAdd(room.pc);
|
|
||||||
|
|
||||||
return pcs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Determine media kind from a WebRTC stats report (handles both spec and legacy fields). */
|
|
||||||
function reportKind(report: any): 'audio' | 'video' | null {
|
|
||||||
const k = report.kind ?? report.mediaType;
|
|
||||||
if (k === 'audio' || k === 'video') return k;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBitrate(bps: number | null): string {
|
|
||||||
if (bps === null) return '\u2014';
|
|
||||||
if (bps < 1000) return `${Math.round(bps)} kbps`;
|
|
||||||
return `${(bps / 1000).toFixed(bps >= 10000 ? 0 : 1)} Mbps`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pingColor(ms: number): string {
|
function pingColor(ms: number): string {
|
||||||
@@ -98,10 +30,89 @@ function jitterColor(ms: number): string {
|
|||||||
return 'text-discord-red';
|
return 'text-discord-red';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceLabel(source: string): string {
|
||||||
|
switch (source) {
|
||||||
|
case 'microphone': return 'Microphone';
|
||||||
|
case 'camera': return 'Camera';
|
||||||
|
case 'screen_share': return 'Screen';
|
||||||
|
case 'screen_share_audio': return 'Screen Audio';
|
||||||
|
default: return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackLabel(direction: 'send' | 'recv', source: string, participantName: string | null): string {
|
||||||
|
const arrow = direction === 'send' ? '\u2191' : '\u2193';
|
||||||
|
if (direction === 'send') {
|
||||||
|
return `${sourceLabel(source)} ${arrow}`;
|
||||||
|
}
|
||||||
|
const name = participantName ?? 'Remote';
|
||||||
|
return `${name} ${sourceLabel(source)} ${arrow}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Row = ({ label, value, colorClass }: { label: string; value: string; colorClass?: string }) => (
|
||||||
|
<div className="flex items-center justify-between py-[3px]">
|
||||||
|
<span className="text-[12px] text-discord-text-muted">{label}</span>
|
||||||
|
<span className={`text-[12px] font-medium ${colorClass ?? 'text-discord-text-secondary'}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Divider = () => <div className="border-t border-[#2b2d31] my-1" />;
|
||||||
|
|
||||||
|
const SectionHeader = ({ title }: { title: string }) => (
|
||||||
|
<div className="text-[10px] font-bold text-discord-text-muted uppercase tracking-wider pt-1 pb-[2px]">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function AudioTrackRow({ track }: { track: AudioTrackStat }) {
|
||||||
|
const label = trackLabel(track.direction, track.source, track.participantName);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-[3px]">
|
||||||
|
<span className="text-[12px] text-discord-text-muted truncate mr-2">{label}</span>
|
||||||
|
<span className="text-[12px] font-medium text-discord-text-secondary whitespace-nowrap">
|
||||||
|
{formatBitrate(track.bitrate)}
|
||||||
|
{track.codec && <span className="text-discord-text-muted ml-2">{track.codec}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoTrackRow({ track }: { track: VideoTrackStat }) {
|
||||||
|
const label = trackLabel(track.direction, track.source, track.participantName);
|
||||||
|
const resolution = (track.width && track.height) ? `${track.width}\u00d7${track.height}` : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-[3px]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[12px] text-discord-text-muted truncate mr-2">{label}</span>
|
||||||
|
<span className="text-[12px] font-medium text-discord-text-secondary whitespace-nowrap">
|
||||||
|
{formatBitrate(track.bitrate)}
|
||||||
|
{track.codec && <span className="text-discord-text-muted ml-2">{track.codec}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(resolution || track.fps !== null || track.qualityLimitation || track.simulcastLayer) && (
|
||||||
|
<div className="flex items-center justify-between pl-3">
|
||||||
|
<span className="text-[11px] text-discord-text-muted">
|
||||||
|
{resolution && `${resolution}`}
|
||||||
|
{track.fps !== null && ` @${track.fps}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-discord-text-muted">
|
||||||
|
{track.direction === 'send' && track.qualityLimitation && track.qualityLimitation !== 'none'
|
||||||
|
? track.qualityLimitation
|
||||||
|
: ''}
|
||||||
|
{track.direction === 'recv' && track.simulcastLayer
|
||||||
|
? track.simulcastLayer
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverProps) {
|
export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverProps) {
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const prevSampleRef = useRef<Map<string, PrevSample>>(new Map());
|
const stats = useTrackStats(open);
|
||||||
const [stats, setStats] = useState<ConnectionStats | null>(null);
|
|
||||||
|
|
||||||
// Click-outside to close
|
// Click-outside to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,266 +126,9 @@ export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverPr
|
|||||||
return () => document.removeEventListener('mousedown', handleClick);
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
// Stats polling — only while open
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
prevSampleRef.current.clear();
|
|
||||||
setStats(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
const room = getActiveRoom();
|
|
||||||
if (!room) {
|
|
||||||
setStats(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pcs = discoverPeerConnections(room as any);
|
|
||||||
if (pcs.length === 0) {
|
|
||||||
setStats(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: ConnectionStats = {
|
|
||||||
ping: null,
|
|
||||||
packetLoss: null,
|
|
||||||
jitter: null,
|
|
||||||
audioUp: null,
|
|
||||||
audioDown: null,
|
|
||||||
videoUp: null,
|
|
||||||
videoDown: null,
|
|
||||||
audioCodec: null,
|
|
||||||
videoCodec: null,
|
|
||||||
resolution: null,
|
|
||||||
fps: null,
|
|
||||||
qualityLimitation: null,
|
|
||||||
serverAddress: null,
|
|
||||||
protocol: null,
|
|
||||||
candidateType: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const prev = prevSampleRef.current;
|
|
||||||
const now = performance.now();
|
|
||||||
|
|
||||||
// Accumulators for inbound aggregate metrics
|
|
||||||
let totalPacketsReceived = 0;
|
|
||||||
let totalPacketsLost = 0;
|
|
||||||
let totalAudioDown = 0;
|
|
||||||
let hasAudioDown = false;
|
|
||||||
let totalVideoDown = 0;
|
|
||||||
let hasVideoDown = false;
|
|
||||||
|
|
||||||
// Track candidate-pair remote ID for Safari fallback (see after forEach)
|
|
||||||
let selectedCandidatePairRemoteId: string | null = null;
|
|
||||||
|
|
||||||
// Process stats from ALL discovered PeerConnections.
|
|
||||||
// Report types (outbound-rtp, inbound-rtp, candidate-pair, etc.) are
|
|
||||||
// self-describing, so we don't need to know which PC they came from.
|
|
||||||
for (const pc of pcs) {
|
|
||||||
let reports: RTCStatsReport;
|
|
||||||
try {
|
|
||||||
reports = await pc.getStats();
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build codec map for this PC's stats (codecId → short name)
|
|
||||||
const codecMap = new Map<string, string>();
|
|
||||||
reports.forEach((report: any) => {
|
|
||||||
if (report.type === 'codec') {
|
|
||||||
codecMap.set(report.id, report.mimeType?.split('/')[1] ?? report.mimeType ?? '');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
reports.forEach((report: any) => {
|
|
||||||
const kind = reportKind(report);
|
|
||||||
|
|
||||||
// ── RTT from candidate-pair ──
|
|
||||||
if (
|
|
||||||
report.type === 'candidate-pair' &&
|
|
||||||
report.state === 'succeeded'
|
|
||||||
) {
|
|
||||||
if (report.currentRoundTripTime != null) {
|
|
||||||
result.ping = Math.round(report.currentRoundTripTime * 1000);
|
|
||||||
}
|
|
||||||
// Safari fallback: collect remote candidate ID for post-loop lookup
|
|
||||||
if (!result.serverAddress && report.remoteCandidateId) {
|
|
||||||
selectedCandidatePairRemoteId = report.remoteCandidateId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Outbound (publisher) ──
|
|
||||||
if (report.type === 'outbound-rtp') {
|
|
||||||
const key = `out-${report.ssrc}`;
|
|
||||||
const prevEntry = prev.get(key);
|
|
||||||
|
|
||||||
if (kind === 'audio') {
|
|
||||||
if (prevEntry) {
|
|
||||||
const deltaBits = (report.bytesSent - prevEntry.bytes) * 8;
|
|
||||||
const deltaMs = now - prevEntry.timestamp;
|
|
||||||
if (deltaMs > 0) result.audioUp = deltaBits / deltaMs;
|
|
||||||
}
|
|
||||||
prev.set(key, { bytes: report.bytesSent, timestamp: now });
|
|
||||||
|
|
||||||
if (report.codecId && codecMap.has(report.codecId)) {
|
|
||||||
result.audioCodec = codecMap.get(report.codecId)!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === 'video') {
|
|
||||||
if (prevEntry) {
|
|
||||||
const deltaBits = (report.bytesSent - prevEntry.bytes) * 8;
|
|
||||||
const deltaMs = now - prevEntry.timestamp;
|
|
||||||
if (deltaMs > 0) {
|
|
||||||
const bitrate = deltaBits / deltaMs;
|
|
||||||
// Only accumulate when bitrate > 0 — filters stale outbound-rtp
|
|
||||||
// reports that Safari/Chrome retain after camera/screenshare stops
|
|
||||||
if (bitrate > 0) {
|
|
||||||
result.videoUp = (result.videoUp ?? 0) + bitrate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prev.set(key, { bytes: report.bytesSent, timestamp: now });
|
|
||||||
|
|
||||||
if (report.codecId && codecMap.has(report.codecId)) {
|
|
||||||
result.videoCodec = codecMap.get(report.codecId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolution + FPS — take the active track (non-zero dimensions)
|
|
||||||
if (report.frameWidth > 0 && report.frameHeight > 0) {
|
|
||||||
result.resolution = `${report.frameWidth}\u00d7${report.frameHeight}`;
|
|
||||||
result.fps = Math.round(report.framesPerSecond || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (report.qualityLimitationReason) {
|
|
||||||
result.qualityLimitation = report.qualityLimitationReason;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inbound (subscriber) ──
|
|
||||||
if (report.type === 'inbound-rtp') {
|
|
||||||
const key = `in-${report.ssrc}`;
|
|
||||||
const prevEntry = prev.get(key);
|
|
||||||
|
|
||||||
if (kind === 'audio') {
|
|
||||||
if (result.jitter === null && report.jitter != null) {
|
|
||||||
result.jitter = Math.round(report.jitter * 1000);
|
|
||||||
}
|
|
||||||
totalPacketsReceived += report.packetsReceived || 0;
|
|
||||||
totalPacketsLost += report.packetsLost || 0;
|
|
||||||
|
|
||||||
if (prevEntry) {
|
|
||||||
const deltaBits = (report.bytesReceived - prevEntry.bytes) * 8;
|
|
||||||
const deltaMs = now - prevEntry.timestamp;
|
|
||||||
if (deltaMs > 0) {
|
|
||||||
totalAudioDown += deltaBits / deltaMs;
|
|
||||||
hasAudioDown = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prev.set(key, { bytes: report.bytesReceived, timestamp: now });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === 'video') {
|
|
||||||
totalPacketsReceived += report.packetsReceived || 0;
|
|
||||||
totalPacketsLost += report.packetsLost || 0;
|
|
||||||
|
|
||||||
if (prevEntry) {
|
|
||||||
const deltaBits = (report.bytesReceived - prevEntry.bytes) * 8;
|
|
||||||
const deltaMs = now - prevEntry.timestamp;
|
|
||||||
if (deltaMs > 0) {
|
|
||||||
const bitrate = deltaBits / deltaMs;
|
|
||||||
// Only accumulate when bitrate > 0 — filters stale inbound-rtp
|
|
||||||
if (bitrate > 0) {
|
|
||||||
totalVideoDown += bitrate;
|
|
||||||
hasVideoDown = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prev.set(key, { bytes: report.bytesReceived, timestamp: now });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Server address from remote-candidate ──
|
|
||||||
// Safari may leave `address` empty but populate the legacy `ip` field
|
|
||||||
if (report.type === 'remote-candidate' && !result.serverAddress) {
|
|
||||||
const addr = report.address || report.ip;
|
|
||||||
if (addr) {
|
|
||||||
result.serverAddress = addr;
|
|
||||||
result.protocol = report.protocol ?? null;
|
|
||||||
result.candidateType = report.candidateType ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Safari fallback: look up remote candidate by ID from candidate-pair
|
|
||||||
if (!result.serverAddress && selectedCandidatePairRemoteId) {
|
|
||||||
const remoteCandidate = reports.get(selectedCandidatePairRemoteId);
|
|
||||||
if (remoteCandidate) {
|
|
||||||
const addr = remoteCandidate.address || remoteCandidate.ip;
|
|
||||||
if (addr) {
|
|
||||||
result.serverAddress = addr;
|
|
||||||
result.protocol = remoteCandidate.protocol ?? null;
|
|
||||||
result.candidateType = remoteCandidate.candidateType ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safari fallback: resolution from MediaStreamTrack.getSettings()
|
|
||||||
// Safari omits frameWidth/frameHeight from outbound-rtp stats entirely
|
|
||||||
if (!result.resolution && result.videoUp !== null && result.videoUp > 0) {
|
|
||||||
const localPart = room.localParticipant;
|
|
||||||
for (const pub of localPart.trackPublications.values()) {
|
|
||||||
if (pub.source === Track.Source.Camera || pub.source === Track.Source.ScreenShare) {
|
|
||||||
const mt = pub.track?.mediaStreamTrack;
|
|
||||||
if (mt && mt.readyState === 'live') {
|
|
||||||
const s = mt.getSettings();
|
|
||||||
if (s.width && s.height) {
|
|
||||||
result.resolution = `${s.width}\u00d7${s.height}`;
|
|
||||||
result.fps = s.frameRate ? Math.round(s.frameRate) : null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finalize aggregated inbound metrics
|
|
||||||
if (hasAudioDown) result.audioDown = totalAudioDown;
|
|
||||||
if (hasVideoDown) result.videoDown = totalVideoDown;
|
|
||||||
|
|
||||||
const totalPackets = totalPacketsReceived + totalPacketsLost;
|
|
||||||
if (totalPackets > 0) {
|
|
||||||
result.packetLoss = (totalPacketsLost / totalPackets) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStats(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
poll();
|
|
||||||
const interval = setInterval(poll, 1500);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
const hasVideo = stats && (
|
|
||||||
(stats.videoUp !== null && stats.videoUp > 0) ||
|
|
||||||
(stats.videoDown !== null && stats.videoDown > 0) ||
|
|
||||||
stats.resolution !== null
|
|
||||||
);
|
|
||||||
|
|
||||||
const Row = ({ label, value, colorClass }: { label: string; value: string; colorClass?: string }) => (
|
|
||||||
<div className="flex items-center justify-between py-[3px]">
|
|
||||||
<span className="text-[12px] text-discord-text-muted">{label}</span>
|
|
||||||
<span className={`text-[12px] font-medium ${colorClass ?? 'text-discord-text-secondary'}`}>{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Divider = () => <div className="border-t border-[#2b2d31] my-1" />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -393,66 +147,53 @@ export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverPr
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Network */}
|
{/* Network */}
|
||||||
|
<SectionHeader title="Network" />
|
||||||
<Row
|
<Row
|
||||||
label="Ping"
|
label="Ping"
|
||||||
value={stats.ping !== null ? `${stats.ping} ms` : '\u2014'}
|
value={stats.network.ping !== null ? `${stats.network.ping} ms` : '\u2014'}
|
||||||
colorClass={stats.ping !== null ? pingColor(stats.ping) : undefined}
|
colorClass={stats.network.ping !== null ? pingColor(stats.network.ping) : undefined}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="Packet Loss"
|
label="Packet Loss"
|
||||||
value={stats.packetLoss !== null ? `${stats.packetLoss.toFixed(1)}%` : '\u2014'}
|
value={stats.network.packetLoss !== null ? `${stats.network.packetLoss.toFixed(1)}%` : '\u2014'}
|
||||||
colorClass={stats.packetLoss !== null ? lossColor(stats.packetLoss) : undefined}
|
colorClass={stats.network.packetLoss !== null ? lossColor(stats.network.packetLoss) : undefined}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="Jitter"
|
label="Jitter"
|
||||||
value={stats.jitter !== null ? `${stats.jitter} ms` : '\u2014'}
|
value={stats.network.jitter !== null ? `${stats.network.jitter} ms` : '\u2014'}
|
||||||
colorClass={stats.jitter !== null ? jitterColor(stats.jitter) : undefined}
|
colorClass={stats.network.jitter !== null ? jitterColor(stats.network.jitter) : undefined}
|
||||||
/>
|
/>
|
||||||
|
<Row label="Server" value={stats.network.serverAddress ?? '\u2014'} />
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Bitrates */}
|
|
||||||
<Row label="Audio \u2191" value={formatBitrate(stats.audioUp)} />
|
|
||||||
<Row label="Audio \u2193" value={formatBitrate(stats.audioDown)} />
|
|
||||||
{hasVideo && (
|
|
||||||
<>
|
|
||||||
<Row label="Video \u2191" value={formatBitrate(stats.videoUp)} />
|
|
||||||
<Row label="Video \u2193" value={formatBitrate(stats.videoDown)} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Codec + media info */}
|
|
||||||
<Row
|
|
||||||
label="Codec"
|
|
||||||
value={[stats.audioCodec, stats.videoCodec].filter(Boolean).join(' / ') || '\u2014'}
|
|
||||||
/>
|
|
||||||
{hasVideo && (
|
|
||||||
<>
|
|
||||||
<Row
|
|
||||||
label="Resolution"
|
|
||||||
value={stats.resolution ? `${stats.resolution}${stats.fps ? ` @${stats.fps}` : ''}` : '\u2014'}
|
|
||||||
/>
|
|
||||||
<Row
|
|
||||||
label="Quality"
|
|
||||||
value={stats.qualityLimitation ?? '\u2014'}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Server */}
|
|
||||||
<Row label="Server" value={stats.serverAddress ?? '\u2014'} />
|
|
||||||
<Row
|
<Row
|
||||||
label="Protocol"
|
label="Protocol"
|
||||||
value={
|
value={
|
||||||
stats.protocol
|
stats.network.protocol
|
||||||
? `${stats.protocol}${stats.candidateType ? ` (${stats.candidateType})` : ''}`
|
? `${stats.network.protocol}${stats.network.candidateType ? ` (${stats.network.candidateType})` : ''}`
|
||||||
: '\u2014'
|
: '\u2014'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Audio Tracks */}
|
||||||
|
{stats.audioTracks.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<SectionHeader title="Audio" />
|
||||||
|
{stats.audioTracks.map((t) => (
|
||||||
|
<AudioTrackRow key={t.key} track={t} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Video Tracks */}
|
||||||
|
{stats.videoTracks.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<SectionHeader title="Video" />
|
||||||
|
{stats.videoTracks.map((t) => (
|
||||||
|
<VideoTrackRow key={t.key} track={t} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -536,29 +536,6 @@ export function useLiveKit() {
|
|||||||
updateActiveTracks().catch(() => {});
|
updateActiveTracks().catch(() => {});
|
||||||
}, [room, videoQuality, isScreenSharing, isCameraOn]);
|
}, [room, videoQuality, isScreenSharing, isCameraOn]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!room) return;
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const engine = (room as any).engine;
|
|
||||||
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc || (room as any).pc;
|
|
||||||
if (!pc) return;
|
|
||||||
const stats = await (pc as RTCPeerConnection).getStats();
|
|
||||||
stats.forEach((report: any) => {
|
|
||||||
if (report.type === 'outbound-rtp' && report.kind === 'video' && report.frameWidth > 0) {
|
|
||||||
const fps = Math.round(report.framesPerSecond || 0);
|
|
||||||
const key = `_lastBytes_${report.ssrc}`;
|
|
||||||
const lastBytes = (window as any)[key] || report.bytesSent;
|
|
||||||
const bitrate = (((report.bytesSent - lastBytes) * 8) / 5000 / 1000).toFixed(2);
|
|
||||||
(window as any)[key] = report.bytesSent;
|
|
||||||
console.log(`[Soft-Launch Diagnostic] ${report.frameWidth}x${report.frameHeight} @ ${fps} FPS (~${bitrate} Mbps) | ${report.qualityLimitationReason}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) { }
|
|
||||||
}, 5000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [room]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => { _connectGeneration++; SpeakingDetector.getInstance().clear(); if (roomRef.current) { destroyRoom(roomRef.current); roomRef.current = null; _activeRoom = null; } };
|
return () => { _connectGeneration++; SpeakingDetector.getInstance().clear(); if (roomRef.current) { destroyRoom(roomRef.current); roomRef.current = null; _activeRoom = null; } };
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Track } from 'livekit-client';
|
||||||
|
import { getActiveRoom } from './useLiveKit';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
type TrackSource = 'microphone' | 'camera' | 'screen_share' | 'screen_share_audio' | 'unknown';
|
||||||
|
type TrackDirection = 'send' | 'recv';
|
||||||
|
|
||||||
|
export interface AudioTrackStat {
|
||||||
|
key: string;
|
||||||
|
direction: TrackDirection;
|
||||||
|
source: TrackSource;
|
||||||
|
participantName: string | null;
|
||||||
|
bitrate: number;
|
||||||
|
codec: string | null;
|
||||||
|
packetLoss: number | null;
|
||||||
|
jitter: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoTrackStat {
|
||||||
|
key: string;
|
||||||
|
direction: TrackDirection;
|
||||||
|
source: TrackSource;
|
||||||
|
participantName: string | null;
|
||||||
|
bitrate: number;
|
||||||
|
codec: string | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
fps: number | null;
|
||||||
|
qualityLimitation: string | null;
|
||||||
|
simulcastLayer: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkStats {
|
||||||
|
ping: number | null;
|
||||||
|
packetLoss: number | null;
|
||||||
|
jitter: number | null;
|
||||||
|
serverAddress: string | null;
|
||||||
|
protocol: string | null;
|
||||||
|
candidateType: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackStatsSnapshot {
|
||||||
|
network: NetworkStats;
|
||||||
|
audioTracks: AudioTrackStat[];
|
||||||
|
videoTracks: VideoTrackStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal types ──
|
||||||
|
|
||||||
|
interface TrackIdentity {
|
||||||
|
source: TrackSource;
|
||||||
|
direction: TrackDirection;
|
||||||
|
participantName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrevSample {
|
||||||
|
bytes: number;
|
||||||
|
frames: number;
|
||||||
|
timestamp: number;
|
||||||
|
packetsRecv: number;
|
||||||
|
packetsLost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function mapSource(lkSource: Track.Source): TrackSource {
|
||||||
|
switch (lkSource) {
|
||||||
|
case Track.Source.Microphone: return 'microphone';
|
||||||
|
case Track.Source.Camera: return 'camera';
|
||||||
|
case Track.Source.ScreenShare: return 'screen_share';
|
||||||
|
case Track.Source.ScreenShareAudio: return 'screen_share_audio';
|
||||||
|
default: return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUsername(identity: string): string {
|
||||||
|
const parts = identity.split(':');
|
||||||
|
return parts[1] ?? identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Determine media kind from a WebRTC stats report (handles both spec and legacy fields). */
|
||||||
|
function reportKind(report: any): 'audio' | 'video' | null {
|
||||||
|
const k = report.kind ?? report.mediaType;
|
||||||
|
if (k === 'audio' || k === 'video') return k;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover all unique RTCPeerConnections from the LiveKit Room engine.
|
||||||
|
* Different livekit-client versions expose the PC at different internal paths.
|
||||||
|
*/
|
||||||
|
function discoverPeerConnections(room: any): RTCPeerConnection[] {
|
||||||
|
const engine = room?.engine;
|
||||||
|
if (!engine) return [];
|
||||||
|
|
||||||
|
const pcs: RTCPeerConnection[] = [];
|
||||||
|
const seen = new WeakSet<object>();
|
||||||
|
|
||||||
|
const tryAdd = (val: any) => {
|
||||||
|
if (val && typeof val.getStats === 'function' && !seen.has(val)) {
|
||||||
|
seen.add(val);
|
||||||
|
pcs.push(val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Current livekit-client (1.x+): engine.pcManager.{publisher,subscriber}.pc
|
||||||
|
tryAdd(engine.pcManager?.publisher?.pc);
|
||||||
|
tryAdd(engine.pcManager?.subscriber?.pc);
|
||||||
|
// Private backing field fallback
|
||||||
|
tryAdd(engine.pcManager?.publisher?._pc);
|
||||||
|
tryAdd(engine.pcManager?.subscriber?._pc);
|
||||||
|
// Older livekit-client paths
|
||||||
|
tryAdd(engine.publisher?.pc);
|
||||||
|
tryAdd(engine.subscriber?.pc);
|
||||||
|
// Unified-plan single PC
|
||||||
|
tryAdd(engine.pc);
|
||||||
|
tryAdd(room.pc);
|
||||||
|
|
||||||
|
return pcs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferSimulcastLayer(width: number | null): string | null {
|
||||||
|
if (width === null || width <= 0) return null;
|
||||||
|
if (width >= 1920) return 'High';
|
||||||
|
if (width >= 1280) return 'Medium';
|
||||||
|
return 'Low';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hook ──
|
||||||
|
|
||||||
|
export function useTrackStats(enabled: boolean): TrackStatsSnapshot | null {
|
||||||
|
const [snapshot, setSnapshot] = useState<TrackStatsSnapshot | null>(null);
|
||||||
|
const prevSampleRef = useRef<Map<string, PrevSample>>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
prevSampleRef.current.clear();
|
||||||
|
setSnapshot(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
const room = getActiveRoom();
|
||||||
|
if (!room) {
|
||||||
|
setSnapshot(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pcs = discoverPeerConnections(room as any);
|
||||||
|
if (pcs.length === 0) {
|
||||||
|
setSnapshot(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = prevSampleRef.current;
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
// ── Step A: Network stats + codec map from pc.getStats() ──
|
||||||
|
const network: NetworkStats = {
|
||||||
|
ping: null,
|
||||||
|
packetLoss: null,
|
||||||
|
jitter: null,
|
||||||
|
serverAddress: null,
|
||||||
|
protocol: null,
|
||||||
|
candidateType: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global codec map across all PCs
|
||||||
|
const globalCodecMap = new Map<string, string>();
|
||||||
|
let selectedCandidatePairRemoteId: string | null = null;
|
||||||
|
|
||||||
|
for (const pc of pcs) {
|
||||||
|
let reports: RTCStatsReport;
|
||||||
|
try {
|
||||||
|
reports = await pc.getStats();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
reports.forEach((report: any) => {
|
||||||
|
if (report.type === 'codec') {
|
||||||
|
globalCodecMap.set(report.id, report.mimeType?.split('/')[1] ?? report.mimeType ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
|
||||||
|
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
|
||||||
|
if (!network.serverAddress && selectedCandidatePairRemoteId) {
|
||||||
|
let reports2: RTCStatsReport;
|
||||||
|
try {
|
||||||
|
reports2 = await pc.getStats();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const remoteCandidate = reports2.get(selectedCandidatePairRemoteId);
|
||||||
|
if (remoteCandidate) {
|
||||||
|
const addr = remoteCandidate.address || remoteCandidate.ip;
|
||||||
|
if (addr) {
|
||||||
|
network.serverAddress = addr;
|
||||||
|
network.protocol = remoteCandidate.protocol ?? null;
|
||||||
|
network.candidateType = remoteCandidate.candidateType ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step B: Build TrackIdentityMap ──
|
||||||
|
const identityMap = new Map<string, TrackIdentity>();
|
||||||
|
|
||||||
|
// Local tracks
|
||||||
|
for (const pub of room.localParticipant.trackPublications.values()) {
|
||||||
|
const mst = pub.track?.mediaStreamTrack;
|
||||||
|
if (mst) {
|
||||||
|
identityMap.set(mst.id, {
|
||||||
|
source: mapSource(pub.source),
|
||||||
|
direction: 'send',
|
||||||
|
participantName: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote tracks
|
||||||
|
for (const [, rp] of room.remoteParticipants) {
|
||||||
|
const name = parseUsername(rp.identity);
|
||||||
|
for (const pub of rp.trackPublications.values()) {
|
||||||
|
const mst = pub.track?.mediaStreamTrack;
|
||||||
|
if (mst) {
|
||||||
|
identityMap.set(mst.id, {
|
||||||
|
source: mapSource(pub.source),
|
||||||
|
direction: 'recv',
|
||||||
|
participantName: name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step C & D: Per-sender and per-receiver stats ──
|
||||||
|
const audioTracks: AudioTrackStat[] = [];
|
||||||
|
const videoTracks: VideoTrackStat[] = [];
|
||||||
|
let totalPacketsReceived = 0;
|
||||||
|
let totalPacketsLost = 0;
|
||||||
|
const seenKeys = new Set<string>();
|
||||||
|
|
||||||
|
for (const pc of pcs) {
|
||||||
|
// Process senders (outbound)
|
||||||
|
for (const sender of pc.getSenders()) {
|
||||||
|
if (!sender.track) continue;
|
||||||
|
|
||||||
|
const identity = identityMap.get(sender.track.id);
|
||||||
|
if (!identity) continue;
|
||||||
|
|
||||||
|
let senderStats: RTCStatsReport;
|
||||||
|
try {
|
||||||
|
senderStats = await sender.getStats();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build per-sender codec map
|
||||||
|
const senderCodecMap = new Map<string, string>();
|
||||||
|
senderStats.forEach((report: any) => {
|
||||||
|
if (report.type === 'codec') {
|
||||||
|
senderCodecMap.set(report.id, report.mimeType?.split('/')[1] ?? report.mimeType ?? '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
senderStats.forEach((report: any) => {
|
||||||
|
if (report.type !== 'outbound-rtp') return;
|
||||||
|
|
||||||
|
const kind = reportKind(report);
|
||||||
|
if (!kind) return;
|
||||||
|
|
||||||
|
const ssrcKey = `out-${report.ssrc}`;
|
||||||
|
if (seenKeys.has(ssrcKey)) return;
|
||||||
|
seenKeys.add(ssrcKey);
|
||||||
|
|
||||||
|
const prevEntry = prev.get(ssrcKey);
|
||||||
|
const deltaMs = prevEntry ? now - prevEntry.timestamp : 0;
|
||||||
|
const deltaSeconds = deltaMs / 1000;
|
||||||
|
|
||||||
|
// Delta bitrate
|
||||||
|
let bitrate = 0;
|
||||||
|
if (prevEntry && deltaMs > 0) {
|
||||||
|
bitrate = ((report.bytesSent - prevEntry.bytes) * 8) / deltaMs; // kbps
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codec: try sender-scoped first, then global fallback
|
||||||
|
let codec: string | null = null;
|
||||||
|
if (report.codecId) {
|
||||||
|
codec = senderCodecMap.get(report.codecId) ?? globalCodecMap.get(report.codecId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'audio') {
|
||||||
|
prev.set(ssrcKey, {
|
||||||
|
bytes: report.bytesSent,
|
||||||
|
frames: 0,
|
||||||
|
timestamp: now,
|
||||||
|
packetsRecv: 0,
|
||||||
|
packetsLost: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bitrate > 0) {
|
||||||
|
audioTracks.push({
|
||||||
|
key: ssrcKey,
|
||||||
|
direction: 'send',
|
||||||
|
source: identity.source,
|
||||||
|
participantName: null,
|
||||||
|
bitrate,
|
||||||
|
codec,
|
||||||
|
packetLoss: null,
|
||||||
|
jitter: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'video') {
|
||||||
|
const framesEncoded = report.framesEncoded ?? 0;
|
||||||
|
const prevFrames = prevEntry?.frames ?? 0;
|
||||||
|
const deltaFrames = framesEncoded - prevFrames;
|
||||||
|
const fps = (prevEntry && deltaSeconds > 0) ? Math.round(deltaFrames / deltaSeconds) : null;
|
||||||
|
|
||||||
|
let width: number | null = report.frameWidth > 0 ? report.frameWidth : null;
|
||||||
|
let height: number | null = report.frameHeight > 0 ? report.frameHeight : null;
|
||||||
|
|
||||||
|
// Safari fallback: resolution from MediaStreamTrack.getSettings()
|
||||||
|
const senderTrack = sender.track;
|
||||||
|
if (width === null && senderTrack && senderTrack.readyState === 'live') {
|
||||||
|
const settings = senderTrack.getSettings();
|
||||||
|
if (settings.width && settings.height) {
|
||||||
|
width = settings.width;
|
||||||
|
height = settings.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prev.set(ssrcKey, {
|
||||||
|
bytes: report.bytesSent,
|
||||||
|
frames: framesEncoded,
|
||||||
|
timestamp: now,
|
||||||
|
packetsRecv: 0,
|
||||||
|
packetsLost: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bitrate > 0 || (width !== null && height !== null)) {
|
||||||
|
videoTracks.push({
|
||||||
|
key: ssrcKey,
|
||||||
|
direction: 'send',
|
||||||
|
source: identity.source,
|
||||||
|
participantName: null,
|
||||||
|
bitrate,
|
||||||
|
codec,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
qualityLimitation: report.qualityLimitationReason ?? null,
|
||||||
|
simulcastLayer: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process receivers (inbound)
|
||||||
|
for (const receiver of pc.getReceivers()) {
|
||||||
|
if (!receiver.track) continue;
|
||||||
|
|
||||||
|
const identity = identityMap.get(receiver.track.id);
|
||||||
|
if (!identity) continue;
|
||||||
|
|
||||||
|
let receiverStats: RTCStatsReport;
|
||||||
|
try {
|
||||||
|
receiverStats = await receiver.getStats();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build per-receiver codec map
|
||||||
|
const recvCodecMap = new Map<string, string>();
|
||||||
|
receiverStats.forEach((report: any) => {
|
||||||
|
if (report.type === 'codec') {
|
||||||
|
recvCodecMap.set(report.id, report.mimeType?.split('/')[1] ?? report.mimeType ?? '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
receiverStats.forEach((report: any) => {
|
||||||
|
if (report.type !== 'inbound-rtp') return;
|
||||||
|
|
||||||
|
const kind = reportKind(report);
|
||||||
|
if (!kind) return;
|
||||||
|
|
||||||
|
const ssrcKey = `in-${report.ssrc}`;
|
||||||
|
if (seenKeys.has(ssrcKey)) return;
|
||||||
|
seenKeys.add(ssrcKey);
|
||||||
|
|
||||||
|
const prevEntry = prev.get(ssrcKey);
|
||||||
|
const deltaMs = prevEntry ? now - prevEntry.timestamp : 0;
|
||||||
|
const deltaSeconds = deltaMs / 1000;
|
||||||
|
|
||||||
|
// Delta bitrate
|
||||||
|
let bitrate = 0;
|
||||||
|
if (prevEntry && deltaMs > 0) {
|
||||||
|
bitrate = ((report.bytesReceived - prevEntry.bytes) * 8) / deltaMs; // kbps
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packet loss
|
||||||
|
const packetsRecv = report.packetsReceived ?? 0;
|
||||||
|
const packetsLost = report.packetsLost ?? 0;
|
||||||
|
totalPacketsReceived += packetsRecv;
|
||||||
|
totalPacketsLost += packetsLost;
|
||||||
|
|
||||||
|
let perTrackLoss: number | null = null;
|
||||||
|
if (prevEntry) {
|
||||||
|
const deltaRecv = packetsRecv - prevEntry.packetsRecv;
|
||||||
|
const deltaLost = packetsLost - prevEntry.packetsLost;
|
||||||
|
const deltaTotal = deltaRecv + deltaLost;
|
||||||
|
if (deltaTotal > 0) {
|
||||||
|
perTrackLoss = (deltaLost / deltaTotal) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jitter
|
||||||
|
const jitter = report.jitter != null ? Math.round(report.jitter * 1000) : null;
|
||||||
|
|
||||||
|
// Codec
|
||||||
|
let codec: string | null = null;
|
||||||
|
if (report.codecId) {
|
||||||
|
codec = recvCodecMap.get(report.codecId) ?? globalCodecMap.get(report.codecId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'audio') {
|
||||||
|
prev.set(ssrcKey, {
|
||||||
|
bytes: report.bytesReceived,
|
||||||
|
frames: 0,
|
||||||
|
timestamp: now,
|
||||||
|
packetsRecv: packetsRecv,
|
||||||
|
packetsLost: packetsLost,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set network jitter from first inbound audio
|
||||||
|
if (network.jitter === null && jitter !== null) {
|
||||||
|
network.jitter = jitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitrate > 0) {
|
||||||
|
audioTracks.push({
|
||||||
|
key: ssrcKey,
|
||||||
|
direction: 'recv',
|
||||||
|
source: identity.source,
|
||||||
|
participantName: identity.participantName,
|
||||||
|
bitrate,
|
||||||
|
codec,
|
||||||
|
packetLoss: perTrackLoss,
|
||||||
|
jitter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'video') {
|
||||||
|
const framesDecoded = report.framesDecoded ?? 0;
|
||||||
|
const prevFrames = prevEntry?.frames ?? 0;
|
||||||
|
const deltaFrames = framesDecoded - prevFrames;
|
||||||
|
const fps = (prevEntry && deltaSeconds > 0) ? Math.round(deltaFrames / deltaSeconds) : null;
|
||||||
|
|
||||||
|
const width: number | null = report.frameWidth > 0 ? report.frameWidth : null;
|
||||||
|
const height: number | null = report.frameHeight > 0 ? report.frameHeight : null;
|
||||||
|
|
||||||
|
prev.set(ssrcKey, {
|
||||||
|
bytes: report.bytesReceived,
|
||||||
|
frames: framesDecoded,
|
||||||
|
timestamp: now,
|
||||||
|
packetsRecv: packetsRecv,
|
||||||
|
packetsLost: packetsLost,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bitrate > 0 || (width !== null && height !== null)) {
|
||||||
|
videoTracks.push({
|
||||||
|
key: ssrcKey,
|
||||||
|
direction: 'recv',
|
||||||
|
source: identity.source,
|
||||||
|
participantName: identity.participantName,
|
||||||
|
bitrate,
|
||||||
|
codec,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
qualityLimitation: null,
|
||||||
|
simulcastLayer: inferSimulcastLayer(width),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step E: Aggregate packet loss ──
|
||||||
|
const totalPackets = totalPacketsReceived + totalPacketsLost;
|
||||||
|
if (totalPackets > 0) {
|
||||||
|
network.packetLoss = (totalPacketsLost / totalPackets) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step F: Cleanup stale prevSample entries ──
|
||||||
|
for (const key of prev.keys()) {
|
||||||
|
if (!seenKeys.has(key)) {
|
||||||
|
prev.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapshot({ network, audioTracks, videoTracks });
|
||||||
|
};
|
||||||
|
|
||||||
|
poll();
|
||||||
|
const interval = setInterval(poll, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user