Quellcodebibliothek Statistik Leitseite products/Sources/formale Sprachen/C/Threema/Threema/Core/ThreemaCalls/   (Social Medium ©)  Datei vom 26.9.2026 mit Größe 62 kB image not shown  

Quelle  VoIPStats.swift   Sprache: JAVA

 

import Foundation

protocol VoIPStatsRepresentation {
    func getShortRepresentation() -> String
    func getRepresentation() -> String
}

enum CandidatePairVariant: Int {
    case NONE = 0x00
    case OVERVIEW = 0x01
    case DETAILED = 0x02
    case OVERVIEW_AND_DETAILED = 0x03 // OVERVIEW | DETAILED
}

final class VoIPStatsOptions {
    var transport = false
    var inboundRtp = false
    var outboundRtp = false
    var codecs = false
    var selectedCandidatePair = false
    var candidatePairsFlag = CandidatePairVariant.NONE
    var tracks = false
    var framesReceived = false
    var crypto = false
}

enum CodecMimeTypePrimary {
    case unknown
    case audio
    case video
    
    static func fromRepresentation(string: String?) -> CodecMimeTypePrimary {
        if string == nil {
            return .unknown
        }
        switch string {
        case "audio":
            return .audio
        case "video":
            return .video
        default:
            return .unknown
        }
    }
    
    func toShortRepresentation() -> String {
        switch self {
        case .audio:
            "a"
        case .video:
            "v"
        case .unknown:
            "?"
        }
    }
    
    func toRepresentation() -> String {
        switch self {
        case .audio:
            "audio"
        case .video:
            "video"
        case .unknown:
            "?"
        }
    }
}

final class VoIPStats: VoIPStatsRepresentation {
    private let report: RTCStatisticsReport
    private let options: VoIPStatsOptions
    private let previousState: VoIPStatsState?
    private let timestamp: CFTimeInterval
            
    private var transport: Transport?
    private var crypto: Crypto?
    private var selectedCandidatePair: CandidatePair?
    private var inboundRtpAudio: InboundRtp?
    private var inboundRtpVideo: InboundRtp?
    private var outboundRtpAudio: OutboundRtp?
    private var outboundRtpVideo: OutboundRtp?
    private var inboundTrackVideo: Track?
    private var outboundTrackVideo: Track?
    private var codecs: [String: Codec]?

    /// Instantaneous microphone level, from the `media-source` entry.
    private var micAudioLevel: Double?

    /// Playout delay above this is not explainable by any network this app should be running on.
    private static let inflatedJitterBufferTargetSeconds = 0.4

    /// Network jitter below this means the link is not asking for a large buffer.
    private static let calmNetworkJitterSeconds = 0.05

    /// Set when the jitter buffer is holding back playout by a lot *and the network did not ask it to*.
    var inflatedJitterBufferDiagnosis: String? {
        guard let playout = inboundRtpAudio?.audioPlayout,
              let target = playout.jitterBufferTargetDelayPerSample,
              target > Self.inflatedJitterBufferTargetSeconds else {
            return nil
        }

        let jitter = inboundRtpAudio?.jitter ?? 0
        guard jitter < Self.calmNetworkJitterSeconds, playout.concealmentEventsDelta ?? 0 == 0 else {
            // The link genuinely wants a buffer this size.
            return nil
        }

        let delay = playout.jitterBufferDelayPerSample ?? 0
        return "target=\(Int(target * 1000))ms jbuf=\(Int(delay * 1000))ms jitter=\(jitter) " +
            "compressed=\(playout.removedSamplesDelta ?? 0) — no network cause, playout stalled earlier in the call"
    }
    
    private let transceivers: [RTCRtpTransceiver]
    private var rtpTransceivers: [RtpTransceiver]?
    private var candidatePairs: [CandidatePair]?
    
    private var totalFramesReceived: UInt64?
    
    final class BytesTransferred: VoIPStatsRepresentation {
        let sent: UInt64?
        let received: UInt64?

        init(_ entry: RTCStatistics) {
            if let sentNumber = entry.values["bytesSent"] as? NSNumber {
                self.sent = UInt64(truncating: sentNumber)
            }
            else {
                self.sent = nil
            }
            
            if let receivedNumber = entry.values["bytesReceived"] as? NSNumber {
                self.received = UInt64(truncating: receivedNumber)
            }
            else {
                self.received = nil
            }
        }
        
        func getShortRepresentation() -> String {
            var result = "tx="
            
            if let sent {
                result += "\(toHumanReadableByteCount(sent))"
            }
            else {
                result += "n/a"
            }
            
            result += ", rx="
            if let received {
                result += "\(toHumanReadableByteCount(received))"
            }
            else {
                result += "n/a"
            }
            
            return result
        }
        
        func getRepresentation() -> String {
            getShortRepresentation()
        }
    }
    
    final class Candidate: VoIPStatsRepresentation {
        let address: String?
        let type: String?
        let protocol_: String?
        let network: String?
        
        init(_ entry: RTCStatistics) {
            self.address = entry.values["ip"] as? String
            self.type = entry.values["candidateType"] as? String
            self.protocol_ = entry.values["protocol"] as? String
            self.network = entry.values["networkType"] as? String
        }
        
        func getShortRepresentation() -> String {
            var result = "\(address ?? "?.?.?.?") \(type ?? "n/a") \(protocol_ ?? "n/a")"
            if let network {
                result += " " + network
            }
            return result
        }
        
        func getRepresentation() -> String {
            var result = "address=\(address ?? "?.?.?.?"), type=\(type ?? "n/a"), protocol=\(protocol_ ?? "n/a")"
            if let network {
                result += ", network=\(network)"
            }
            return result
        }
        
        func usesCellular() -> Bool {
            network == "cellular"
        }
    }
    
    final class CandidatePair: VoIPStatsRepresentation {
        enum State: String {
            case unknown
            case frozen
            case waiting
            case in_progress = "in-progress"
            case succeeded
            case failed
        }
        
        let id: String
        let priority: UInt64
        let local: Candidate?
        let remote: Candidate?
        let nominated: Bool?
        let state: State
        let bytesTransferred: BytesTransferred
        let roundTripTime: RoundTripTime
        let availableOutgoingBitrate: Double?
        let usesRelay: Bool

        static func fromID(_ id: String, report: RTCStatisticsReport) -> CandidatePair? {
            // Lookup pair
            let id = id.dropFirst(20)
            for entry in report.statistics.values {
                let entryID = candidatePairID(statsID: entry.id)
                if entryID == id {
                    return CandidatePair(entry, report: report)
                }
            }
            return nil
        }

