diff --git a/src/AnamClient.ts b/src/AnamClient.ts index 899fe9c..b290bee 100644 --- a/src/AnamClient.ts +++ b/src/AnamClient.ts @@ -225,6 +225,9 @@ export default class AnamClient { showPeerConnectionStatsReport: this.clientOptions?.metrics?.showPeerConnectionStatsReport ?? false, + peerConnectionStatsReportOutputFormat: + this.clientOptions?.metrics + ?.peerConnectionStatsReportOutputFormat ?? 'console', }, }, this.publicEventEmitter, @@ -407,7 +410,7 @@ export default class AnamClient { public async stopStreaming(): Promise { if (this.streamingClient) { - this.streamingClient.stopConnection(); + await this.streamingClient.stopConnection(); this.streamingClient = null; this.sessionId = null; setMetricsContext({ diff --git a/src/lib/ClientMetrics.ts b/src/lib/ClientMetrics.ts index e271113..03cd9ef 100644 --- a/src/lib/ClientMetrics.ts +++ b/src/lib/ClientMetrics.ts @@ -79,11 +79,51 @@ export const sendClientMetric = async ( } }; -export const createRTCStatsReport = (stats: RTCStatsReport) => { +export interface RTCStatsJsonReport { + personaVideoStream?: { + framesReceived: number | string; + framesDropped: number | string; + framesPerSecond: number | string; + packetsReceived: number | string; + packetsLost: number | string; + resolution?: string; + jitter?: number; + }[]; + personaAudioStream?: { + packetsReceived: number | string; + packetsLost: number | string; + audioLevel: number | string; + jitter?: number; + totalAudioEnergy?: number; + }[]; + userAudioInput?: { + packetsSent: number | string; + retransmittedPackets?: number; + avgPacketSendDelay?: number; + }[]; + codecs?: { + status: string; + mimeType: string; + payloadType: string | number; + clockRate?: number; + channels?: number; + }[]; + transportLayer?: { + dtlsState: string; + iceState: string; + bytesSent?: number; + bytesReceived?: number; + }[]; + issues: string[]; +} + +export const createRTCStatsReport = ( + stats: RTCStatsReport, + outputFormat: 'console' | 'json' = 'console', +): RTCStatsJsonReport | void => { /** - * constructs a report of the RTC stats for logging to the console + * constructs a report of the RTC stats for logging to the console or returns as JSON */ - console.group('📊 WebRTC Session Statistics Report'); // Collect stats by type for organized reporting const statsByType: Record = {}; @@ -95,198 +135,256 @@ export const createRTCStatsReport = (stats: RTCStatsReport) => { statsByType[report.type].push(report); }); - // Report connection overview - if (statsByType['peer-connection']) { - console.group('🔗 Connection Overview'); - statsByType['peer-connection'].forEach((report) => { - console.log(`Connection State: ${report.connectionState || 'unknown'}`); - console.log(`Data Channels Opened: ${report.dataChannelsOpened || 0}`); - console.log(`Data Channels Closed: ${report.dataChannelsClosed || 0}`); - }); - console.groupEnd(); - } + // Initialize JSON report structure + const jsonReport: RTCStatsJsonReport = { + issues: [], + }; - // Report video statistics (AI video output) + // Build video statistics (Persona video output) const inboundVideo = statsByType['inbound-rtp']?.filter((r) => r.kind === 'video') || []; if (inboundVideo.length > 0) { - console.group('📹 AI Video Stream (Inbound)'); + jsonReport.personaVideoStream = []; + inboundVideo.forEach((report) => { - console.log(`Frames Received: ${report.framesReceived || 0}`); - console.log(`Frames Dropped: ${report.framesDropped || 0}`); - console.log(`Frames Per Second: ${report.framesPerSecond || 0}`); - console.log( - `Bytes Received: ${(report.bytesReceived || 0).toLocaleString()}`, - ); - console.log( - `Packets Received: ${(report.packetsReceived || 0).toLocaleString()}`, - ); - console.log(`Packets Lost: ${report.packetsLost || 0}`); - if (report.frameWidth && report.frameHeight) { - console.log(`Resolution: ${report.frameWidth}x${report.frameHeight}`); - } - if (report.jitter !== undefined) { - console.log(`Jitter: ${report.jitter.toFixed(3)}ms`); - } + const videoData = { + framesReceived: report.framesReceived || 'unknown', + framesDropped: report.framesDropped || 'unknown', + framesPerSecond: report.framesPerSecond || 'unknown', + packetsReceived: report.packetsReceived || 'unknown', + packetsLost: report.packetsLost || 'unknown', + resolution: + report.frameWidth && report.frameHeight + ? `${report.frameWidth}x${report.frameHeight}` + : undefined, + jitter: report.jitter !== undefined ? report.jitter : undefined, + }; + + jsonReport.personaVideoStream!.push(videoData); }); - console.groupEnd(); } - // Report audio statistics (AI audio output) + // Build audio statistics (Persona audio output) const inboundAudio = statsByType['inbound-rtp']?.filter((r) => r.kind === 'audio') || []; if (inboundAudio.length > 0) { - console.group('🔊 AI Audio Stream (Inbound)'); + jsonReport.personaAudioStream = []; + inboundAudio.forEach((report) => { - console.log( - `Bytes Received: ${(report.bytesReceived || 0).toLocaleString()}`, - ); - console.log( - `Packets Received: ${(report.packetsReceived || 0).toLocaleString()}`, - ); - console.log(`Packets Lost: ${report.packetsLost || 0}`); - console.log(`Audio Level: ${report.audioLevel || 0}`); - if (report.jitter !== undefined) { - console.log(`Jitter: ${report.jitter.toFixed(3)}ms`); - } - if (report.totalAudioEnergy !== undefined) { - console.log( - `Total Audio Energy: ${report.totalAudioEnergy.toFixed(6)}`, - ); - } + const audioData = { + packetsReceived: report.packetsReceived || 'unknown', + packetsLost: report.packetsLost || 'unknown', + audioLevel: report.audioLevel || 'unknown', + jitter: report.jitter !== undefined ? report.jitter : undefined, + totalAudioEnergy: + report.totalAudioEnergy !== undefined + ? report.totalAudioEnergy + : undefined, + }; + + jsonReport.personaAudioStream!.push(audioData); }); - console.groupEnd(); } - // Report user audio input statistics + // Build user audio input statistics const outboundAudio = statsByType['outbound-rtp']?.filter((r) => r.kind === 'audio') || []; if (outboundAudio.length > 0) { - console.group('🎤 User Audio Input (Outbound)'); + jsonReport.userAudioInput = []; + outboundAudio.forEach((report) => { - console.log(`Bytes Sent: ${(report.bytesSent || 0).toLocaleString()}`); - console.log( - `Packets Sent: ${(report.packetsSent || 0).toLocaleString()}`, - ); - if (report.retransmittedPacketsSent) { - console.log( - `Retransmitted Packets: ${report.retransmittedPacketsSent}`, - ); - } - if (report.totalPacketSendDelay !== undefined) { - console.log( - `Avg Packet Send Delay: ${((report.totalPacketSendDelay / (report.packetsSent || 1)) * 1000).toFixed(2)}ms`, - ); - } + const userAudioData = { + packetsSent: report.packetsSent || 'unknown', + retransmittedPackets: report.retransmittedPacketsSent || undefined, + avgPacketSendDelay: + report.totalPacketSendDelay !== undefined + ? (report.totalPacketSendDelay / (report.packetsSent || 1)) * 1000 + : undefined, + }; + + jsonReport.userAudioInput!.push(userAudioData); }); - console.groupEnd(); } - // Report codec information + // Build codec information if (statsByType['codec']) { - console.group('🔧 Codecs Used'); + jsonReport.codecs = []; + statsByType['codec'].forEach((report) => { - const direction = report.payloadType ? 'Active' : 'Available'; - console.log( - `${direction} ${report.mimeType || 'Unknown'} - Payload Type: ${report.payloadType || 'N/A'}`, - ); - if (report.clockRate) { - console.log(` Clock Rate: ${report.clockRate}Hz`); - } - if (report.channels) { - console.log(` Channels: ${report.channels}`); - } - }); - console.groupEnd(); - } + const codecData = { + status: report.payloadType ? 'Active' : 'Available', + mimeType: report.mimeType || 'Unknown', + payloadType: report.payloadType || 'N/A', + clockRate: report.clockRate || undefined, + channels: report.channels || undefined, + }; - // Report connection quality metrics - const candidatePairs = - statsByType['candidate-pair']?.filter((r) => r.state === 'succeeded') || []; - if (candidatePairs.length > 0) { - console.group('📡 Connection Quality'); - candidatePairs.forEach((report) => { - console.log( - `Connection Type: ${report.localCandidateType || 'unknown'} → ${report.remoteCandidateType || 'unknown'}`, - ); - if (report.currentRoundTripTime !== undefined) { - console.log( - `Round Trip Time: ${(report.currentRoundTripTime * 1000).toFixed(1)}ms`, - ); - } - if (report.availableOutgoingBitrate !== undefined) { - console.log( - `Available Outgoing Bitrate: ${Math.round(report.availableOutgoingBitrate / 1000)}kbps`, - ); - } - if (report.availableIncomingBitrate !== undefined) { - console.log( - `Available Incoming Bitrate: ${Math.round(report.availableIncomingBitrate / 1000)}kbps`, - ); - } - console.log(`Bytes Sent: ${(report.bytesSent || 0).toLocaleString()}`); - console.log( - `Bytes Received: ${(report.bytesReceived || 0).toLocaleString()}`, - ); + jsonReport.codecs!.push(codecData); }); - console.groupEnd(); } - // Report any transport issues + // Build transport layer information if (statsByType['transport']) { - console.group('🚚 Transport Layer'); + jsonReport.transportLayer = []; + statsByType['transport'].forEach((report) => { - console.log(`DTLS State: ${report.dtlsState || 'unknown'}`); - console.log(`ICE State: ${report.iceState || 'unknown'}`); - if (report.bytesReceived || report.bytesSent) { - console.log( - `Data Transfer - Sent: ${(report.bytesSent || 0).toLocaleString()}, Received: ${(report.bytesReceived || 0).toLocaleString()}`, - ); - } + const transportData = { + dtlsState: report.dtlsState || 'unknown', + iceState: report.iceState || 'unknown', + bytesSent: report.bytesSent || undefined, + bytesReceived: report.bytesReceived || undefined, + }; + + jsonReport.transportLayer!.push(transportData); }); - console.groupEnd(); } - // Summary of potential issues + // Build issues summary const issues: string[] = []; // Check for video issues inboundVideo.forEach((report) => { - if (report.framesDropped > 0) { + if (typeof report.framesDropped === 'number' && report.framesDropped > 0) { issues.push(`Video: ${report.framesDropped} frames dropped`); } - if (report.packetsLost > 0) { + if (typeof report.packetsLost === 'number' && report.packetsLost > 0) { issues.push(`Video: ${report.packetsLost} packets lost`); } - if (report.framesPerSecond < 15) { + if ( + typeof report.framesPerSecond === 'number' && + report.framesPerSecond < 23 + ) { issues.push(`Video: Low frame rate (${report.framesPerSecond} fps)`); } }); // Check for audio issues inboundAudio.forEach((report) => { - if (report.packetsLost > 0) { + if (typeof report.packetsLost === 'number' && report.packetsLost > 0) { issues.push(`Audio: ${report.packetsLost} packets lost`); } - if (report.jitter > 0.1) { + if (typeof report.jitter === 'number' && report.jitter > 0.1) { issues.push( `Audio: High jitter (${(report.jitter * 1000).toFixed(1)}ms)`, ); } }); - // Check for connection issues - candidatePairs.forEach((report) => { - if (report.currentRoundTripTime > 0.5) { - issues.push( - `Connection: High latency (${(report.currentRoundTripTime * 1000).toFixed(1)}ms RTT)`, + jsonReport.issues = issues; + + // Return JSON if requested + if (outputFormat === 'json') { + return jsonReport; + } + + // Generate console output from JSON report + console.group('📊 WebRTC Session Statistics Report'); + + // Console output for video stream + if ( + jsonReport.personaVideoStream && + jsonReport.personaVideoStream.length > 0 + ) { + console.group('📹 Persona Video Stream (Inbound)'); + jsonReport.personaVideoStream.forEach((videoData) => { + console.log(`Frames Received: ${videoData.framesReceived}`); + console.log(`Frames Dropped: ${videoData.framesDropped}`); + console.log(`Frames Per Second: ${videoData.framesPerSecond}`); + console.log( + `Packets Received: ${typeof videoData.packetsReceived === 'number' ? videoData.packetsReceived.toLocaleString() : videoData.packetsReceived}`, ); - } - }); + console.log(`Packets Lost: ${videoData.packetsLost}`); + if (videoData.resolution) { + console.log(`Resolution: ${videoData.resolution}`); + } + if (videoData.jitter !== undefined) { + console.log(`Jitter: ${videoData.jitter.toFixed(5)}ms`); + } + }); + console.groupEnd(); + } + + // Console output for audio stream + if ( + jsonReport.personaAudioStream && + jsonReport.personaAudioStream.length > 0 + ) { + console.group('🔊 Persona Audio Stream (Inbound)'); + jsonReport.personaAudioStream.forEach((audioData) => { + console.log( + `Packets Received: ${typeof audioData.packetsReceived === 'number' ? audioData.packetsReceived.toLocaleString() : audioData.packetsReceived}`, + ); + console.log(`Packets Lost: ${audioData.packetsLost}`); + console.log(`Audio Level: ${audioData.audioLevel}`); + if (audioData.jitter !== undefined) { + console.log(`Jitter: ${audioData.jitter.toFixed(5)}ms`); + } + if (audioData.totalAudioEnergy !== undefined) { + console.log( + `Total Audio Energy: ${audioData.totalAudioEnergy.toFixed(6)}`, + ); + } + }); + console.groupEnd(); + } + + // Console output for user audio input + if (jsonReport.userAudioInput && jsonReport.userAudioInput.length > 0) { + console.group('🎤 User Audio Input (Outbound)'); + jsonReport.userAudioInput.forEach((userAudioData) => { + console.log( + `Packets Sent: ${typeof userAudioData.packetsSent === 'number' ? userAudioData.packetsSent.toLocaleString() : userAudioData.packetsSent}`, + ); + if (userAudioData.retransmittedPackets) { + console.log( + `Retransmitted Packets: ${userAudioData.retransmittedPackets}`, + ); + } + if (userAudioData.avgPacketSendDelay !== undefined) { + console.log( + `Avg Packet Send Delay: ${userAudioData.avgPacketSendDelay.toFixed(5)}ms`, + ); + } + }); + console.groupEnd(); + } + + // Console output for codecs + if (jsonReport.codecs && jsonReport.codecs.length > 0) { + console.group('🔧 Codecs Used'); + jsonReport.codecs.forEach((codecData) => { + console.log( + `${codecData.status} ${codecData.mimeType} - Payload Type: ${codecData.payloadType}`, + ); + if (codecData.clockRate) { + console.log(` Clock Rate: ${codecData.clockRate}Hz`); + } + if (codecData.channels) { + console.log(` Channels: ${codecData.channels}`); + } + }); + console.groupEnd(); + } + + // Console output for transport layer + if (jsonReport.transportLayer && jsonReport.transportLayer.length > 0) { + console.group('🚚 Transport Layer'); + jsonReport.transportLayer.forEach((transportData) => { + console.log(`DTLS State: ${transportData.dtlsState}`); + console.log(`ICE State: ${transportData.iceState}`); + if (transportData.bytesReceived || transportData.bytesSent) { + console.log( + `Data Transfer (bytes) - Sent: ${(transportData.bytesSent || 0).toLocaleString()}, Received: ${(transportData.bytesReceived || 0).toLocaleString()}`, + ); + } + }); + console.groupEnd(); + } - if (issues.length > 0) { + // Console output for issues + if (jsonReport.issues.length > 0) { console.group('⚠️ Potential Issues Detected'); - issues.forEach((issue) => console.warn(issue)); + jsonReport.issues.forEach((issue) => console.warn(issue)); console.groupEnd(); } else { console.log('✅ No significant issues detected'); diff --git a/src/modules/StreamingClient.ts b/src/modules/StreamingClient.ts index a8a3564..66f63f0 100644 --- a/src/modules/StreamingClient.ts +++ b/src/modules/StreamingClient.ts @@ -47,6 +47,7 @@ export class StreamingClient { private successMetricPoller: ReturnType | null = null; private successMetricFired = false; private showPeerConnectionStatsReport: boolean = false; + private peerConnectionStatsReportOutputFormat: 'console' | 'json' = 'console'; constructor( sessionId: string, @@ -89,6 +90,8 @@ export class StreamingClient { this.audioDeviceId = options.inputAudio.audioDeviceId; this.showPeerConnectionStatsReport = options.metrics?.showPeerConnectionStatsReport ?? false; + this.peerConnectionStatsReportOutputFormat = + options.metrics?.peerConnectionStatsReportOutputFormat ?? 'console'; } private onInputAudioStateChange( @@ -241,8 +244,8 @@ export class StreamingClient { } } - public stopConnection() { - this.shutdown(); + public async stopConnection() { + await this.shutdown(); } public async sendTalkCommand(content: string): Promise { @@ -562,11 +565,22 @@ export class StreamingClient { await this.signallingClient.sendOffer(this.peerConnection.localDescription); } - private shutdown() { + private async shutdown() { if (this.showPeerConnectionStatsReport) { - this.peerConnection?.getStats().then((stats: RTCStatsReport) => { - createRTCStatsReport(stats); - }); + const stats = await this.peerConnection?.getStats(); + if (!stats) { + console.error( + 'StreamingClient - shutdown: peer connection is unavailable. Unable to create RTC stats report.', + ); + } else { + const report = createRTCStatsReport( + stats, + this.peerConnectionStatsReportOutputFormat, + ); + if (report) { + console.log(report, undefined, 2); + } + } } // reset video frame polling if (this.successMetricPoller) { diff --git a/src/types/AnamPublicClientOptions.ts b/src/types/AnamPublicClientOptions.ts index 8274fe7..1368ef2 100644 --- a/src/types/AnamPublicClientOptions.ts +++ b/src/types/AnamPublicClientOptions.ts @@ -7,5 +7,6 @@ export interface AnamPublicClientOptions { disableInputAudio?: boolean; metrics?: { showPeerConnectionStatsReport?: boolean; + peerConnectionStatsReportOutputFormat?: 'console' | 'json'; }; } diff --git a/src/types/streaming/StreamingClientOptions.ts b/src/types/streaming/StreamingClientOptions.ts index c3a8e45..e2a564d 100644 --- a/src/types/streaming/StreamingClientOptions.ts +++ b/src/types/streaming/StreamingClientOptions.ts @@ -9,5 +9,6 @@ export interface StreamingClientOptions { inputAudio: InputAudioOptions; metrics?: { showPeerConnectionStatsReport?: boolean; + peerConnectionStatsReportOutputFormat?: 'console' | 'json'; }; }