From c8376c86855ff7fa314252f1a7f03e896f7a5f9a Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:14:57 +0000 Subject: [PATCH] fix(stats): report measured stream UDP receive rate --- locales/en.json | 4 + .../crates/opennow-streamer-core/src/lib.rs | 94 ++++++++++++++++++- .../opennow-streamer-transport/src/nvst.rs | 77 +++++++++++++++ .../qml/desktop/stream/DesktopStreamStats.qml | 12 ++- opennow-qt/qml/state/ShellStore.qml | 2 +- .../src/streaming/NativeStreamRuntime.cpp | 25 +++-- opennow-qt/tests/StreamStatsV2Acceptance.qml | 18 +++- opennow-qt/tests/tst_nativestreamruntime.cpp | 10 ++ 8 files changed, 222 insertions(+), 20 deletions(-) diff --git a/locales/en.json b/locales/en.json index 3c203d139..772a67111 100644 --- a/locales/en.json +++ b/locales/en.json @@ -532,6 +532,10 @@ "advancedAudioL4sAndFrameRateView": "Advanced · audio, L4S and frame-rate view", "videoFormatUnavailable": "Video format unavailable", "streamFpsLabel": "STREAM FPS", + "videoBitrateLabel": "VIDEO BITRATE", + "streamUdpReceiveLabel": "STREAM UDP RECEIVE", + "knownSessionPeerUdpDatagramBytes": "known session peer · UDP datagram bytes", + "udpRx": "UDP RX", "localOutputFps": "LOCAL OUTPUT FPS", "frameGeneration": "FRAME GENERATION", "warmingUp": "Warming up", diff --git a/native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs b/native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs index 18576a7b4..ead65c7ec 100644 --- a/native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs +++ b/native/opennow-streamer/crates/opennow-streamer-core/src/lib.rs @@ -100,6 +100,9 @@ trait NvstSessionResources { fn network_metrics(&self) -> Option<(f64, f64)> { None } + fn socket_receive_bytes(&self) -> Option { + None + } fn frame_stage_timings(&self) -> Option { None } @@ -139,6 +142,9 @@ impl NvstSessionResources for ActiveNvstResources { fn network_metrics(&self) -> Option<(f64, f64)> { self.feedback.recent_network_metrics(Instant::now()) } + fn socket_receive_bytes(&self) -> Option { + Some(self.feedback.socket_receive_bytes()) + } fn frame_stage_timings(&self) -> Option { let timings = self.feedback.frame_stage_timings(); (!timings.is_empty()).then_some(timings) @@ -1744,6 +1750,7 @@ fn forward_nvst_session_events( transport: resources, } = event_resources; let mut feedback_state = NvstMediaFeedbackState::new(false); + feedback_state.previous_socket_receive_bytes = resources.socket_receive_bytes().unwrap_or(0); feedback_state.start_id = start_id.clone(); if let Some(queue) = captured_input.as_ref() { queue.set_text_ready(generation, false); @@ -1957,6 +1964,14 @@ fn forward_nvst_session_events( } } } + if feedback_state.telemetry_started + || resources + .socket_receive_bytes() + .is_some_and(|bytes| bytes > feedback_state.previous_socket_receive_bytes) + { + feedback_state.telemetry_started = true; + flush_nvst_telemetry(output, &resources, &mut feedback_state); + } } if let Some(queue) = captured_input.as_ref() { queue.set_text_ready(generation, false); @@ -2331,6 +2346,8 @@ struct NvstMediaFeedbackState { telemetry_window_started: Instant, telemetry_frames: u64, telemetry_bytes: u64, + telemetry_started: bool, + previous_socket_receive_bytes: u64, peak_bitrate_mbps: f64, decode_timings: Option, decode_progress: DecodeProgressWatchdog, @@ -2348,6 +2365,8 @@ impl NvstMediaFeedbackState { telemetry_window_started: Instant::now(), telemetry_frames: 0, telemetry_bytes: 0, + telemetry_started: false, + previous_socket_receive_bytes: 0, peak_bitrate_mbps: 0.0, decode_timings: None, decode_progress: DecodeProgressWatchdog::default(), @@ -2369,6 +2388,15 @@ fn flush_nvst_telemetry( let elapsed_seconds = elapsed.as_secs_f64(); let frames_per_second = state.telemetry_frames as f64 / elapsed_seconds; let bitrate_mbps = state.telemetry_bytes as f64 * 8.0 / elapsed_seconds / 1_000_000.0; + let socket_bytes = resources.socket_receive_bytes(); + let receive_bitrate_mbps = socket_bytes.and_then(|bytes| { + bytes + .checked_sub(state.previous_socket_receive_bytes) + .map(|delta| delta as f64 * 8.0 / elapsed_seconds / 1_000_000.0) + }); + if let Some(bytes) = socket_bytes { + state.previous_socket_receive_bytes = bytes; + } state.peak_bitrate_mbps = state.peak_bitrate_mbps.max(bitrate_mbps); let network = resources.network_metrics(); let _ = output.send(event( @@ -2376,6 +2404,7 @@ fn flush_nvst_telemetry( json!({ "framesPerSecond": frames_per_second, "bitrateMbps": bitrate_mbps, + "receiveBitrateMbps": receive_bitrate_mbps, "peakBitrateMbps": state.peak_bitrate_mbps, "pingMs": resources.ping_ms(), "jitterMs": network.map(|metrics| metrics.0), @@ -2427,6 +2456,7 @@ fn forward_nvst_media_feedback( } state.telemetry_frames = state.telemetry_frames.saturating_add(1); state.telemetry_bytes = state.telemetry_bytes.saturating_add(u64::from(bytes)); + state.telemetry_started = true; flush_nvst_telemetry(output, resources, state); } MediaFeedback::PlaybackStarted { backend } => { @@ -2977,7 +3007,7 @@ fn consume_encoded_media( mod tests { use super::*; use std::net::UdpSocket; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::time::Instant; fn command(value: Value) -> Command { @@ -3241,6 +3271,7 @@ mod tests { struct TestNvstResources { rumble: Arc; 4]>>, ping_ms: Option, + socket_bytes: Option>, frame_stage_timings: Option, decode_progress_policy: Option, keyframe_requests: Arc, @@ -3259,6 +3290,12 @@ mod tests { self.ping_ms } + fn socket_receive_bytes(&self) -> Option { + self.socket_bytes + .as_ref() + .map(|bytes| bytes.load(Ordering::Relaxed)) + } + fn frame_stage_timings(&self) -> Option { self.frame_stage_timings } @@ -3573,6 +3610,61 @@ mod tests { assert_eq!(telemetry["peakBitrateMbps"], telemetry["bitrateMbps"]); } + #[test] + fn socket_receive_rate_tracks_cumulative_bytes_through_idle_and_reset() { + let (sender, receiver) = std::sync::mpsc::channel(); + let sender = EventSender::unbounded(sender); + let bytes = Arc::new(AtomicU64::new(50_000)); + let resources = TestNvstResources { + socket_bytes: Some(Arc::clone(&bytes)), + ..Default::default() + }; + let mut state = NvstMediaFeedbackState::new(true); + state.previous_socket_receive_bytes = resources.socket_receive_bytes().unwrap(); + bytes.store(300_000, Ordering::Relaxed); + state.telemetry_window_started = Instant::now() - Duration::from_secs(2); + flush_nvst_telemetry(&sender, &resources, &mut state); + let first = receiver.recv().unwrap(); + assert!((first["receiveBitrateMbps"].as_f64().unwrap() - 1.0).abs() < 0.01); + assert_eq!(first["bitrateMbps"], json!(0.0)); + + bytes.store(550_000, Ordering::Relaxed); + state.telemetry_window_started = Instant::now() - Duration::from_secs(1); + flush_nvst_telemetry(&sender, &resources, &mut state); + let second = receiver.recv().unwrap(); + assert!((second["receiveBitrateMbps"].as_f64().unwrap() - 2.0).abs() < 0.02); + + state.telemetry_window_started = Instant::now() - Duration::from_secs(1); + flush_nvst_telemetry(&sender, &resources, &mut state); + assert_eq!(receiver.recv().unwrap()["receiveBitrateMbps"], json!(0.0)); + + bytes.store(100, Ordering::Relaxed); + state.telemetry_window_started = Instant::now() - Duration::from_secs(1); + flush_nvst_telemetry(&sender, &resources, &mut state); + assert_eq!(receiver.recv().unwrap()["receiveBitrateMbps"], Value::Null); + + bytes.store(125_100, Ordering::Relaxed); + state.telemetry_window_started = Instant::now() - Duration::from_secs(1); + flush_nvst_telemetry(&sender, &resources, &mut state); + assert!( + (receiver.recv().unwrap()["receiveBitrateMbps"] + .as_f64() + .unwrap() + - 1.0) + .abs() + < 0.01 + ); + + let mut next_session = NvstMediaFeedbackState::new(true); + let new_resources = TestNvstResources { + socket_bytes: Some(Arc::new(AtomicU64::new(0))), + ..Default::default() + }; + next_session.telemetry_window_started = Instant::now() - Duration::from_secs(1); + flush_nvst_telemetry(&sender, &new_resources, &mut next_session); + assert_eq!(receiver.recv().unwrap()["receiveBitrateMbps"], json!(0.0)); + } + #[test] fn accepted_video_telemetry_preserves_measured_and_unavailable_ping() { for ping_ms in [None, Some(0.0), Some(25.5)] { diff --git a/native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs b/native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs index c6dd8f4ab..55057055f 100644 --- a/native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs +++ b/native/opennow-streamer/crates/opennow-streamer-transport/src/nvst.rs @@ -536,6 +536,12 @@ struct RecentNetworkMetrics { previous: Option<(Instant, NetworkCounters)>, } +#[derive(Debug, Clone, Copy)] +enum StreamSocket { + Bundle, + Video, +} + impl RecentNetworkMetrics { fn sample(&mut self, now: Instant, counters: NetworkCounters) -> Option { if counters.ssrc == 0 || counters.base == u32::MAX || counters.highest < counters.base { @@ -584,6 +590,8 @@ pub struct NvstFeedbackState { received_packets: AtomicU32, report_prior: Mutex<(u32, u32)>, recent_network_metrics: Mutex, + bundle_receive_bytes: AtomicU64, + video_receive_bytes: AtomicU64, reception_timing: Mutex, ice_ping: Mutex>, video_ping: Mutex>, @@ -607,6 +615,8 @@ impl Default for NvstFeedbackState { received_packets: AtomicU32::new(0), report_prior: Mutex::new((0, 0)), recent_network_metrics: Mutex::new(RecentNetworkMetrics::default()), + bundle_receive_bytes: AtomicU64::new(0), + video_receive_bytes: AtomicU64::new(0), reception_timing: Mutex::new(ReceptionTiming::default()), ice_ping: Mutex::new(None), video_ping: Mutex::new(None), @@ -622,6 +632,31 @@ impl Default for NvstFeedbackState { } impl NvstFeedbackState { + fn record_socket_receive( + &self, + socket: StreamSocket, + peer_matches: bool, + authenticated: bool, + length: usize, + ) { + if !peer_matches || !authenticated { + return; + } + let counter = match socket { + StreamSocket::Bundle => &self.bundle_receive_bytes, + StreamSocket::Video => &self.video_receive_bytes, + }; + let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |bytes| { + Some(bytes.saturating_add(length as u64)) + }); + } + + pub fn socket_receive_bytes(&self) -> u64 { + self.bundle_receive_bytes + .load(Ordering::Relaxed) + .saturating_add(self.video_receive_bytes.load(Ordering::Relaxed)) + } + fn update_ice_ping( &self, pair: Option<&CandidatePairStats>, @@ -7050,6 +7085,7 @@ fn run_nvst_webrtc_bundle( if source != bundle_peer { continue; } + feedback.record_socket_receive(StreamSocket::Bundle, true, dtls_ready, length); if let Some(credentials) = stun_credentials.as_ref() { let received_at = Instant::now(); if let Some(elapsed) = ping_tracker.receive( @@ -7294,6 +7330,13 @@ fn run_nvst_udp_receiver( ); } let expected_source = receiver.config.accepts_video_source(source); + let authenticated_before = receiver.last_authenticated_packet.is_some(); + feedback.record_socket_receive( + StreamSocket::Video, + expected_source, + authenticated_before, + length, + ); if !expected_source { wrong_source += 1; } @@ -7330,6 +7373,14 @@ fn run_nvst_udp_receiver( } let received_at = Instant::now(); let events = receiver.process_datagram(source, &datagram[..length], received_at); + if !authenticated_before { + feedback.record_socket_receive( + StreamSocket::Video, + expected_source, + receiver.last_authenticated_packet.is_some(), + length, + ); + } for event in events { if !forward_receive_event( &media_consumer, @@ -7529,6 +7580,30 @@ fn forward_receive_event( mod tests { static PREFERRED_NVST_PORTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] + fn stream_socket_receive_counts_peer_datagram_lengths_once_across_sockets() { + use super::{NvstFeedbackState, StreamSocket}; + + let feedback = NvstFeedbackState::default(); + feedback.record_socket_receive(StreamSocket::Bundle, false, true, 99_000); + feedback.record_socket_receive(StreamSocket::Bundle, true, false, 88_000); + assert_eq!(feedback.socket_receive_bytes(), 0); + + let bundle_control = 1_300; + let bundle_audio = 800; + let video_data = 1_400; + let video_fec = 300; + let video_retransmission = 1_400; + feedback.record_socket_receive(StreamSocket::Bundle, true, true, bundle_control); + feedback.record_socket_receive(StreamSocket::Bundle, true, true, bundle_audio); + feedback.record_socket_receive(StreamSocket::Video, true, true, video_data); + feedback.record_socket_receive(StreamSocket::Video, true, true, video_fec); + feedback.record_socket_receive(StreamSocket::Video, true, true, video_retransmission); + feedback.record_socket_receive(StreamSocket::Video, false, true, 77_000); + assert_eq!(feedback.socket_receive_bytes(), 5_200); + assert_eq!(NvstFeedbackState::default().socket_receive_bytes(), 0); + } + #[test] fn typed_text_queue_preserves_order_reservation_and_readiness() { use opennow_streamer_protocol::text_input::{TextInputError, TextInputSlot}; @@ -11564,6 +11639,7 @@ mod tests { let mut config = NvstVideoConfig::from_legacy_handoff(&handoff, None).unwrap(); config.stun_credentials = Some(stun_credentials()); config.ping_payload = b"setup-ping".to_vec(); + let feedback = config.feedback(); let packet = protect_for_test( &test_srtp(&config), build_plaintext_rtp( @@ -11621,6 +11697,7 @@ mod tests { assert_eq!(frame.frame_index, Some(42)); assert!(frame.keyframe); session.stop(); + assert_eq!(feedback.socket_receive_bytes(), packet.len() as u64); } } } diff --git a/opennow-qt/qml/desktop/stream/DesktopStreamStats.qml b/opennow-qt/qml/desktop/stream/DesktopStreamStats.qml index f470bcb69..91e52105c 100644 --- a/opennow-qt/qml/desktop/stream/DesktopStreamStats.qml +++ b/opennow-qt/qml/desktop/stream/DesktopStreamStats.qml @@ -42,7 +42,7 @@ Item { readonly property string toggleShortcut: String(ShellStore.settings.shortcutToggleStats ?? "Ctrl+N") readonly property var heroCards: ["Fps", "Ping", "Latency"].map(key => cards.find(card => card.key === key)).filter(card => card !== undefined) readonly property var unmeasuredKeys: ["Decode", "Residence", "Latency", "Swap"] - readonly property var ledgerCards: cards.filter(card => ["Jitter", "Drops", "PacketLoss", "Decode", "Residence", "Swap", "LocalOutputFps"].includes(card.key) + readonly property var ledgerCards: cards.filter(card => ["Receive", "Jitter", "Drops", "PacketLoss", "Decode", "Residence", "Swap", "LocalOutputFps"].includes(card.key) && (card.key !== "Drops" || card.field === "videoDropCount" || card.value > 0)) readonly property var featureBadges: { const badges = [] @@ -130,7 +130,8 @@ Item { const cards = [ {key:"Ping", label:qsTr("PING"), value:read("pingMs"), unit:"ms", field:"pingMs"}, {key:"Fps", label:qsTr("STREAM FPS"), value:read("framesPerSecond"), unit:"fps", field:"framesPerSecond"}, - {key:"Bitrate", label:qsTr("BITRATE"), value:read("bitrateMbps"), unit:"Mbps", field:"bitrateMbps", decimals:1}, + {key:"Bitrate", label:qsTr("VIDEO BITRATE"), value:read("bitrateMbps"), unit:"Mbps", field:"bitrateMbps", decimals:1}, + {key:"Receive", label:qsTr("STREAM UDP RECEIVE"), value:read("receiveBitrateMbps"), unit:"Mbps", field:"receiveBitrateMbps", decimals:1}, {key:"Jitter", label:qsTr("JITTER"), value:read("jitterMs"), unit:"ms", field:"jitterMs", decimals:1}, {key:"Drops", label:qsTr("VIDEO DROPS"), value:read("videoDropCount"), unit:qsTr("frames"), field:"videoDropCount"}, {key:"Drops", label:qsTr("AUDIO DISCARDED"), value:read("audioDiscardedMs"), unit:"ms", field:"audioDiscardedMs", decimals:1}, @@ -146,7 +147,7 @@ Item { cards.push({key:"Drops", label:qsTr("UNCLASSIFIED DROPS"), value:read("otherQueueDropCount"), unit:qsTr("items"), field:"otherQueueDropCount"}) if (frameGenerationEnabled) cards.push({key:"LocalOutputFps", label:qsTr("LOCAL OUTPUT FPS"), value:frameGenerationOutputFps(), unit:"fps", field:"frameGenerationOutputFps"}) - return cards.filter(item => shown(item.key) + return cards.filter(item => shown(item.key === "Receive" ? "Bitrate" : item.key) && (!unmeasuredKeys.includes(item.key) || item.value !== null || (item.key === "Swap" && swapGated))) } @@ -179,6 +180,7 @@ Item { history = next } function ledgerDetail(card) { + if (card.field === "receiveBitrateMbps") return qsTr("known session peer · UDP datagram bytes") if (card.field === "jitterMs") { const samples = (history.jitterMs || []).filter(value => value !== null) return samples.length ? qsTr("max %1 · 60 s").arg(format(Math.max(...samples), 1)) : "" @@ -263,6 +265,7 @@ Item { const metrics = [] if (root.shown("Fps")) metrics.push({value:root.format(root.read("framesPerSecond")), unit:"fps"}) if (root.shown("Ping")) metrics.push({value:root.format(root.read("pingMs")), unit:"ms"}) + if (root.shown("Bitrate")) metrics.push({value:qsTr("UDP RX") + " " + root.format(root.read("receiveBitrateMbps"), 1), unit:"Mbps", socketReceive:true}) if (root.shown("Region")) metrics.push({value:root.region, unit:"", region:true}) if (root.shown("Video")) { const h = Number(root.profile.height || String(root.profile.resolution || "").split("x")[1] || root.live.outputHeight || 0) @@ -282,6 +285,7 @@ Item { Image { visible: compactMetric.modelData.region === true; anchors.verticalCenter: parent.verticalCenter; width: 11; height: 11; sourceSize: Qt.size(22, 22); source: "qrc:/qt/qml/OpenNOW/res/icons/stats-globe.svg" } Mono { id: compactValue + objectName: compactMetric.modelData.socketReceive === true ? "compactSocketReceive" : "" text: compactMetric.modelData.value width: Math.min(implicitWidth, compactMetric.modelData.region ? 180 : 100) font.pixelSize: compactMetric.modelData.region ? 10.5 : 12.5 @@ -374,7 +378,7 @@ Item { Item { visible: root.shown("Bitrate") width: parent.width; height: 41 - Mono { x: 16; y: 2; text: qsTr("BITRATE"); font.pixelSize: 10; font.letterSpacing: 0.95; color: "#80FFFFFF" } + Mono { x: 16; y: 2; text: qsTr("VIDEO BITRATE"); font.pixelSize: 10; font.letterSpacing: 0.95; color: "#80FFFFFF" } Row { anchors.right: parent.right; anchors.rightMargin: 16; spacing: 4 Mono { id: bitrateValue; text: root.format(root.read("bitrateMbps"), 1); font.pixelSize: 12; color: root.metricColor } diff --git a/opennow-qt/qml/state/ShellStore.qml b/opennow-qt/qml/state/ShellStore.qml index b09a1be1a..175d5377b 100644 --- a/opennow-qt/qml/state/ShellStore.qml +++ b/opennow-qt/qml/state/ShellStore.qml @@ -3056,7 +3056,7 @@ QtObject { if (event.peakBitrateMbps !== undefined) fields.peakBitrateMbps = Number(event.peakBitrateMbps) // Keep missing measurements unavailable instead of converting null to 0. - for (const key of ["pingMs", "jitterMs", "packetLossPercent", "decodeTimeMs", "decoderResidenceMs", "latencyMs"]) { + for (const key of ["receiveBitrateMbps", "pingMs", "jitterMs", "packetLossPercent", "decodeTimeMs", "decoderResidenceMs", "latencyMs"]) { if (event[key] !== undefined) fields[key] = event[key] === null || !Number.isFinite(Number(event[key])) ? null : Number(event[key]) diff --git a/opennow-qt/src/streaming/NativeStreamRuntime.cpp b/opennow-qt/src/streaming/NativeStreamRuntime.cpp index a5c079dd8..713472ea7 100644 --- a/opennow-qt/src/streaming/NativeStreamRuntime.cpp +++ b/opennow-qt/src/streaming/NativeStreamRuntime.cpp @@ -886,19 +886,18 @@ void NativeStreamRuntime::drainCallbacks(const std::shared_ptr &s if (message.event && kind == u"telemetry"_s) { const auto object = document.object(); const auto startId = object.value(u"startId"_s).toString(); - if (presentationAllowed() && !startId.isEmpty() - && startId == d->acceptedSessionStartId) { - const auto stage = object.value(u"decodeProgressStage"_s).toString(); - const bool stalled = - object.value(u"transportFrameProgressStalled"_s).toBool() - || stage == u"keyframe-pending"_s - || stage == u"recovery-required"_s; - const auto decodeTimings = object.value(u"decodeTimings"_s).toObject(); - const auto epoch = decodedCounterFromJson(decodeTimings.value(u"epoch"_s)); - const auto outputs = decodedCounterFromJson(decodeTimings.value(u"outputsTotal"_s)); - d->upstreamProgressSamples.publish(stalled, epoch.has_value() && outputs.has_value(), - epoch.value_or(0), outputs.value_or(0)); - } + if (!presentationAllowed() || startId.isEmpty() + || startId != d->acceptedSessionStartId) continue; + const auto stage = object.value(u"decodeProgressStage"_s).toString(); + const bool stalled = + object.value(u"transportFrameProgressStalled"_s).toBool() + || stage == u"keyframe-pending"_s + || stage == u"recovery-required"_s; + const auto decodeTimings = object.value(u"decodeTimings"_s).toObject(); + const auto epoch = decodedCounterFromJson(decodeTimings.value(u"epoch"_s)); + const auto outputs = decodedCounterFromJson(decodeTimings.value(u"outputsTotal"_s)); + d->upstreamProgressSamples.publish(stalled, epoch.has_value() && outputs.has_value(), + epoch.value_or(0), outputs.value_or(0)); } const auto status = document.object().value(u"status"_s).toString(); if (message.event && kind == u"cursor-capture"_s) { diff --git a/opennow-qt/tests/StreamStatsV2Acceptance.qml b/opennow-qt/tests/StreamStatsV2Acceptance.qml index 0eb5b2fed..2cf824abc 100644 --- a/opennow-qt/tests/StreamStatsV2Acceptance.qml +++ b/opennow-qt/tests/StreamStatsV2Acceptance.qml @@ -138,7 +138,7 @@ QtObject { ShellStore.runtimeStreamProfile = {maxBitrateMbps:75} ShellStore.streamStartedAtMs = Date.now() - 6130000 ShellStore.streamer = {status:"streaming", framesPerSecond:120, pingMs:9, latencyMs:31, - bitrateMbps:74.6, jitterMs:1.2, packetLossPercent:0, decodeTimeMs:2.1, decoderResidenceMs:6.4, + bitrateMbps:74.6, receiveBitrateMbps:82.1, jitterMs:1.2, packetLossPercent:0, decodeTimeMs:2.1, decoderResidenceMs:6.4, mediaBackend:"Vulkan"} ShellStore.connectionHealth.clock = () => fixture.sampleTime sample(0) @@ -146,6 +146,22 @@ QtObject { check(stats !== null, "overlay created") check(stats.allocatedBitrateMbps === 75, "allocation uses prepared session, not editable settings") check(Math.abs(stats.bitrateUsage - 74.6 / 75) < 0.000001, "bar uses measured / allocated bitrate") + check(stats.cards.find(card => card.field === "bitrateMbps").label === qsTr("VIDEO BITRATE") + && stats.cards.find(card => card.field === "receiveBitrateMbps").label === qsTr("STREAM UDP RECEIVE") + && stats.ledgerCards.some(card => card.field === "receiveBitrateMbps" && card.value === 82.1) + && stats.ledgerDetail(stats.cards.find(card => card.field === "receiveBitrateMbps")) + === qsTr("known session peer · UDP datagram bytes") + && stats.report().includes("STREAM UDP RECEIVE: 82.1 Mbps"), + "video and peer-filtered socket rates remain distinct in the expanded panel and report") + const compactReceive = find(stats, "compactSocketReceive") + const compactBar = find(stats, "compactStatsBar") + check(compactReceive && compactReceive.text === qsTr("UDP RX") + " 82.1" + && compactBar && compactBar.x >= 0 && compactBar.x + compactBar.width <= stats.width, + "default compact F3 shows the measured socket rate within the viewport") + ShellStore.acceptNativeEvent({type:"telemetry", receiveBitrateMbps:null}) + check(stats.read("receiveBitrateMbps") === null && stats.report().includes("STREAM UDP RECEIVE: N/A Mbps"), + "unavailable socket samples must not retain a previous rate") + ShellStore.acceptNativeEvent({type:"telemetry", receiveBitrateMbps:82.1}) check(stats.healthKnown && !stats.degraded, "zero packet loss is healthy") check(stats.videoText === "AV1 · 2560×1440 · 10-bit 4:2:0 · HDR", "real negotiated profile fields format correctly") check(stats.featureBadges.length === 1 && stats.featureBadges[0].text === "HDR", "only enabled features are advertised") diff --git a/opennow-qt/tests/tst_nativestreamruntime.cpp b/opennow-qt/tests/tst_nativestreamruntime.cpp index 6d3f0b78e..ffc1e0cab 100644 --- a/opennow-qt/tests/tst_nativestreamruntime.cpp +++ b/opennow-qt/tests/tst_nativestreamruntime.cpp @@ -709,6 +709,7 @@ private slots: }; NativeStreamRuntime runtime(api); QVERIFY(runtime.start()); + QSignalSpy delivered(&runtime, &NativeStreamRuntime::eventReceived); const auto start = [&](const QString &id) { return runtime.send({{QStringLiteral("type"), QStringLiteral("start")}, {QStringLiteral("id"), id}}); @@ -741,6 +742,8 @@ private slots: deliver(telemetry(QStringLiteral("session-a"), true, QString(), timings)); QCOMPARE(sample(), (std::pair{false, false})); + QCoreApplication::processEvents(); + QCOMPARE(delivered.size(), 0); QVERIFY(start(QStringLiteral("session-a"))); QTRY_VERIFY(runtime.presentationAllowed()); @@ -748,6 +751,7 @@ private slots: deliver(telemetry(QStringLiteral("session-a"), true, QStringLiteral("tracking"), timings)); QTRY_COMPARE(sample(), (std::pair{true, true})); + QTRY_COMPARE(delivered.size(), 1); deliver(telemetry(QStringLiteral("session-a"), false, QStringLiteral("tracking"), timings)); QTRY_COMPARE(sample(), (std::pair{true, false})); QCOMPARE(runtime.upstreamProgress().decodeEpoch, quint64(4)); @@ -763,6 +767,8 @@ private slots: QCOMPARE(sample(), (std::pair{true, true})); deliver(telemetry(QString(), false, QStringLiteral("tracking"), timings)); QCOMPARE(sample(), (std::pair{true, true})); + QCoreApplication::processEvents(); + QCOMPARE(delivered.size(), 4); deliver(telemetry(QStringLiteral("session-a"), false, QStringLiteral("tracking"), QJsonObject{{QStringLiteral("epoch"), -1}, {QStringLiteral("outputsTotal"), 1.5}})); @@ -774,8 +780,12 @@ private slots: QVERIFY(start(QStringLiteral("session-b"))); QTRY_VERIFY(runtime.presentationAllowed()); QCOMPARE(sample(), (std::pair{false, false})); + deliver(telemetry(QStringLiteral("session-a"), false, QStringLiteral("tracking"), timings)); + QCoreApplication::processEvents(); + QCOMPARE(delivered.size(), 6); deliver(telemetry(QStringLiteral("session-b"), false, QStringLiteral("tracking"), timings)); QTRY_COMPARE(sample(), (std::pair{true, false})); + QTRY_COMPARE(delivered.size(), 7); QVERIFY(runtime.shutdown()); }