        init(_ entry: RTCStatistics, report: RTCStatisticsReport) {
            // ID
            self.id = candidatePairID(statsID: entry.id)
            
            // Priority
            self.priority = CandidatePair.priority(entry)
            
            // Candidates
            let localCandidateID = entry.values["localCandidateId"] as? String
            let remoteCandidateID = entry.values["remoteCandidateId"] as? String
            (local, remote) = CandidatePair.lookupCandidates(
                localCandidateID: localCandidateID, remoteCandidateID: remoteCandidateID, report: report
            )
            
            // Nominated
            self.nominated = CandidatePair.nominated(entry)
        
            // State
            self.state = CandidatePair.state(entry)
            
            // Bytes transferred
            self.bytesTransferred = BytesTransferred(entry)
            
            // RTT
            self.roundTripTime = RoundTripTime(entry)
            
            // Available bitrate
            self.availableOutgoingBitrate = CandidatePair.availableOutgoingBitrate(entry)
                        
            // Check use relay
            self.usesRelay = CandidatePair.usesRelay(entry, local: local, remote: remote)
        }
        
        static func lookupCandidates(
            localCandidateID: String?,
            remoteCandidateID: String?,
            report: RTCStatisticsReport
        )
            -> (Candidate?, Candidate?) {
            var localCandidate: Candidate?
            var remoteCandidate: Candidate?
            if localCandidateID != nil || remoteCandidateID != nil {
                for entry in report.statistics.values {
                    if localCandidateID != nil, entry.id == localCandidateID {
                        localCandidate = Candidate(entry)
                    }
                    if remoteCandidateID != nil, entry.id == remoteCandidateID {
                        remoteCandidate = Candidate(entry)
                    }
                }
            }
            return (localCandidate, remoteCandidate)
        }
        
        private static func priority(_ entry: RTCStatistics) -> UInt64 {
            if let p = entry.values["priority"] as? NSNumber {
                return UInt64(truncating: p)
            }
            return 0
        }
        
        private static func nominated(_ entry: RTCStatistics) -> Bool? {
            if let n = entry.values["nominated"] as? NSNumber {
                return Bool(truncating: n)
            }
            return nil
        }
        
        private static func state(_ entry: RTCStatistics) -> State {
            if let s = entry.values["state"] as? String,
               let newState = State(rawValue: s) {
                return newState
            }
            return .unknown
        }
        
        private static func availableOutgoingBitrate(_ entry: RTCStatistics) -> Double? {
            if let aOB = entry.values["availableOutgoingBitrate"] as? NSNumber {
                return Double(truncating: aOB)
            }
            return nil
        }
        
        private static func usesRelay(_ entry: RTCStatistics, local: Candidate?, remote: Candidate?) -> Bool {
            if let local, local.type == "relay" {
                return true
            }
            else if let remote, remote.type == "relay" {
                return true
            }
            return false
        }
            
        func getShortRepresentation() -> String {
            var result = "pair=\(state) "
            if let nominated, nominated == true {
                result += " nominated"
            }
            result += "\n"
            
            result += "local="
            if local != nil {
                result += local!.getShortRepresentation()
            }
            else {
                result += "n/a"
            }
            result += "\n"
            
            result += "remote="
            if remote != nil {
                result += remote!.getShortRepresentation()
            }
            else {
                result += "n/a"
            }
            result += "\n"
            
            result += "relayed=\(usesRelay)\n"
            
            result += "\(bytesTransferred.getShortRepresentation())"
            if availableOutgoingBitrate != nil {
                result += " bitrate=\(String(format: "%.0fkbps", availableOutgoingBitrate! / 1000))"
            }
            result += "\n"
            
            result += "\(roundTripTime.getShortRepresentation())"
            return result
        }
        
        func getRepresentation() -> String {
            var result = "id=\(id), state=\(state), priority="
            if priority > 0 {
                result += String(priority)
            }
            else {
                result += "n/a"
            }
            result += ", active="
            if let active = nominated {
                result += active ? "yes" : "no"
            }
            else {
                result += "n/a"
            }
            result += ", \(roundTripTime.getRepresentation())" +
                ", \(bytesTransferred.getRepresentation())" +
                "\n  Local: \(local?.getRepresentation() ?? "n/a")" +
                "\n  Remote: \(remote?.getRepresentation() ?? "n/a")"
            return result
        }
        
        func getStatusChar() -> String {
            // '-' -> frozen: The pair has been held back due to another pair with the same
            //        foundation that is currently in the waiting state.
            // '.' -> waiting: Pair checking has not started, yet.
            // '+' -> in-progress: Pair checking is in progress. In the webrtc.org implementation,
            //        this is also being used for pairs that (temporarily) have no connection.
            // 'o' -> succeeded: A connection could be established via this pair.
            // 'x' -> failed: No connection could be established via this pair and no further
            //        attempts will be made.
            switch state {
            case .unknown: "?"
            case .frozen: "-"
            case .waiting: "."
            case .in_progress: "+"
            case .succeeded: "o"
            case .failed: "x"
            }
        }
    }
    
    final class Codec: VoIPStatsRepresentation {
        
        struct CodecMimeType {
            let primary: CodecMimeTypePrimary
            let secondary: String
        }

        public let codecID: String
        private let mimeType: CodecMimeType
        private let clockRate: UInt64?

        public init(_ entry: RTCStatistics) {
            self.codecID = entry.id
            self.mimeType = Codec.getMimeType(mimeTypeString: entry.values["mimeType"] as? String)
            if let clockRateNumber = entry.values["clockRate"] as? NSNumber {
                self.clockRate = UInt64(truncating: clockRateNumber)
            }
            else {
                self.clockRate = nil
            }
        }
        
        func getShortRepresentation() -> String {
            var result = "\(mimeType.primary.toShortRepresentation())/\(mimeType.secondary)"
            if clockRate == nil {
                return result
            }
            
            result += "@"
            let clockRateK = clockRate! / 1000
            if clockRateK >= 1 {
                result += "\(clockRateK)k"
            }
            else {
                result += " \(clockRate!)"
            }
            
            return result
        }
        
        func getRepresentation() -> String {
            var result = "mime-type=\(mimeType.primary.toRepresentation())/\(mimeType.secondary)"
                        
            result += ", clock-rate="
            if clockRate != nil {
                result += "\(clockRate!)"
            }
            else {
                result += "n/a"
            }
            return result
        }
        
        static func getMimeType(mimeTypeString: String?) -> CodecMimeType {
            if mimeTypeString == nil {
                return CodecMimeType(primary: CodecMimeTypePrimary.unknown, secondary: "?")
            }

            if let mimeType = mimeTypeString?.split(separator: "/") {
                if mimeType.count != 2 {
                    return CodecMimeType(primary: CodecMimeTypePrimary.unknown, secondary: "?")
                }
                return CodecMimeType(
                    primary: CodecMimeTypePrimary.fromRepresentation(string: String(mimeType[0])),
                    secondary: String(mimeType[1])
                )
            }
            
            return CodecMimeType(primary: CodecMimeTypePrimary.unknown, secondary: "?")
        }
    }
    
    /// Audio playout metrics of an `inbound-rtp` audio entry
    ///
    /// These answer two questions the rest of the stats cannot: whether the jitter buffer is
    /// holding back playout by seconds (the RFC 3550 `jitter` above is a *network* metric and stays
    /// in the low milliseconds even when playout is three seconds behind), and whether media that
    /// is demonstrably arriving is actually being played out.
    ///
    /// All the underlying WebRTC values except `audioLevel` are cumulative over the whole call, so
    /// reporting them raw is useless here: a three second spike that recovers averages away to
    /// nothing over a five minute call. Everything printed is a delta against the previous sample,
    /// carried over in `VoIPStatsState` the same way `bytesTransferred` already is.
    struct AudioPlayout: VoIPStatsRepresentation {

        // Cumulative values as reported. Retained so the next sample can difference against them;
        // never printed directly.
        let jitterBufferDelay: Double?
        let jitterBufferTargetDelay: Double?
        let jitterBufferEmittedCount: UInt64?
        let concealedSamples: UInt64?
        let concealmentEvents: UInt64?
        let insertedSamplesForDeceleration: UInt64?
        let removedSamplesForAcceleration: UInt64?
        let totalSamplesReceived: UInt64?
        let totalAudioEnergy: Double?

        /// Level of the most recent audio sample, 0...1. Instantaneous, no delta needed. Staying at
        /// zero while `packetsReceived` climbs is the signature of "media arrives, playout never
        /// starts".
        let audioLevel: Double?

        /// Mean playout delay over the last sampling period, in seconds
        private(set) var jitterBufferDelayPerSample: Double?

        /// Mean delay NetEQ was *aiming* for over the last sampling period, in seconds. This rising
        /// is what turns a network blip into seconds of added latency that never recovers.
        private(set) var jitterBufferTargetDelayPerSample: Double?

        private(set) var concealedSamplesDelta: UInt64?
        private(set) var concealmentEventsDelta: UInt64?
        private(set) var insertedSamplesDelta: UInt64?
        private(set) var removedSamplesDelta: UInt64?
        private(set) var totalSamplesReceivedDelta: UInt64?
        private(set) var totalAudioEnergyDelta: Double?

        /// Share of the samples played out over the last sampling period that had to be concealed
        private(set) var concealedPercent: Double?

        init(_ entry: RTCStatistics, previous: AudioPlayout?) {
            self.jitterBufferDelay = entry.doubleValue("jitterBufferDelay")
            self.jitterBufferTargetDelay = entry.doubleValue("jitterBufferTargetDelay")
            self.jitterBufferEmittedCount = entry.uInt64Value("jitterBufferEmittedCount")
            self.concealedSamples = entry.uInt64Value("concealedSamples")
            self.concealmentEvents = entry.uInt64Value("concealmentEvents")
            self.insertedSamplesForDeceleration = entry.uInt64Value("insertedSamplesForDeceleration")
            self.removedSamplesForAcceleration = entry.uInt64Value("removedSamplesForAcceleration")
            self.totalSamplesReceived = entry.uInt64Value("totalSamplesReceived")
            self.totalAudioEnergy = entry.doubleValue("totalAudioEnergy")
            self.audioLevel = entry.doubleValue("audioLevel")

            // Both delays are sums over the emitted samples, so both are divided by the same
            // emitted count delta to get a mean for the period
            if let emittedCountDelta = cumulativeDelta(
                self.jitterBufferEmittedCount,
                previous?.jitterBufferEmittedCount
            ), emittedCountDelta > 0 {
                if let delayDelta = cumulativeDelta(self.jitterBufferDelay, previous?.jitterBufferDelay) {
                    self.jitterBufferDelayPerSample = delayDelta / Double(emittedCountDelta)
                }
                if let targetDelayDelta = cumulativeDelta(
                    self.jitterBufferTargetDelay,
                    previous?.jitterBufferTargetDelay
                ) {
                    self.jitterBufferTargetDelayPerSample = targetDelayDelta / Double(emittedCountDelta)
                }
            }

            self.concealedSamplesDelta = cumulativeDelta(self.concealedSamples, previous?.concealedSamples)
            self.concealmentEventsDelta = cumulativeDelta(self.concealmentEvents, previous?.concealmentEvents)
            self.insertedSamplesDelta = cumulativeDelta(
                self.insertedSamplesForDeceleration,
                previous?.insertedSamplesForDeceleration
            )
            self.removedSamplesDelta = cumulativeDelta(
                self.removedSamplesForAcceleration,
                previous?.removedSamplesForAcceleration
            )
            self.totalSamplesReceivedDelta = cumulativeDelta(self.totalSamplesReceived, previous?.totalSamplesReceived)
            self.totalAudioEnergyDelta = cumulativeDelta(self.totalAudioEnergy, previous?.totalAudioEnergy)

            if let concealedSamplesDelta = self.concealedSamplesDelta,
               let totalSamplesReceivedDelta = self.totalSamplesReceivedDelta, totalSamplesReceivedDelta > 0 {
                self.concealedPercent = Double(concealedSamplesDelta) / Double(totalSamplesReceivedDelta) * 100
            }
        }

        func getShortRepresentation() -> String {
            var parts = [String]()

            if let jitterBufferDelayPerSample {
                var jitterBuffer = "jbuf=\(String(format: "%.0f", jitterBufferDelayPerSample * 1000))ms"
                if let jitterBufferTargetDelayPerSample {
                    jitterBuffer += "/target=\(String(format: "%.0f", jitterBufferTargetDelayPerSample * 1000))ms"
                }
                parts.append(jitterBuffer)
            }

            if let concealedSamplesDelta {
                var concealed = "concealed=\(concealedSamplesDelta)"
                if let concealedPercent {
                    concealed += "(\(String(format: "%.1f", concealedPercent))%)"
                }
                if let concealmentEventsDelta {
                    concealed += "/\(concealmentEventsDelta)evt"
                }
                parts.append(concealed)
            }

            if let insertedSamplesDelta, let removedSamplesDelta {
                parts.append("stretched=\(insertedSamplesDelta)/compressed=\(removedSamplesDelta)")
            }

            // Deliberately coarse — see `levelBucket`
            if let audioLevel {
                parts.append("level=\(AudioPlayout.levelBucket(audioLevel))")
            }

            // Over a 30s sampling period an instantaneous level of zero proves nothing: the remote
            // party may simply not have been speaking at that instant. Whether *any* energy
            // accumulated over the whole period is what separates "playout never started" from
            // "nobody was talking", and it is a single bit.
            if let totalAudioEnergyDelta {
                parts.append("playout=\(totalAudioEnergyDelta > 0 ? "active" : "silent")")
            }

            return parts.joined(separator: " ")
        }

        func getRepresentation() -> String {
            getShortRepresentation()
        }

        /// Bucket an audio level for logging
        ///
        /// `audioLevel` and `totalAudioEnergy` are the only values here derived from call *content*
        /// rather than from transport or DSP behaviour, and they describe the remote party's speech —
        /// who never consented to the log being exported to support. A run of precise levels is an
        /// amplitude envelope of the conversation: far too coarse to recover speech, but enough to
        /// show who spoke when and for how long.
        ///
        /// The diagnosis only needs "is anything coming out of the decoder at all", so only a coarse
        /// bucket is logged. Four buckets at a 30s (or, while connecting, 2s) interval answer that
        /// while carrying essentially nothing about the conversation.
        static func levelBucket(_ level: Double) -> String {
            switch level {
            case ..<0.0001:
                "silent"
            case ..<0.01:
                "low"
            case ..<0.1:
                "mid"
            default:
                "high"
            }
        }
    }

    class Rtp: VoIPStatsRepresentation {
        private let codecs: [String: Codec]
        
        let codecID: String?
        let kind: String
        var jitter: Double?
        public var packetsTotal: UInt64?
        public var bytesTotal: UInt64?
        public var packetsLost: UInt64?
        public var packetLossPercent: Float64?
        public var qualityLimitationReason: String?
        public var qualityLimitationResolutionChanges: UInt64?
        public var implementation: String?
        public var averageFps: Float?
        public var bitrate: Double?
        
        public init(_ entry: RTCStatistics, codecs: [String: Codec]) {
            self.codecs = codecs
            self.codecID = entry.values["codecId"] as? String
            self.kind = entry.values["kind"] as? String ?? "?"
        }

        public func getShortRepresentation() -> String {
            var shortRepresentation = "\(kind)"
            
            if packetsTotal != nil, packetsLost != nil {
                shortRepresentation
                    .append(
                        " packets-lost=\(packetsLost!)/\(packetsTotal!)(\(String(format: "%.1f", packetLossPercent ?? 0))%)"
                    )
            }
            else if packetsTotal != nil {
                shortRepresentation.append(" packets=\(packetsTotal!)")
            }
            if jitter != nil {
                shortRepresentation.append(" jitter=\(jitter!)")
            }
            
            if bitrate != nil {
                shortRepresentation.append(" bitrate=\(String(format: "%.0f", bitrate! / 1000))kbps")
            }

            if averageFps != nil {
                shortRepresentation.append(" avfps=\(String(format: "%.1f", averageFps!))")
            }
            
            shortRepresentation.append(" codec=")
            if codecID != nil, let codec = codecs[codecID!] {
                shortRepresentation.append(codec.getShortRepresentation())
            }
            else {
                shortRepresentation.append("?")
            }
            
            if implementation != nil {
                switch implementation {
                case "HWEncoder":
                    shortRepresentation.append(" (hw)")
                case "SWEncoder":
                    shortRepresentation.append(" (sw)")
                case "unknown":
                    break
                default:
                    shortRepresentation.append(" (\(implementation!))")
                }
            }
            
            if qualityLimitationReason != nil {
                shortRepresentation
                    .append(" limit=\(qualityLimitationReason!.replacingOccurrences(of: "bandwidth", with: "bw"))")
                if qualityLimitationResolutionChanges != nil {
                    shortRepresentation.append("/\(qualityLimitationResolutionChanges!)")
                }
            }

            return shortRepresentation
        }
        
        public func getRepresentation() -> String {
            getShortRepresentation()
        }
    }
    
    final class InboundRtp: Rtp {

        /// Only populated for the audio entry
        private(set) var audioPlayout: AudioPlayout?

        public init(
            _ entry: RTCStatistics,
            codecs: [String: Codec],
            previousState: VoIPStatsState?,
            timestamp: CFTimeInterval
        ) {
            super.init(entry, codecs: codecs)
            if let j = entry.values["jitter"] as? NSNumber {
                self.jitter = Double(truncating: j)
            }
            else {
                self.jitter = nil
            }
            
            if let pt = entry.values["packetsReceived"] as? NSNumber {
                self.packetsTotal = UInt64(truncating: pt)
            }
            else {
                self.packetsTotal = nil
            }
            
            if let bt = entry.values["bytesReceived"] as? NSNumber {
                self.bytesTotal = UInt64(truncating: bt)
            }
            else {
                self.bytesTotal = nil
            }
            
            if let pl = entry.values["packetsLost"] as? NSNumber {
                self.packetsLost = UInt64(truncating: pl)
            }
            else {
                self.packetsLost = nil
            }

            self.packetLossPercent = calculatePacketLoss()
            
            var totalInterFrameDelay: Double?
            if let tifd = entry.values["totalInterFrameDelay"] as? NSNumber {
                totalInterFrameDelay = Double(truncating: tifd)
            }
            var framesDecoded: UInt64?
            if let fd = entry.values["framesDecoded"] as? NSNumber {
                framesDecoded = UInt64(truncating: fd)
            }
            
            if totalInterFrameDelay != nil, framesDecoded != nil {
                averageFps = calculateAverageFps(
                    totalInterFrameDelay: totalInterFrameDelay!,
                    framesDecoded: framesDecoded!
                )
            }
            
            // Difference against the previous sample of the *same* kind: measuring the audio entry
            // against the video byte count (and vice versa) yields a meaningless bitrate
            let previousBytesReceived = kind == "audio" ? previousState?.audioBytesReceived : previousState?
                .videoBytesReceived
            if let previousState, let previousBytesReceived, let bytesTotal {
                bitrate = calculateBitrate(
                    previousTimestamp: previousState.timestampUs,
                    previousBytes: previousBytesReceived,
                    currentTimestamp: timestamp,
                    currentBytes: bytesTotal
                )
            }

            if kind == "audio" {
                self.audioPlayout = AudioPlayout(entry, previous: previousState?.audioPlayout)
            }
        }

        override public func getShortRepresentation() -> String {
            let playout = audioPlayout?.getShortRepresentation() ?? ""
            guard !playout.isEmpty else {
                return super.getShortRepresentation()
            }
            return "\(super.getShortRepresentation()) \(playout)"
        }

        private func calculatePacketLoss() -> Float64? {
            if packetsTotal == nil || packetsLost == nil {
                return nil
            }
            
            if packetsLost! > UInt64(0) {
                return Float64(packetsLost!) / Float64(packetsTotal!) * Float64(100)
            }
            else {
                return 0
            }
        }
        
        private func calculateAverageFps(totalInterFrameDelay: Double, framesDecoded: UInt64) -> Float {
            if framesDecoded == 0 {
                return 0.0
            }
            return Float(1.0 / (totalInterFrameDelay / Double(framesDecoded)))
        }
    }
    
    final class OutboundRtp: Rtp {
        public init(
            _ entry: RTCStatistics,
            codecs: [String: Codec],
            previousState: VoIPStatsState?,
            timestamp: CFTimeInterval
        ) {
            super.init(entry, codecs: codecs)
            
            if let pt = entry.values["packetsSent"] as? NSNumber {
                self.packetsTotal = UInt64(truncating: pt)
            }
            else {
                self.packetsTotal = nil
            }
            
            if let bt = entry.values["bytesSent"] as? NSNumber {
                self.bytesTotal = UInt64(truncating: bt)
            }
            else {
                self.bytesTotal = nil
            }
            
            self.qualityLimitationReason = entry.values["qualityLimitationReason"] as? String
            
            if let qlrc = entry.values["qualityLimitationResolutionChanges"] as? NSNumber {
                self.qualityLimitationResolutionChanges = UInt64(truncating: qlrc)
            }
            else {
                self.qualityLimitationResolutionChanges = nil
            }

            self.implementation = entry.values["encoderImplementation"] as? String

            // Same-kind differencing as in `InboundRtp`
            let previousBytesSent = kind == "audio" ? previousState?.audioBytesSent : previousState?.videoBytesSent
            if let previousState, let previousBytesSent, let bytesTotal {
                bitrate = calculateBitrate(
                    previousTimestamp: previousState.timestampUs,
                    previousBytes: previousBytesSent,
                    currentTimestamp: timestamp,
                    currentBytes: bytesTotal
                )
            }
        }
    }
    
    final class Track: VoIPStatsRepresentation {
        public let kind: String
        public var frameWidth: UInt64?
        public var frameHeight: UInt64?
        public var freezeCount: UInt64?
        public var pauseCount: UInt64?
        
        public var ended: Bool?
        public var remoteSource: Bool?
        public var detached: Bool?
        public var totalFramesReceived: UInt64?

        public init(_ entry: RTCStatistics) {
            self.kind = entry.values["kind"] as? String ?? "?"
            
            if let fw = entry.values["frameWidth"] as? NSNumber {
                self.frameWidth = UInt64(truncating: fw)
            }
            else {
                self.frameWidth = nil
            }
            
            if let fh = entry.values["frameHeight"] as? NSNumber {
                self.frameHeight = UInt64(truncating: fh)
            }
            else {
                self.frameHeight = nil
            }
            
            if let fc = entry.values["freezeCount"] as? NSNumber {
                self.freezeCount = UInt64(truncating: fc)
            }
            else {
                self.freezeCount = nil
            }
            
            if let pc = entry.values["pauseCount"] as? NSNumber {
                self.pauseCount = UInt64(truncating: pc)
            }
            else {
                self.pauseCount = nil
            }
            
            if let e = entry.values["ended"] as? NSNumber {
                self.ended = Bool(truncating: e)
            }
            else {
                self.ended = nil
            }
            
            if let rs = entry.values["remoteSource"] as? NSNumber {
                self.remoteSource = Bool(truncating: rs)
            }
            else {
                self.remoteSource = nil
            }
            
            if let tfr = entry.values["framesReceived"] as? NSNumber {
                self.totalFramesReceived = UInt64(truncating: tfr)
            }
            else {
                self.totalFramesReceived = nil
            }
            
            if let d = entry.values["detached"] as? NSNumber {
                self.detached = Bool(truncating: d)
            }
            else {
                self.detached = nil
            }
        }
               
        public func getShortRepresentation() -> String {
            var result = "\(kind)"
            if frameWidth != nil, frameHeight != nil {
                result += " res=\(frameWidth!)x\(frameHeight!)"
            }
            if freezeCount != nil {
                result += " freeze=\(freezeCount!)"
            }
            if pauseCount != nil {
                result += " pause=\(pauseCount!)"
            }
            return result
        }
        
        public func getRepresentation() -> String {
            getShortRepresentation()
        }
        
        public func areFramesReceived() -> Bool {
            if kind == "video" {
                if totalFramesReceived != nil, ended != nil, remoteSource != nil, totalFramesReceived! > UInt64(0),
                   ended == false, remoteSource == true {
                    return true
                }
            }
            return false
        }
    }
    
    final class RtpTransceiver: VoIPStatsRepresentation {
        
        public let transceiver: RTCRtpTransceiver

        public init(_ rtpTransceiver: RTCRtpTransceiver) {
            self.transceiver = rtpTransceiver
        }
               
        public func getShortRepresentation() -> String {
            var result = "kind=\(mediaType())"
            result += ", mid=\(transceiver.mid)"
            var transceiverDirection = transceiver.direction
            if transceiver.currentDirection(&transceiverDirection) {
                result += ", cur-dir=\(direction())"
            }
            
            result += "\n sender: \(addParametersShortRepresentation(transceiver.sender.parameters))"
            result += "\n receiver: \(addParametersShortRepresentation(transceiver.receiver.parameters))"
            return result
        }
        
        public func getRepresentation() -> String {
            var result = "kind=\(mediaType())"
            result += ", mid=\(transceiver.mid)"
            result += ", direction=\(direction())"
            var transceiverDirection = transceiver.direction
            if transceiver.currentDirection(&transceiverDirection) {
                result += ", cur-dir=\(direction())"
            }
            
            result += "\n Sender: \(addParametersRepresentation(transceiver.sender.parameters))"
            result += "\n Receiver: \(addParametersRepresentation(transceiver.receiver.parameters))"
            
            return result
        }
        
        private func addParametersShortRepresentation(_ parameters: RTCRtpParameters) -> String {
            var result = ""
            var plain = 0
            var encrypted = 0
            
            // Add amount of encrypted vs. non-encrypted header extensions
            for headerExtension in parameters.headerExtensions {
                if headerExtension.isEncrypted {
                    encrypted += 1
                }
                else {
                    plain += 1
                }
            }
            
            result += "#exts=\(encrypted)e/\(plain)p, "
            
            // Add codecs
            result += "cs="
            if !parameters.codecs.isEmpty {
                for codec in parameters.codecs {
                    // Add codec
                    result += "\(codec.name)/\(shortClockRate(codec: codec))"
                    if let numChannels = codec.numChannels {
                        result += "/\(numChannels)"
                    }
                    
                    // Add codec attributes
                    // Note: We only care about Opus CBR attributes
                    if codec.name == "opus" {
                        let cbr = codec.parameters["cbr"] as? String
                        result += "["
                        result += "cbr=\(cbr ?? "?")]"
                    }
                    result += " "
                }
            }
            else {
                result += "?"
            }
            
            return result
        }
        
        private func addParametersRepresentation(_ parameters: RTCRtpParameters) -> String {
            var result = ""
            
            // Add codecs
            result += "\n    Codecs (\(parameters.codecs.count))"
            if !parameters.codecs.isEmpty {
                for (_, codec) in parameters.codecs.enumerated() {
                    // add codec
                    result += "\n    - name=\(codec.name), clock-rate=\(shortClockRate(codec: codec))"
                    
                    if let numChannels = codec.numChannels {
                        result += ", #channels=\(numChannels)"
                    }
                    
                    // Add codec attributes
                    result += ", attributes="
                    for (key, value) in codec.parameters {
                        result += "\(key)=\(value) "
                    }
                }
            }
            
            // Add header extensions
            var extensionResult = ""
            var plain = 0
            var encrypted = 0

            for (_, headerExtension) in parameters.headerExtensions.enumerated() {
                extensionResult += "\n    - id=\(headerExtension.id)"
                extensionResult += ", encrypted=\(headerExtension.isEncrypted ? "true" : "false")"
                extensionResult += ", uri=\(headerExtension.uri)"
                if headerExtension.isEncrypted {
                    encrypted += 1
                }
                else {
                    plain += 1
                }
            }
            
            result += "\n    Header Extensions (\(encrypted)e/\(plain)p)\(extensionResult)"

            return result
        }
        
        private func mediaType() -> String {
            switch transceiver.mediaType {
            case .audio:
                "audio"
            case .video:
                "video"
            default:
                "?"
            }
        }
        
        private func direction() -> String {
            switch transceiver.direction {
            case .sendRecv:
                "send/recv"
            case .sendOnly:
                "send"
            case .recvOnly:
                "recv"
            case .inactive:
                "inactive"
            default:
                "?"
            }
        }
        
        private func shortClockRate(codec: RTCRtpCodecParameters) -> String {
            let clockRateK = codec.clockRate!.intValue / 1000
            if clockRateK >= 1 {
                return "\(clockRateK)k"
            }
            return codec.clockRate!.stringValue
        }
    }
    
    final class RoundTripTime: VoIPStatsRepresentation {
        private let latest: Double?
        private var average: Double?
        
        public init(_ entry: RTCStatistics) {
            if let latestNumber = entry.values["currentRoundTripTime"] as? NSNumber {
                self.latest = Double(truncating: latestNumber)
            }
            else {
                self.latest = nil
            }
            
            self.average = nil
            let totalRoundTripTime = entry.values["totalRoundTripTime"] as? NSNumber
            let responsesReceived = entry.values["responsesReceived"] as? NSNumber
                        
            if totalRoundTripTime != nil, responsesReceived != nil {
                if UInt64(truncating: responsesReceived!).signum() == 1 {
                    let t = Decimal(Double(truncating: totalRoundTripTime!))
                    let r = Decimal(UInt64(truncating: responsesReceived!))
                    let av = NSDecimalNumber(decimal: t / r)
                    self.average = Double(truncating: av)
                }
            }
        }
        
        public func getShortRepresentation() -> String {
            var result = "rtt-latest="
            if latest != nil {
                result += "\(String(format: "%.3f", latest!))"
            }
            else {
                result += "n/a"
            }
            
            result += " rtt-avg="
            if average != nil {
                result += "\(String(format: "%.3f", average!))"
            }
            else {
                result += "n/a"
            }
            
            return result
        }
        
        public func getRepresentation() -> String {
            getShortRepresentation()
        }
    }
    
    final class Transport: VoIPStatsRepresentation {
        public let selectedCandidatePairID: String
        private let bytesTransferred: BytesTransferred
        private let dtlsState: String
        
        public init(_ entry: RTCStatistics) {
            if let candidatePairIDString = entry.values["selectedCandidatePairId"] as? String {
                self.selectedCandidatePairID = candidatePairID(statsID: candidatePairIDString)
            }
            else {
                self.selectedCandidatePairID = "???"
            }
            
            self.bytesTransferred = BytesTransferred(entry)
            self.dtlsState = entry.values["dtlsState"] as? String ?? "n/a"
        }
        
        public func getShortRepresentation() -> String {
            "dtls=\(dtlsState) \(bytesTransferred.getShortRepresentation())"
        }
        
        public func getRepresentation() -> String {
            "dtls-state=\(dtlsState), selected-candidate-pair-id=\(selectedCandidatePairID), \(bytesTransferred.getRepresentation())"
        }
    }
    
    final class Crypto: VoIPStatsRepresentation {
        private let dtlsVersion: String
        private let dtlsCipher: String
        private let srtpCipher: String
        
        public init(_ entry: RTCStatistics) {
            self.dtlsVersion = VoIPStats.Crypto
                .dtlsVersionString(dtlsVersionBytes: entry.values["tlsVersion"] as? String)
            self.dtlsCipher = entry.values["dtlsCipher"] as? String ?? "?"
            self.srtpCipher = entry.values["srtpCipher"] as? String ?? "?"
        }
        
        private static func dtlsVersionString(dtlsVersionBytes: String?) -> String {
            if dtlsVersionBytes == nil {
                return "?"
            }
            switch dtlsVersionBytes {
            case "FEFF":
                return "1.0"
            case "FEFD":
                return "1.2"
            default:
                return "?"
            }
        }

        public func getShortRepresentation() -> String {
            "dtls=v\(dtlsVersion):\(dtlsCipher) srtp=\(srtpCipher)"
        }
        
        public func getRepresentation() -> String {
            "dtls-version=\(dtlsVersion), dtls-cipher=\(dtlsCipher), srtp-cipher=\(srtpCipher)"
        }
    }
    
    public init(
        report: RTCStatisticsReport,
        options: VoIPStatsOptions,
        transceivers: [RTCRtpTransceiver],
        previousState: VoIPStatsState?
    ) {
        self.report = report
        self.options = options
        self.transceivers = transceivers
        self.previousState = previousState
        self.timestamp = report.timestamp_us

        extract()
    }

    // O(n^2) but could be optimised for O(n) if needed
    func extract() {
        if options.candidatePairsFlag != CandidatePairVariant.NONE {
            candidatePairs = [CandidatePair]()
        }

        // Codecs are collected in their own pass, for two reasons. `report.statistics` is a
        // dictionary, so a `codec` entry can be visited *after* the `inbound-rtp`/`outbound-rtp`
        // entry that references it, and `Rtp` copies the dictionary it is handed — a codec
        // collected later would never become visible to it. And each RTP entry names its own codec
        // via `codecId`, so splitting the codecs by direction up front was never necessary: it was
        // done by matching "Inbound" in the stats ID, which this WebRTC version never emits, so
        // the inbound table was always empty and every inbound line reported `codec=?`.
        var codecs = [String: Codec]()
        for entry in report.statistics.values where entry.type == "codec" {
            codecs[entry.id] = Codec(entry)
        }
        self.codecs = codecs

        // Extract values
        for entry in report.statistics.values {
            switch entry.type {
            case "candidate-pair":
                if options.candidatePairsFlag != .NONE {
                    candidatePairs!.append(CandidatePair(entry, report: report))
                }
                
            case "inbound-rtp":
                if options.inboundRtp {
                    let kind = entry.values["kind"] as? String
                    if kind == "audio" {
                        inboundRtpAudio = InboundRtp(
                            entry,
                            codecs: codecs,
                            previousState: previousState,
                            timestamp: timestamp
                        )
                    }
                    else if kind == "video" {
                        inboundRtpVideo = InboundRtp(
                            entry,
                            codecs: codecs,
                            previousState: previousState,
                            timestamp: timestamp
                        )
                    }
                }
                
            case "media-source":
                if options.outboundRtp, entry.values["kind"] as? String == "audio" {
                    micAudioLevel = entry.values["audioLevel"] as? Double
                }
                
            case "outbound-rtp":
                if options.outboundRtp {
                    let kind = entry.values["kind"] as? String
                    if kind == "audio" {
                        outboundRtpAudio = OutboundRtp(
                            entry,
                            codecs: codecs,
                            previousState: previousState,
                            timestamp: timestamp
                        )
                    }
                    else if kind == "video" {
                        outboundRtpVideo = OutboundRtp(
                            entry,
                            codecs: codecs,
                            previousState: previousState,
                            timestamp: timestamp
                        )
                    }
                }
                
            case "track":
                if options.tracks {
                    let kind = entry.values["kind"] as? String
                    let inbound = entry.values["remoteSource"] as? NSNumber
                    if kind == "video", inbound != nil {
                        if inbound!.boolValue {
                            inboundTrackVideo = Track(entry)
                        }
                        else {
                            outboundTrackVideo = Track(entry)
                        }
                    }
                }
                if options.framesReceived {
                    let kind = entry.values["kind"] as? String
                    let inbound = entry.values["remoteSource"] as? NSNumber
                    if kind == "video",
                       let inbound, inbound.boolValue {
                        let track = Track(entry)
                        if track.totalFramesReceived != nil, track.areFramesReceived() {
                            if totalFramesReceived == nil {
                                totalFramesReceived = track.totalFramesReceived
                            }
                            else {
                                totalFramesReceived! += track.totalFramesReceived!
                            }
                        }
                    }
                }
                
            case "transport":
                if options.transport {
                    transport = Transport(entry)
                }
                if options.crypto {
                    crypto = Crypto(entry)
                }
                if !options.selectedCandidatePair {
                    break
                }
                
                if let candidatePairIDString = entry.values["selectedCandidatePairId"] as? String {
                    if let candidatePair = CandidatePair.fromID(candidatePairIDString, report: report) {
                        selectedCandidatePair = candidatePair
                    }
                }
                
            default:
                break // Ignore
            }
        }
        
        // Sort candidate pairs by priority
        if let candidatePairs {
            self.candidatePairs = candidatePairs.sorted(by: { $0.priority > $1.priority })
        }
        
        // Add transceivers (if any)
        rtpTransceivers = [RtpTransceiver]()
        for transceiver in transceivers {
            rtpTransceivers?.append(RtpTransceiver(transceiver))
        }
    }
    
    public func getShortRepresentation() -> String {
        var result = ""
        if let transport, options.transport {
            result += "\(transport.getShortRepresentation())\n"
        }
        if let crypto, options.crypto {
            result += "\(crypto.getShortRepresentation())\n"
        }
        if let candidatePairs,
           options.candidatePairsFlag.rawValue & CandidatePairVariant.OVERVIEW.rawValue != 0 {
            result += "pairs(\(candidatePairs.count))=\(candidatePairs.map { $0.getStatusChar() }.joined())\n"
        }
        if let selectedCandidatePair {
            result += "\(selectedCandidatePair.getShortRepresentation())\n"
        }
        result += "\n"
        
        if let inboundRtpAudio {
            result += "in: \(inboundRtpAudio.getShortRepresentation())\n"
        }
        if let inboundRtpVideo {
            result += "in: \(inboundRtpVideo.getShortRepresentation())\n"
        }
        if let outboundRtpAudio {
            result += "out: \(outboundRtpAudio.getShortRepresentation())\n"
        }
        if let outboundRtpVideo {
            result += "out: \(outboundRtpVideo.getShortRepresentation())\n"
        }
        result += "\n"
        
        if let inboundTrackVideo {
            result += "in/track- \(inboundTrackVideo.getShortRepresentation())\n"
        }
        if let outboundTrackVideo {
            result += "out/track- \(outboundTrackVideo.getShortRepresentation())\n"
        }

        if options.codecs, codecs != nil {
            result += "codecs "
            for (direction, codec) in describedCodecs() {
                result += "\(direction):\(codec.getShortRepresentation())"
                result += " "
            }
            result += "\n"
        }
        
        if let rtpTransceivers {
            result += "\n"
            for transceiver in rtpTransceivers {
                result += "transceiver \(transceiver.getShortRepresentation())\n"
            }
            result += "\n"
        }
        
        if let candidatePairs,
           options.candidatePairsFlag.rawValue & CandidatePairVariant.DETAILED.rawValue != 0 {
            result += "\(candidatePairs.map { $0.getShortRepresentation() }.joined(separator: "\n"))\n"
        }
        
        // Strip newline (if any)
        result.removeLast(1)
        return result
    }
    
    public func getRepresentation() -> String {
        var result = ""
        if let transport {
            result += "Transport: \(transport.getRepresentation())\n"
        }
        if let crypto {
            result += "Crypto: \(crypto.getRepresentation())\n"
        }
        if let candidatePairs,
           options.candidatePairsFlag.rawValue & CandidatePairVariant.OVERVIEW.rawValue != 0 {
            result +=
                "Candidate Pairs Overview (\(candidatePairs.count)): \(candidatePairs.map { $0.getStatusChar() }.joined())\n"
        }
        if let selectedCandidatePair {
            result += "Selected Candidate Pair: \(selectedCandidatePair.getRepresentation())\n"
        }
        if let inboundRtpAudio {
            result += "Inbound RTP Audio: \(inboundRtpAudio.getRepresentation())\n"
        }
        if let inboundRtpVideo {
            result += "Inbound RTP Video: \(inboundRtpVideo.getRepresentation())\n"
        }
        if let outboundRtpAudio {
            var line = outboundRtpAudio.getRepresentation()
            line += " mic=\(micAudioLevel.map(AudioPlayout.levelBucket) ?? "?")"
            result += "Outbound RTP Audio: \(line)\n"
        }
        if let outboundRtpVideo {
            result += "Outbound RTP Video: \(outboundRtpVideo.getRepresentation())\n"
        }
        if let inboundTrackVideo {
            result += "Inbound Track Video: \(inboundTrackVideo.getRepresentation())\n"
        }
        if let outboundTrackVideo {
            result += "Outbound Track Video: \(outboundTrackVideo.getRepresentation())\n"
        }
        if let codecs {
            result += "Codecs (\(codecs.count))\n"
            for (direction, codec) in describedCodecs() {
                result += "- \(direction) \(codec.getShortRepresentation())\n"
            }
        }
        if let rtpTransceivers {
            result += "Transceivers (\(rtpTransceivers.count))\n"
            for transceiver in rtpTransceivers {
                result += "- \(transceiver.getRepresentation())"
            }
            result += "\n"
        }
        if let candidatePairs,
           options.candidatePairsFlag.rawValue & CandidatePairVariant.DETAILED.rawValue != 0 {
            result += "Candidate Pairs (\(candidatePairs.count))\n"
            result += candidatePairs.map { "- \($0.getRepresentation())\n" }.joined()
        }
        
        // Strip newline (if any)
        result.removeLast(1)
        return result
    }
    
    /// Which direction a codec entry is used in
    ///
    /// A `codec` entry carries no direction of its own — the relationship exists only as the
    /// `codecId` pointer *from* each `inbound-rtp`/`outbound-rtp` entry *to* the codec it uses. So
    /// the label is read back off those pointers rather than guessed from the codec's stats ID,
    /// which is what the old `contains("Inbound")` classification got wrong.
    ///
    /// `?` means the codec was negotiated but no extracted RTP entry references it — including the
    /// case where `options.inboundRtp`/`outboundRtp` were off, so there is nothing to read from.
    private func codecDirectionLabel(for codecID: String) -> String {
        let isInbound = codecID == inboundRtpAudio?.codecID || codecID == inboundRtpVideo?.codecID
        let isOutbound = codecID == outboundRtpAudio?.codecID || codecID == outboundRtpVideo?.codecID

        return switch (isInbound, isOutbound) {
        case (true, false):
            "in"
        case (false, true):
            "out"
        case (true, true):
            "in/out"
        case (false, false):
            "?"
        }
    }

    /// Codecs paired with the direction they are used in, ordered so the log is stable between
    /// samples (`codecs` is a dictionary, so its own iteration order is not)
    private func describedCodecs() -> [(direction: String, codec: Codec)] {
        guard let codecs else {
            return []
        }

        return codecs
            .map { (direction: codecDirectionLabel(for: $0.key), codec: $0.value) }
            .sorted { ($0.direction, $0.codec.getShortRepresentation()) <
                ($1.direction, $1.codec.getShortRepresentation())
            }
    }

    public func isReceivingVideo() -> Bool {
        // check previous and actual frames and compare
        if totalFramesReceived != nil, previousState != nil, previousState?.videoFramesReceived != nil {
            let diff = totalFramesReceived!.distance(to: previousState!.videoFramesReceived!)
            if diff != 0 {
                return true
            }
        }
        return false
    }
    
    public func isSelectedCandidatePairCellular() -> Bool {
        if let candidatePair = selectedCandidatePair, let local = candidatePair.local {
            return local.usesCellular()
        }
        return false
    }
    
    public func usesRelay() -> Bool {
        if selectedCandidatePair != nil, selectedCandidatePair!.usesRelay {
            return true
        }
        return false
    }
        
    func buildVoIPStatsState() -> VoIPStatsState {
        VoIPStatsState(
            timestampUs: timestamp,
            audioBytesSent: outboundRtpAudio?.bytesTotal,
            audioBytesReceived: inboundRtpAudio?.bytesTotal,
            videoBytesSent: outboundRtpVideo?.bytesTotal,
            videoBytesReceived: inboundRtpVideo?.bytesTotal,
            videoFramesReceived: totalFramesReceived,
            audioPlayout: inboundRtpAudio?.audioPlayout
        )
    }
}

struct VoIPStatsState {
    let timestampUs: Double
    let audioBytesSent: UInt64?
    let audioBytesReceived: UInt64?
    let videoBytesSent: UInt64?
    let videoBytesReceived: UInt64?
    let videoFramesReceived: UInt64?

    /// Cumulative playout counters of this sample, so the next one can difference against them
    let audioPlayout: VoIPStats.AudioPlayout?
}

// Convert byte count into human readable number
// Based on: https://stackoverflow.com/a/3758880
func toHumanReadableByteCount(_ value: UInt64) -> String {
    let unit: UInt64 = 1024
    if value < unit {
        return "\(value)B"
    }
    else {
        let value = Double(value)
        let unit = Double(unit)
        let exp = (log(value) / log(unit)).binade
        return "\(String(format: "%.1f", value / pow(unit, exp)))\("KMGTPE"[Int(exp - 1)])iB"
    }
}

// Convert ms to seconds
func msToSeconds(_ value: String?) -> String {
    guard let value = Double(value) else {
        return "n/a"
    }
    return String(format: "%.3f", value / 1000)
}

func msToSeconds(_ value: Double?) -> String {
    guard value != nil else {
        return "n/a"
    }
    return String(format: "%.3f", value! / 1000)
}

// Calculate a fraction
func toFraction(_ dividend: String?, divisor: String?) -> String {
    guard let dividend = Double(dividend), let divisor = Double(divisor), divisor > 0 else {
        return "n/a"
    }
    return String(format: "%.1f", dividend / divisor)
}

func candidatePairID(statsID: String?) -> String {
    if statsID == nil {
        return "???"
    }
    let substring = String(statsID!.dropFirst(20))
    if !substring.isEmpty {
        return substring
    }
    return "???"
}

func calculateBitrate(
    previousTimestamp: Double,
    previousBytes: UInt64,
    currentTimestamp: Double,
    currentBytes: UInt64
) -> Double? {
    let microSecondsElapsed = currentTimestamp - previousTimestamp

    if microSecondsElapsed < 0 {
        debugPrint("Previous state must not have a higher timestamp than current state")
        return nil
    }
    if microSecondsElapsed < 100_000 {
        debugPrint("State timestamps should be at least 100ms apart")
        return nil
    }
    // `previous.distance(to: current)`, not the reverse: `Strideable.distance(to:)` returns
    // `other - self`, so the operands being swapped here reported every bitrate negated
    guard let bytesTransferred = cumulativeDelta(currentBytes, previousBytes) else {
        return nil
    }
    return (8 * Double(bytesTransferred)) / (microSecondsElapsed / 1000 / 1000)
}

/// Difference two samples of a counter that only ever grows
///
/// Returns `nil` rather than wrapping around when the counter went backwards, which happens when
/// the underlying stream is replaced mid-call and the counters restart at zero.
func cumulativeDelta(_ current: UInt64?, _ previous: UInt64?) -> UInt64? {
    guard let current, let previous, current >= previous else {
        return nil
    }
    return current - previous
}

func cumulativeDelta(_ current: Double?, _ previous: Double?) -> Double? {
    guard let current, let previous, current >= previous else {
        return nil
    }
    return current - previous
}

extension RTCStatistics {
    fileprivate func doubleValue(_ key: String) -> Double? {
        guard let number = values[key] as? NSNumber else {
            return nil
        }
        return Double(truncating: number)
    }

    fileprivate func uInt64Value(_ key: String) -> UInt64? {
        guard let number = values[key] as? NSNumber else {
            return nil
        }
        return UInt64(truncating: number)
    }
}

Messung
CHZG
849410092

¤ Dauer der Verarbeitung: 0.25 Sekunden  ¤

*© Formatika GbR, Deutschland






Wurzel

Suchen

PVS Prover

Isabelle Prover


Cephes Mathematical Library

Vienna Development Method

Haftungshinweis

Die Informationen auf dieser Webseite wurden nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit, noch Qualität der bereit gestellten Informationen zugesichert.

Bemerkung:

Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.