From 17fba213d52ecb59ea47b4f44603458db68894ec Mon Sep 17 00:00:00 2001 From: leandredesmaretz Date: Sat, 18 Jul 2026 01:48:26 +0200 Subject: [PATCH 1/8] feat(stream): aggregate Moonlight FEC telemetry Signed-off-by: leandredesmaretz --- cmake/compile_definitions/common.cmake | 2 + src/network_metrics.cpp | 169 +++++++++++++++ src/network_metrics.h | 157 ++++++++++++++ src/stream.cpp | 78 +++++++ src/stream.h | 12 +- tests/unit/test_network_metrics.cpp | 279 +++++++++++++++++++++++++ 6 files changed, 696 insertions(+), 1 deletion(-) create mode 100644 src/network_metrics.cpp create mode 100644 src/network_metrics.h create mode 100644 tests/unit/test_network_metrics.cpp diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index 502c9d63d2a..8fec485c34a 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -160,6 +160,8 @@ set(SUNSHINE_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/process.h" "${CMAKE_SOURCE_DIR}/src/network.cpp" "${CMAKE_SOURCE_DIR}/src/network.h" + "${CMAKE_SOURCE_DIR}/src/network_metrics.cpp" + "${CMAKE_SOURCE_DIR}/src/network_metrics.h" "${CMAKE_SOURCE_DIR}/src/move_by_copy.h" "${CMAKE_SOURCE_DIR}/src/system_tray.cpp" "${CMAKE_SOURCE_DIR}/src/system_tray.h" diff --git a/src/network_metrics.cpp b/src/network_metrics.cpp new file mode 100644 index 00000000000..04e5a9cf20f --- /dev/null +++ b/src/network_metrics.cpp @@ -0,0 +1,169 @@ +/** + * @file src/network_metrics.cpp + * @brief Definitions for per-session Moonlight network telemetry. + */ + +// local includes +#include "network_metrics.h" + +namespace stream::network_metrics { + namespace { + /** + * @brief Read a big-endian 16-bit integer from a validated payload. + * + * @param payload Raw protocol payload. + * @param offset Byte offset to read. + * @return Host-endian integer. + */ + std::uint16_t read_u16_be(const std::string_view payload, const std::size_t offset) { + const auto *bytes = reinterpret_cast(payload.data()); + return static_cast((static_cast(bytes[offset]) << 8) | bytes[offset + 1]); + } + + /** + * @brief Read a big-endian 32-bit integer from a validated payload. + * + * @param payload Raw protocol payload. + * @param offset Byte offset to read. + * @return Host-endian integer. + */ + std::uint32_t read_u32_be(const std::string_view payload, const std::size_t offset) { + const auto *bytes = reinterpret_cast(payload.data()); + return (static_cast(bytes[offset]) << 24) | + (static_cast(bytes[offset + 1]) << 16) | + (static_cast(bytes[offset + 2]) << 8) | + bytes[offset + 3]; + } + + /** + * @brief Check invariants guaranteed by Moonlight's FEC queue implementation. + * + * @param status Decoded status to validate. + * @return `true` when all count and block relationships are valid. + */ + bool has_valid_relationships(const frame_fec_status_t &status) { + constexpr std::uint8_t max_multi_fec_blocks = 4; + const auto total_packets = static_cast(status.total_data_packets) + status.total_parity_packets; + const auto valid_missing_count = total_packets == 0 ? + status.missing_packets_before_highest_received == 0 : + status.missing_packets_before_highest_received < total_packets; + const auto valid_counts = status.received_data_packets <= status.total_data_packets && + status.received_parity_packets <= status.total_parity_packets && + valid_missing_count; + const auto valid_blocks = status.multi_fec_block_count != 0 && + status.multi_fec_block_count <= max_multi_fec_blocks && + status.multi_fec_block_index < status.multi_fec_block_count; + return valid_counts && valid_blocks; + } + } // namespace + + std::optional parse_frame_fec_status(const std::string_view payload) { + if (payload.size() != frame_fec_status_payload_size) { + return std::nullopt; + } + + const auto *bytes = reinterpret_cast(payload.data()); + + frame_fec_status_t status { + .frame_index = read_u32_be(payload, 0), + .highest_received_sequence_number = read_u16_be(payload, 4), + .next_contiguous_sequence_number = read_u16_be(payload, 6), + .missing_packets_before_highest_received = read_u16_be(payload, 8), + .total_data_packets = read_u16_be(payload, 10), + .total_parity_packets = read_u16_be(payload, 12), + .received_data_packets = read_u16_be(payload, 14), + .received_parity_packets = read_u16_be(payload, 16), + .fec_percentage = bytes[18], + .multi_fec_block_index = bytes[19], + .multi_fec_block_count = bytes[20], + }; + + return status; + } + + tracker_t::tracker_t(const time_point_t window_start): + window_start_ {window_start} { + } + + bool tracker_t::accumulator_t::has_activity() const { + return processed_reports != 0 || idr_requests != 0; + } + + ingest_result_e tracker_t::ingest(const std::string_view payload, const bool feature_advertised) { + if (accumulator_.processed_reports >= max_reports_per_window) { + ++accumulator_.rate_limited_reports; + return ingest_result_e::rate_limited; + } + ++accumulator_.processed_reports; + + if (!feature_advertised) { + ++accumulator_.protocol_mismatch_reports; + return ingest_result_e::protocol_mismatch; + } + + auto status = parse_frame_fec_status(payload); + if (!status || !has_valid_relationships(*status)) { + ++accumulator_.malformed_reports; + return ingest_result_e::malformed; + } + + ++accumulator_.fec_reports; + accumulator_.missing_packets += status->missing_packets_before_highest_received; + accumulator_.received_data_packets += status->received_data_packets; + accumulator_.received_parity_packets += status->received_parity_packets; + + const auto missing_data_packets = status->total_data_packets > status->received_data_packets ? + static_cast(status->total_data_packets - status->received_data_packets) : + 0; + const auto received_shards = static_cast(status->received_data_packets) + status->received_parity_packets; + if (received_shards >= status->total_data_packets) { + accumulator_.fec_recovered_data_packets += missing_data_packets; + } else { + accumulator_.unrecovered_data_packets += missing_data_packets; + } + + return ingest_result_e::accepted; + } + + void tracker_t::record_idr_request() { + ++accumulator_.idr_requests; + } + + std::optional tracker_t::poll( + const time_point_t now, + const std::uint32_t rtt_ms, + const std::uint32_t rtt_variance_ms + ) { + const auto elapsed = now - window_start_; + if (elapsed < aggregation_window) { + return std::nullopt; + } + + const auto had_activity = accumulator_.has_activity(); + snapshot_t snapshot { + .sequence = next_sequence_, + .window_duration_ms = static_cast(std::chrono::duration_cast(elapsed).count()), + .fec_reports = accumulator_.fec_reports, + .missing_packets = accumulator_.missing_packets, + .unrecovered_data_packets = accumulator_.unrecovered_data_packets, + .received_data_packets = accumulator_.received_data_packets, + .received_parity_packets = accumulator_.received_parity_packets, + .fec_recovered_data_packets = accumulator_.fec_recovered_data_packets, + .idr_requests = accumulator_.idr_requests, + .malformed_reports = accumulator_.malformed_reports, + .protocol_mismatch_reports = accumulator_.protocol_mismatch_reports, + .rate_limited_reports = accumulator_.rate_limited_reports, + .rtt_ms = rtt_ms, + .rtt_variance_ms = rtt_variance_ms, + }; + + window_start_ = now; + accumulator_ = {}; + if (!had_activity) { + return std::nullopt; + } + + ++next_sequence_; + return snapshot; + } +} // namespace stream::network_metrics diff --git a/src/network_metrics.h b/src/network_metrics.h new file mode 100644 index 00000000000..4cdc83cf042 --- /dev/null +++ b/src/network_metrics.h @@ -0,0 +1,157 @@ +/** + * @file src/network_metrics.h + * @brief Declarations for per-session Moonlight network telemetry. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include + +namespace stream::network_metrics { + /** Aggregation period for client network telemetry. */ + inline constexpr std::chrono::milliseconds aggregation_window {500}; + + /** Size of the current SS_FRAME_FEC_STATUS wire payload. */ + inline constexpr std::size_t frame_fec_status_payload_size = 21; + + /** Maximum number of FEC reports processed for one session in one window. */ + inline constexpr std::uint32_t max_reports_per_window = 512; + + /** Monotonic time point used to delimit aggregation windows. */ + using time_point_t = std::chrono::steady_clock::time_point; + + /** + * @brief Describes how an incoming FEC report was handled. + */ + enum class ingest_result_e { + accepted, ///< The payload was valid and added to the current window. + malformed, ///< The payload size or field relationships were invalid. + protocol_mismatch, ///< The client did not advertise the current FEC status feature. + rate_limited, ///< The per-window processing limit was already reached. + }; + + /** + * @brief Host-endian representation of one SS_FRAME_FEC_STATUS message. + */ + struct frame_fec_status_t { + std::uint32_t frame_index; ///< Client frame index associated with the report. + std::uint16_t highest_received_sequence_number; ///< Highest RTP sequence number observed by the client. + std::uint16_t next_contiguous_sequence_number; ///< Client contiguous-prefix cursor when fast-path tracking stopped. + std::uint16_t missing_packets_before_highest_received; ///< Data or parity gaps observed before the highest received packet. + std::uint16_t total_data_packets; ///< Data shards expected for the FEC block. + std::uint16_t total_parity_packets; ///< Parity shards expected for the FEC block. + std::uint16_t received_data_packets; ///< Data shards received from the network. + std::uint16_t received_parity_packets; ///< Parity shards received from the network. + std::uint8_t fec_percentage; ///< FEC percentage encoded by Sunshine for this FEC block. + std::uint8_t multi_fec_block_index; ///< Zero-based FEC block index within the frame. + std::uint8_t multi_fec_block_count; ///< Total FEC block count for the frame. + }; + + /** + * @brief Immutable aggregate published for one telemetry window. + * + * @note Moonlight emits FEC status reports only around recovery and drop + * events, over a bounded best-effort queue. An empty window therefore means + * "no report observed", not "the network had no packet loss". + */ + struct snapshot_t { + std::uint64_t sequence; ///< Per-session snapshot sequence number. + std::uint64_t window_duration_ms; ///< Actual monotonic window duration in milliseconds. + std::uint64_t fec_reports; ///< Valid FEC reports accepted in the window. + std::uint64_t missing_packets; ///< Client-reported gaps before the highest received packets. + std::uint64_t unrecovered_data_packets; ///< Missing data shards from blocks that could not be decoded. + std::uint64_t received_data_packets; ///< Data shards received across all accepted reports. + std::uint64_t received_parity_packets; ///< Parity shards received across all accepted reports. + std::uint64_t fec_recovered_data_packets; ///< Missing data shards reconstructed by decodable FEC blocks. + std::uint64_t idr_requests; ///< IDR requests received in the window. + std::uint64_t malformed_reports; ///< Reports rejected because their payload was invalid. + std::uint64_t protocol_mismatch_reports; ///< Reports received without advertised FEC status support. + std::uint64_t rate_limited_reports; ///< Reports skipped after the processing limit was reached. + std::uint32_t rtt_ms; ///< ENet smoothed round-trip time at publication. + std::uint32_t rtt_variance_ms; ///< ENet round-trip-time variance at publication. + }; + + /** + * @brief Decode and validate a current-version SS_FRAME_FEC_STATUS payload. + * + * @param payload Raw 21-byte big-endian payload received on the control channel. + * @return Decoded host-endian fields, or no value when the payload is invalid. + */ + std::optional parse_frame_fec_status(std::string_view payload); + + /** + * @brief Aggregates Moonlight FEC reports for one stream session. + * + * The tracker is owned by the control thread. Call poll() before ingest() when + * processing a new event so an elapsed window is published before that event + * starts the next window. Reports may be lost, reordered, or repeated for + * different FEC blocks of the same frame, so aggregation never infers a + * complete per-frame timeline. + */ + class tracker_t { + public: + /** + * @brief Create a tracker whose first window starts at the supplied time. + * + * @param window_start Start of the first aggregation window. + */ + explicit tracker_t(time_point_t window_start = std::chrono::steady_clock::now()); + + /** + * @brief Validate and add one client FEC report to the current window. + * + * @param payload Raw SS_FRAME_FEC_STATUS payload. + * @param feature_advertised Whether the client advertised ML_FF_FEC_STATUS for the session. + * @return Result describing whether the report was accepted or rejected. + */ + ingest_result_e ingest(std::string_view payload, bool feature_advertised); + + /** + * @brief Count one IDR request in the current window. + */ + void record_idr_request(); + + /** + * @brief Publish and reset an elapsed non-empty aggregation window. + * + * @param now Monotonic publication time. + * @param rtt_ms Current ENet smoothed round-trip time. + * @param rtt_variance_ms Current ENet round-trip-time variance. + * @return Completed snapshot, or no value before expiry or for an empty window. + */ + std::optional poll(time_point_t now, std::uint32_t rtt_ms, std::uint32_t rtt_variance_ms); + + private: + /** + * @brief Mutable counters for the active aggregation window. + */ + struct accumulator_t { + std::uint32_t processed_reports = 0; ///< Reports admitted to validation in this window. + std::uint64_t fec_reports = 0; ///< Valid FEC reports. + std::uint64_t missing_packets = 0; ///< Client-reported packet gaps. + std::uint64_t unrecovered_data_packets = 0; ///< Data shards that remained unrecovered. + std::uint64_t received_data_packets = 0; ///< Data shards received. + std::uint64_t received_parity_packets = 0; ///< Parity shards received. + std::uint64_t fec_recovered_data_packets = 0; ///< Data shards recovered through FEC. + std::uint64_t idr_requests = 0; ///< IDR requests. + std::uint64_t malformed_reports = 0; ///< Invalid payloads. + std::uint64_t protocol_mismatch_reports = 0; ///< Reports without advertised support. + std::uint64_t rate_limited_reports = 0; ///< Reports skipped by the processing limit. + + /** + * @brief Return whether this window contains any report or IDR activity. + * + * @return `true` when publishing the window would expose useful telemetry. + */ + bool has_activity() const; + }; + + time_point_t window_start_; ///< Monotonic start of the active window. + std::uint64_t next_sequence_ = 1; ///< Sequence assigned to the next published snapshot. + accumulator_t accumulator_; ///< Counters for the active window. + }; +} // namespace stream::network_metrics diff --git a/src/stream.cpp b/src/stream.cpp index fa0151997bd..0710f85527a 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -26,6 +26,7 @@ extern "C" { #include "input.h" #include "logging.h" #include "network.h" +#include "network_metrics.h" #include "platform/common.h" #include "process.h" #include "stream.h" @@ -77,6 +78,11 @@ using asio::ip::udp; using namespace std::literals; +static_assert( + sizeof(SS_FRAME_FEC_STATUS) == stream::network_metrics::frame_fec_status_payload_size, + "Moonlight SS_FRAME_FEC_STATUS wire layout must remain 21 bytes" +); + namespace stream { /** @@ -534,6 +540,11 @@ namespace stream { safe::mail_raw_t::event_t hdr_queue; } control; ///< Runtime state for the encrypted GameStream control channel. + struct { + network_metrics::tracker_t tracker; ///< Control-thread aggregator for Moonlight FEC reports. + sync_util::sync_t> latest_snapshot; ///< Latest stable window for future consumers. + } network_metrics; ///< Per-session network telemetry state. + std::uint32_t launch_session_id; ///< RTSP launch-session ID associated with this stream. std::string client_cert; ///< PEM certificate for the paired client owning the stream. @@ -1101,6 +1112,50 @@ namespace stream { return 0; } + /** + * @brief Publish an elapsed network telemetry window for one session. + * + * @param session Active streaming session whose control peer supplies RTT data. + * @param now Monotonic publication time. + */ + void publish_network_metrics(session_t *session, const network_metrics::time_point_t now) { + if (!session->control.peer) { + return; + } + + auto snapshot = session->network_metrics.tracker.poll( + now, + session->control.peer->roundTripTime, + session->control.peer->roundTripTimeVariance + ); + if (!snapshot) { + return; + } + + { + auto lock = session->network_metrics.latest_snapshot.lock(); + *session->network_metrics.latest_snapshot = *snapshot; + } + + BOOST_LOG(debug) + << "network_metrics" + << " session_id=" << session->launch_session_id + << " sequence=" << snapshot->sequence + << " window_ms=" << snapshot->window_duration_ms + << " fec_reports=" << snapshot->fec_reports + << " missing_packets=" << snapshot->missing_packets + << " unrecovered_data_packets=" << snapshot->unrecovered_data_packets + << " received_data_packets=" << snapshot->received_data_packets + << " received_parity_packets=" << snapshot->received_parity_packets + << " fec_recovered_data_packets=" << snapshot->fec_recovered_data_packets + << " idr_requests=" << snapshot->idr_requests + << " malformed_reports=" << snapshot->malformed_reports + << " protocol_mismatch_reports=" << snapshot->protocol_mismatch_reports + << " rate_limited_reports=" << snapshot->rate_limited_reports + << " rtt_ms=" << snapshot->rtt_ms + << " rtt_variance_ms=" << snapshot->rtt_variance_ms; + } + /** * @brief Run the broadcast control-channel worker thread. * @@ -1135,9 +1190,22 @@ namespace stream { << "---end stats---"; }); + // Moonlight sends SS_FRAME_FEC_STATUS as 0x5502 from client to host. Sunshine + // also uses 0x5502 in the opposite direction for RGB LED feedback, so keep + // this receive-only mapping separate from packetTypes[IDX_SET_RGB_LED]. + server->map(SS_FRAME_FEC_PTYPE, [&](session_t *session, const std::string_view &payload) { + publish_network_metrics(session, std::chrono::steady_clock::now()); + session->network_metrics.tracker.ingest( + payload, + (session->config.mlFeatureFlags & ML_FF_FEC_STATUS) != 0 + ); + }); + server->map(packetTypes[IDX_REQUEST_IDR_FRAME], [&](session_t *session, const std::string_view &payload) { BOOST_LOG(debug) << "type [IDX_REQUEST_IDR_FRAME]"sv; + publish_network_metrics(session, std::chrono::steady_clock::now()); + session->network_metrics.tracker.record_idr_request(); session->video.idr_events->raise(true); }); @@ -1299,6 +1367,8 @@ namespace stream { if (!session->control.peer) { has_session_awaiting_peer = true; } else { + publish_network_metrics(session, now); + auto &feedback_queue = session->control.feedback_queue; while (feedback_queue->peek()) { auto feedback_msg = feedback_queue->pop(); @@ -2139,6 +2209,14 @@ namespace stream { return session.client_cert; } + /** + * @brief Return the latest completed network telemetry window for this session. + */ + std::optional network_metrics_snapshot(session_t &session) { + auto lock = session.network_metrics.latest_snapshot.lock(); + return *session.network_metrics.latest_snapshot; + } + /** * @brief Stop the active streaming session and prevent new packets from being queued. */ diff --git a/src/stream.h b/src/stream.h index c0b0f962378..c9ffcaba980 100644 --- a/src/stream.h +++ b/src/stream.h @@ -5,6 +5,7 @@ #pragma once // standard includes +#include #include // lib includes @@ -13,6 +14,7 @@ // local includes #include "audio.h" #include "crypto.h" +#include "network_metrics.h" #include "video.h" namespace stream { @@ -31,7 +33,7 @@ namespace stream { int packetsize; ///< Maximum payload size for network packets. int minRequiredFecPackets; ///< Minimum recovery packets required before FEC is emitted. - int mlFeatureFlags; ///< Moonlight feature flags negotiated for this session. + int mlFeatureFlags; ///< Moonlight feature flags advertised by the client for this session. int controlProtocolType; ///< GameStream control protocol variant selected by the client. int audioQosType; ///< Audio QoS type. int videoQosType; ///< Video QoS type. @@ -94,5 +96,13 @@ namespace stream { * @return PEM certificate associated with the session's client. */ const std::string &client_cert(session_t &session); + + /** + * @brief Return the latest completed Moonlight network telemetry window. + * + * @param session Active streaming session to inspect. + * @return Stable per-session snapshot, or no value before the first active window completes. + */ + std::optional network_metrics_snapshot(session_t &session); } // namespace session } // namespace stream diff --git a/tests/unit/test_network_metrics.cpp b/tests/unit/test_network_metrics.cpp new file mode 100644 index 00000000000..b73d75554f9 --- /dev/null +++ b/tests/unit/test_network_metrics.cpp @@ -0,0 +1,279 @@ +/** + * @file tests/unit/test_network_metrics.cpp + * @brief Test src/network_metrics.*. + */ + +// standard includes +#include +#include +#include +#include + +// local includes +#include "../tests_common.h" + +#include + +namespace { + using namespace std::chrono_literals; + using stream::network_metrics::frame_fec_status_payload_size; + + /** + * @brief Write a big-endian 16-bit integer into a test payload. + * + * @param payload Destination payload. + * @param offset Byte offset to write. + * @param value Host-endian value. + */ + void put_u16_be(std::array &payload, const std::size_t offset, const std::uint16_t value) { + payload[offset] = static_cast(value >> 8); + payload[offset + 1] = static_cast(value); + } + + /** + * @brief Write a big-endian 32-bit integer into a test payload. + * + * @param payload Destination payload. + * @param offset Byte offset to write. + * @param value Host-endian value. + */ + void put_u32_be(std::array &payload, const std::size_t offset, const std::uint32_t value) { + payload[offset] = static_cast(value >> 24); + payload[offset + 1] = static_cast(value >> 16); + payload[offset + 2] = static_cast(value >> 8); + payload[offset + 3] = static_cast(value); + } + + /** + * @brief Build a valid current-version FEC report for tracker tests. + * + * @param total_data Expected data-shard count. + * @param total_parity Expected parity-shard count. + * @param received_data Received data-shard count. + * @param received_parity Received parity-shard count. + * @param missing Client-reported packet gaps. + * @return Big-endian SS_FRAME_FEC_STATUS payload. + */ + std::array make_payload( + const std::uint16_t total_data, + const std::uint16_t total_parity, + const std::uint16_t received_data, + const std::uint16_t received_parity, + const std::uint16_t missing + ) { + std::array payload {}; + put_u32_be(payload, 0, 42); + put_u16_be(payload, 4, 120); + put_u16_be(payload, 6, 115); + put_u16_be(payload, 8, missing); + put_u16_be(payload, 10, total_data); + put_u16_be(payload, 12, total_parity); + put_u16_be(payload, 14, received_data); + put_u16_be(payload, 16, received_parity); + payload[18] = 20; + payload[19] = 0; + payload[20] = 1; + return payload; + } + + /** + * @brief View a byte array as an immutable protocol payload. + * + * @param payload Byte array to view. + * @return String view spanning the payload bytes. + */ + template + std::string_view payload_view(const std::array &payload) { + return {reinterpret_cast(payload.data()), payload.size()}; + } +} // namespace + +TEST(FrameFecStatusTests, ParsesCurrentBigEndianWireFormat) { + std::array payload {}; + for (std::size_t index = 0; index < payload.size(); ++index) { + payload[index] = static_cast(index + 1); + } + + auto status = stream::network_metrics::parse_frame_fec_status(payload_view(payload)); + + ASSERT_TRUE(status); + EXPECT_EQ(status->frame_index, 0x01020304u); + EXPECT_EQ(status->highest_received_sequence_number, 0x0506u); + EXPECT_EQ(status->next_contiguous_sequence_number, 0x0708u); + EXPECT_EQ(status->missing_packets_before_highest_received, 0x090Au); + EXPECT_EQ(status->total_data_packets, 0x0B0Cu); + EXPECT_EQ(status->total_parity_packets, 0x0D0Eu); + EXPECT_EQ(status->received_data_packets, 0x0F10u); + EXPECT_EQ(status->received_parity_packets, 0x1112u); + EXPECT_EQ(status->fec_percentage, 0x13u); + EXPECT_EQ(status->multi_fec_block_index, 0x14u); + EXPECT_EQ(status->multi_fec_block_count, 0x15u); +} + +TEST(FrameFecStatusTests, RejectsPayloadWithWrongVersionSize) { + std::array short_payload {}; + std::array long_payload {}; + + EXPECT_FALSE(stream::network_metrics::parse_frame_fec_status(payload_view(short_payload))); + EXPECT_FALSE(stream::network_metrics::parse_frame_fec_status(payload_view(long_payload))); +} + +TEST(NetworkMetricsTrackerTests, RequiresAdvertisedFeature) { + const auto start = stream::network_metrics::time_point_t {}; + stream::network_metrics::tracker_t tracker {start}; + auto payload = make_payload(10, 3, 8, 2, 2); + + EXPECT_EQ( + tracker.ingest(payload_view(payload), false), + stream::network_metrics::ingest_result_e::protocol_mismatch + ); + + auto snapshot = tracker.poll(start + 500ms, 7, 2); + ASSERT_TRUE(snapshot); + EXPECT_EQ(snapshot->fec_reports, 0u); + EXPECT_EQ(snapshot->protocol_mismatch_reports, 1u); +} + +TEST(NetworkMetricsTrackerTests, CountsMalformedPayloads) { + const auto start = stream::network_metrics::time_point_t {}; + stream::network_metrics::tracker_t tracker {start}; + std::array payload {}; + + EXPECT_EQ( + tracker.ingest(payload_view(payload), true), + stream::network_metrics::ingest_result_e::malformed + ); + + auto snapshot = tracker.poll(start + 500ms, 7, 2); + ASSERT_TRUE(snapshot); + EXPECT_EQ(snapshot->malformed_reports, 1u); +} + +TEST(NetworkMetricsTrackerTests, RejectsInvalidFieldRelationships) { + const auto start = stream::network_metrics::time_point_t {}; + stream::network_metrics::tracker_t tracker {start}; + auto excessive_data = make_payload(10, 3, 11, 2, 0); + auto excessive_parity = make_payload(10, 3, 8, 4, 0); + auto excessive_missing = make_payload(10, 3, 8, 2, 13); + auto zero_block_count = make_payload(10, 3, 8, 2, 0); + auto excessive_block_count = make_payload(10, 3, 8, 2, 0); + auto invalid_block_index = make_payload(10, 3, 8, 2, 0); + zero_block_count[20] = 0; + excessive_block_count[20] = 5; + invalid_block_index[19] = 1; + + EXPECT_EQ(tracker.ingest(payload_view(excessive_data), true), stream::network_metrics::ingest_result_e::malformed); + EXPECT_EQ(tracker.ingest(payload_view(excessive_parity), true), stream::network_metrics::ingest_result_e::malformed); + EXPECT_EQ(tracker.ingest(payload_view(excessive_missing), true), stream::network_metrics::ingest_result_e::malformed); + EXPECT_EQ(tracker.ingest(payload_view(zero_block_count), true), stream::network_metrics::ingest_result_e::malformed); + EXPECT_EQ(tracker.ingest(payload_view(excessive_block_count), true), stream::network_metrics::ingest_result_e::malformed); + EXPECT_EQ(tracker.ingest(payload_view(invalid_block_index), true), stream::network_metrics::ingest_result_e::malformed); + + auto snapshot = tracker.poll(start + 500ms, 7, 2); + ASSERT_TRUE(snapshot); + EXPECT_EQ(snapshot->fec_reports, 0u); + EXPECT_EQ(snapshot->malformed_reports, 6u); +} + +TEST(NetworkMetricsTrackerTests, HandlesZeroTotalIncompleteReports) { + const auto start = stream::network_metrics::time_point_t {}; + stream::network_metrics::tracker_t tracker {start}; + auto incomplete = make_payload(0, 0, 0, 0, 0); + auto invalid_missing = make_payload(0, 0, 0, 0, 1); + + EXPECT_EQ(tracker.ingest(payload_view(incomplete), true), stream::network_metrics::ingest_result_e::accepted); + EXPECT_EQ(tracker.ingest(payload_view(invalid_missing), true), stream::network_metrics::ingest_result_e::malformed); + + auto snapshot = tracker.poll(start + 500ms, 7, 2); + ASSERT_TRUE(snapshot); + EXPECT_EQ(snapshot->fec_reports, 1u); + EXPECT_EQ(snapshot->malformed_reports, 1u); + EXPECT_EQ(snapshot->received_data_packets, 0u); + EXPECT_EQ(snapshot->received_parity_packets, 0u); +} + +TEST(NetworkMetricsTrackerTests, AggregatesRecoveredAndUnrecoveredBlocks) { + const auto start = stream::network_metrics::time_point_t {}; + stream::network_metrics::tracker_t tracker {start}; + auto recovered = make_payload(10, 3, 8, 2, 2); + auto unrecovered = make_payload(10, 3, 7, 2, 3); + + EXPECT_EQ(tracker.ingest(payload_view(recovered), true), stream::network_metrics::ingest_result_e::accepted); + EXPECT_EQ(tracker.ingest(payload_view(unrecovered), true), stream::network_metrics::ingest_result_e::accepted); + tracker.record_idr_request(); + tracker.record_idr_request(); + + EXPECT_FALSE(tracker.poll(start + 499ms, 9, 3)); + auto snapshot = tracker.poll(start + 500ms, 9, 3); + + ASSERT_TRUE(snapshot); + EXPECT_EQ(snapshot->sequence, 1u); + EXPECT_EQ(snapshot->window_duration_ms, 500u); + EXPECT_EQ(snapshot->fec_reports, 2u); + EXPECT_EQ(snapshot->missing_packets, 5u); + EXPECT_EQ(snapshot->unrecovered_data_packets, 3u); + EXPECT_EQ(snapshot->received_data_packets, 15u); + EXPECT_EQ(snapshot->received_parity_packets, 4u); + EXPECT_EQ(snapshot->fec_recovered_data_packets, 2u); + EXPECT_EQ(snapshot->idr_requests, 2u); + EXPECT_EQ(snapshot->rtt_ms, 9u); + EXPECT_EQ(snapshot->rtt_variance_ms, 3u); +} + +TEST(NetworkMetricsTrackerTests, KeepsSessionsIsolated) { + const auto start = stream::network_metrics::time_point_t {}; + stream::network_metrics::tracker_t first {start}; + stream::network_metrics::tracker_t second {start}; + auto first_payload = make_payload(10, 3, 8, 2, 2); + auto second_payload = make_payload(20, 4, 20, 0, 0); + + first.ingest(payload_view(first_payload), true); + second.ingest(payload_view(second_payload), true); + second.record_idr_request(); + + auto first_snapshot = first.poll(start + 500ms, 5, 1); + auto second_snapshot = second.poll(start + 500ms, 15, 4); + + ASSERT_TRUE(first_snapshot); + ASSERT_TRUE(second_snapshot); + EXPECT_EQ(first_snapshot->received_data_packets, 8u); + EXPECT_EQ(first_snapshot->fec_recovered_data_packets, 2u); + EXPECT_EQ(first_snapshot->idr_requests, 0u); + EXPECT_EQ(first_snapshot->rtt_ms, 5u); + EXPECT_EQ(second_snapshot->received_data_packets, 20u); + EXPECT_EQ(second_snapshot->fec_recovered_data_packets, 0u); + EXPECT_EQ(second_snapshot->idr_requests, 1u); + EXPECT_EQ(second_snapshot->rtt_ms, 15u); +} + +TEST(NetworkMetricsTrackerTests, RateLimitsOneWindow) { + const auto start = stream::network_metrics::time_point_t {}; + stream::network_metrics::tracker_t tracker {start}; + auto payload = make_payload(10, 3, 8, 2, 2); + + for (std::uint32_t report = 0; report < stream::network_metrics::max_reports_per_window; ++report) { + EXPECT_EQ(tracker.ingest(payload_view(payload), true), stream::network_metrics::ingest_result_e::accepted); + } + for (std::uint32_t report = 0; report < 3; ++report) { + EXPECT_EQ(tracker.ingest(payload_view(payload), true), stream::network_metrics::ingest_result_e::rate_limited); + } + + auto snapshot = tracker.poll(start + 500ms, 8, 2); + ASSERT_TRUE(snapshot); + EXPECT_EQ(snapshot->fec_reports, stream::network_metrics::max_reports_per_window); + EXPECT_EQ(snapshot->rate_limited_reports, 3u); +} + +TEST(NetworkMetricsTrackerTests, DoesNotPublishEmptyWindows) { + const auto start = stream::network_metrics::time_point_t {}; + stream::network_metrics::tracker_t tracker {start}; + + EXPECT_FALSE(tracker.poll(start + 500ms, 8, 2)); + tracker.record_idr_request(); + auto snapshot = tracker.poll(start + 1000ms, 8, 2); + + ASSERT_TRUE(snapshot); + EXPECT_EQ(snapshot->sequence, 1u); + EXPECT_EQ(snapshot->fec_reports, 0u); + EXPECT_EQ(snapshot->idr_requests, 1u); +} From aecf485adcf67a6da6a1885f29d691086d51e21f Mon Sep 17 00:00:00 2001 From: leandredesmaretz Date: Sat, 18 Jul 2026 01:48:55 +0200 Subject: [PATCH 2/8] feat(win/nvenc): support runtime bitrate reconfiguration Signed-off-by: leandredesmaretz --- cmake/compile_definitions/common.cmake | 2 + src/globals.h | 1 + src/nvenc/nvenc_base.cpp | 31 ++++ src/nvenc/nvenc_base.h | 10 ++ src/nvenc/nvenc_encoder.h | 9 + src/nvenc/nvenc_reconfigure.cpp | 120 +++++++++++++ src/nvenc/nvenc_reconfigure.h | 66 +++++++ src/stream.cpp | 23 +++ src/stream.h | 26 +++ src/video.cpp | 70 +++++++- src/video.h | 69 ++++++++ tests/CMakeLists.txt | 2 + tests/unit/test_nvenc_reconfigure.cpp | 232 +++++++++++++++++++++++++ tests/unit/test_stream.cpp | 40 +++++ tests/unit/test_video.cpp | 106 +++++++++++ 15 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 src/nvenc/nvenc_reconfigure.cpp create mode 100644 src/nvenc/nvenc_reconfigure.h create mode 100644 tests/unit/test_nvenc_reconfigure.cpp diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index 8fec485c34a..ad3da9372fc 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -79,6 +79,8 @@ if(WIN32) "${CMAKE_SOURCE_DIR}/src/nvenc/nvenc_d3d11_native.cpp" "${CMAKE_SOURCE_DIR}/src/nvenc/nvenc_d3d11_on_cuda.cpp" "${CMAKE_SOURCE_DIR}/src/nvenc/nvenc_dynamic_factory_impl.cpp" + "${CMAKE_SOURCE_DIR}/src/nvenc/nvenc_reconfigure.cpp" + "${CMAKE_SOURCE_DIR}/src/nvenc/nvenc_reconfigure.h" "${CMAKE_SOURCE_DIR}/src/nvenc/nvenc_utils.cpp" ) diff --git a/src/globals.h b/src/globals.h index 2c7ee9b121b..95926ab3b84 100644 --- a/src/globals.h +++ b/src/globals.h @@ -57,6 +57,7 @@ namespace mail { MAIL(touch_port); ///< Touch port. MAIL(idr); ///< IDR. MAIL(invalidate_ref_frames); ///< Invalidate ref frames. + MAIL(video_bitrate); ///< Latest runtime video bitrate request. MAIL(gamepad_feedback); ///< Gamepad feedback. MAIL(hdr); ///< HDR. #undef MAIL diff --git a/src/nvenc/nvenc_base.cpp b/src/nvenc/nvenc_base.cpp index f6f5b6fe34d..7513a224b6d 100644 --- a/src/nvenc/nvenc_base.cpp +++ b/src/nvenc/nvenc_base.cpp @@ -633,6 +633,8 @@ namespace NVENC_NAMESPACE { if (!validate_encoder_capabilities(init_params.encodeGUID, buffer_format)) { return false; } + const bool supports_dynamic_bitrate = + get_encoder_cap(init_params.encodeGUID, NV_ENC_CAPS_SUPPORT_DYN_BITRATE_CHANGE); init_params.presetGUID = quality_preset_guid_from_number(config.quality_preset); init_params.tuningInfo = NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY; @@ -666,6 +668,7 @@ namespace NVENC_NAMESPACE { if (!initialize_encoder_resources(init_params)) { return false; } + bitrate_reconfigure_state_.initialize(supports_dynamic_bitrate, init_params, enc_config); auto frame_size_format = stat_trackers::two_digits_after_decimal(); BOOST_LOG(debug) << "NvEnc: requested encoded frame size " @@ -678,6 +681,7 @@ namespace NVENC_NAMESPACE { } void nvenc_base::destroy_encoder() { + bitrate_reconfigure_state_.reset(); if (output_bitstream) { if (nvenc_failed(nvenc->nvEncDestroyBitstreamBuffer(encoder, output_bitstream))) { BOOST_LOG(error) << "NvEnc: NvEncDestroyBitstreamBuffer() failed: " << last_nvenc_error_string; @@ -708,6 +712,33 @@ namespace NVENC_NAMESPACE { encoder_params = {}; } + video::bitrate_reconfigure_result_t nvenc_base::reconfigure_bitrate(std::uint32_t target_kbps) { + auto result = bitrate_reconfigure_state_.reconfigure( + target_kbps, + [this](NV_ENC_RECONFIGURE_PARAMS *params) { + NVENCSTATUS status; + if (!encoder || !nvenc || !nvenc->nvEncReconfigureEncoder) { + status = NV_ENC_ERR_ENCODER_NOT_INITIALIZED; + } else { + status = nvenc->nvEncReconfigureEncoder(encoder, params); + } + nvenc_failed(status); + return status; + } + ); + + if (result.status == video::bitrate_reconfigure_status_e::failed) { + BOOST_LOG(warning) + << "NvEnc: bitrate reconfiguration failed" + << " old_target_kbps=" << result.old_target_kbps + << " requested_target_kbps=" << result.requested_target_kbps + << " effective_target_kbps=" << result.effective_target_kbps + << " error=" << last_nvenc_error_string; + } + + return result; + } + ::nvenc::nvenc_encoded_frame nvenc_base::encode_frame(uint64_t frame_index, bool force_idr) { if (!encoder) { return {}; diff --git a/src/nvenc/nvenc_base.h b/src/nvenc/nvenc_base.h index 8f9c8b01be3..1f60fff938b 100644 --- a/src/nvenc/nvenc_base.h +++ b/src/nvenc/nvenc_base.h @@ -10,6 +10,7 @@ #include "nvenc_encoded_frame.h" #include "nvenc_encoder.h" #include "nvenc_sdk.h" +#include "nvenc_reconfigure.h" #include "src/logging.h" #include "src/video.h" #include "src/video_colorspace.h" @@ -75,6 +76,14 @@ namespace NVENC_NAMESPACE { */ bool invalidate_ref_frames(uint64_t first_frame, uint64_t last_frame) override; + /** + * @brief Reconfigure bitrate on the active NVENC session without recreating it. + * + * @param target_kbps Requested bitrate in kilobits per second. + * @return Detailed result of the driver-backed bitrate update. + */ + video::bitrate_reconfigure_result_t reconfigure_bitrate(std::uint32_t target_kbps) override; + protected: /** * @brief Required. Used for loading NvEnc library and setting `nvenc` variable with `NvEncodeAPICreateInstance()`. @@ -329,6 +338,7 @@ namespace NVENC_NAMESPACE { ) const; NV_ENC_OUTPUT_PTR output_bitstream = nullptr; + bitrate_reconfigure_state_t bitrate_reconfigure_state_; ///< Cached last-success state for atomic bitrate updates. struct { uint64_t last_encoded_frame_index = 0; diff --git a/src/nvenc/nvenc_encoder.h b/src/nvenc/nvenc_encoder.h index 4c75058308c..63fc4bef60d 100644 --- a/src/nvenc/nvenc_encoder.h +++ b/src/nvenc/nvenc_encoder.h @@ -16,6 +16,7 @@ namespace platf { } namespace video { + struct bitrate_reconfigure_result_t; struct config_t; struct sunshine_colorspace_t; } // namespace video @@ -70,6 +71,14 @@ namespace nvenc { * @return `true` on success, `false` on error. */ virtual bool invalidate_ref_frames(std::uint64_t first_frame, std::uint64_t last_frame) = 0; + + /** + * @brief Reconfigure bitrate on the active encoder session. + * + * @param target_kbps Requested bitrate in kilobits per second. + * @return Detailed result of the runtime bitrate update. + */ + virtual video::bitrate_reconfigure_result_t reconfigure_bitrate(std::uint32_t target_kbps) = 0; }; } // namespace nvenc diff --git a/src/nvenc/nvenc_reconfigure.cpp b/src/nvenc/nvenc_reconfigure.cpp new file mode 100644 index 00000000000..74953039eb5 --- /dev/null +++ b/src/nvenc/nvenc_reconfigure.cpp @@ -0,0 +1,120 @@ +/** + * @file src/nvenc/nvenc_reconfigure.cpp + * @brief Definitions for atomic NVENC bitrate reconfiguration state. + */ + +// standard includes +#include +#include + +// local includes +#include "nvenc_reconfigure.h" + +namespace NVENC_NAMESPACE { + + void bitrate_reconfigure_state_t::initialize( + bool supported, + const NV_ENC_INITIALIZE_PARAMS &init_params, + const NV_ENC_CONFIG &encode_config + ) { + supported_ = supported; + init_params_ = init_params; + encode_config_ = encode_config; + init_params_.encodeConfig = &encode_config_; + baseline_average_bitrate_ = encode_config.rcParams.averageBitRate; + baseline_vbv_buffer_size_ = encode_config.rcParams.vbvBufferSize; + baseline_vbv_initial_delay_ = encode_config.rcParams.vbvInitialDelay; + } + + void bitrate_reconfigure_state_t::reset() { + supported_ = false; + init_params_ = {}; + encode_config_ = {}; + baseline_average_bitrate_ = 0; + baseline_vbv_buffer_size_ = 0; + baseline_vbv_initial_delay_ = 0; + } + + video::bitrate_reconfigure_result_t bitrate_reconfigure_state_t::reconfigure( + std::uint32_t target_kbps, + const apply_function_t &apply + ) { + const auto old_target_kbps = encode_config_.rcParams.averageBitRate / 1000; + auto result = video::bitrate_reconfigure_result_t { + video::bitrate_reconfigure_status_e::unsupported, + old_target_kbps, + target_kbps, + old_target_kbps, + }; + + if (!supported_) { + return result; + } + + if (target_kbps == 0 || target_kbps > std::numeric_limits::max() / 1000) { + result.status = video::bitrate_reconfigure_status_e::invalid; + return result; + } + + const auto target_bps = target_kbps * 1000; + if (target_bps == encode_config_.rcParams.averageBitRate) { + result.status = video::bitrate_reconfigure_status_e::unchanged; + return result; + } + + if (baseline_average_bitrate_ == 0 || !apply) { + result.status = video::bitrate_reconfigure_status_e::failed; + return result; + } + + auto scaled_from_baseline = [&](std::uint32_t baseline_value) -> std::optional { + const auto scaled = static_cast(baseline_value) * target_bps / baseline_average_bitrate_; + if (scaled > std::numeric_limits::max()) { + return std::nullopt; + } + return static_cast(scaled); + }; + + auto next_config = encode_config_; + next_config.rcParams.averageBitRate = target_bps; + next_config.rcParams.maxBitRate = target_bps; + if (baseline_vbv_buffer_size_ != 0) { + auto scaled_vbv = scaled_from_baseline(baseline_vbv_buffer_size_); + if (!scaled_vbv) { + result.status = video::bitrate_reconfigure_status_e::invalid; + return result; + } + next_config.rcParams.vbvBufferSize = *scaled_vbv; + } + if (baseline_vbv_initial_delay_ != 0) { + auto scaled_delay = scaled_from_baseline(baseline_vbv_initial_delay_); + if (!scaled_delay) { + result.status = video::bitrate_reconfigure_status_e::invalid; + return result; + } + next_config.rcParams.vbvInitialDelay = *scaled_delay; + } + + auto next_init_params = init_params_; + next_init_params.encodeConfig = &next_config; + + NV_ENC_RECONFIGURE_PARAMS reconfigure_params = {NV_ENC_RECONFIGURE_PARAMS_VER}; + reconfigure_params.reInitEncodeParams = next_init_params; + reconfigure_params.resetEncoder = 0; + reconfigure_params.forceIDR = 0; + + if (apply(&reconfigure_params) != NV_ENC_SUCCESS) { + result.status = video::bitrate_reconfigure_status_e::failed; + return result; + } + + encode_config_ = next_config; + init_params_ = next_init_params; + init_params_.encodeConfig = &encode_config_; + + result.status = video::bitrate_reconfigure_status_e::applied; + result.effective_target_kbps = target_kbps; + return result; + } + +} // namespace NVENC_NAMESPACE diff --git a/src/nvenc/nvenc_reconfigure.h b/src/nvenc/nvenc_reconfigure.h new file mode 100644 index 00000000000..d062deeb784 --- /dev/null +++ b/src/nvenc/nvenc_reconfigure.h @@ -0,0 +1,66 @@ +/** + * @file src/nvenc/nvenc_reconfigure.h + * @brief Declarations for atomic NVENC bitrate reconfiguration state. + */ +#pragma once + +// standard includes +#include +#include + +// local includes +#include "nvenc_sdk.h" +#include "src/video.h" + +namespace NVENC_NAMESPACE { + + /** + * @brief Own the last successful NVENC configuration used for bitrate-only updates. + */ + class bitrate_reconfigure_state_t { + public: + /** + * @brief Callback that applies one prepared NVENC reconfiguration request. + */ + using apply_function_t = std::function; + + /** + * @brief Initialize state from the configuration accepted by NVENC. + * + * @param supported Whether the driver advertises dynamic bitrate support. + * @param init_params Successful encoder initialization parameters. + * @param encode_config Successful encoder configuration. + */ + void initialize( + bool supported, + const NV_ENC_INITIALIZE_PARAMS &init_params, + const NV_ENC_CONFIG &encode_config + ); + + /** + * @brief Reset all reconfiguration capability and cached encoder state. + */ + void reset(); + + /** + * @brief Apply a bitrate-only update while preserving every other encoder field. + * + * @param target_kbps Requested bitrate in kilobits per second. + * @param apply Function that invokes the driver reconfiguration API. + * @return Detailed result including the effective target after the request. + */ + video::bitrate_reconfigure_result_t reconfigure( + std::uint32_t target_kbps, + const apply_function_t &apply + ); + + private: + bool supported_ = false; ///< Whether the active driver supports dynamic bitrate changes. + NV_ENC_INITIALIZE_PARAMS init_params_ {}; ///< Last successful encoder initialization parameters. + NV_ENC_CONFIG encode_config_ {}; ///< Last successful encoder configuration. + std::uint32_t baseline_average_bitrate_ = 0; ///< Initial average bitrate used as the scaling denominator. + std::uint32_t baseline_vbv_buffer_size_ = 0; ///< Initial VBV size used to avoid cumulative rounding drift. + std::uint32_t baseline_vbv_initial_delay_ = 0; ///< Initial VBV delay used to avoid cumulative rounding drift. + }; + +} // namespace NVENC_NAMESPACE diff --git a/src/stream.cpp b/src/stream.cpp index 0710f85527a..4ee00cbb2ad 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -503,6 +503,7 @@ namespace stream { safe::mail_raw_t::event_t idr_events; safe::mail_raw_t::event_t> invalidate_ref_frames_events; + safe::mail_raw_t::event_t bitrate_events; std::unique_ptr qos; } video; ///< Video worker thread state for the active stream. @@ -2217,6 +2218,27 @@ namespace stream { return *session.network_metrics.latest_snapshot; } + /** + * @brief Request a runtime video bitrate change for this session. + */ + void request_video_bitrate(session_t &session, std::uint32_t target_kbps, std::string diagnostic_reason) { + session.video.bitrate_events->raise(video::bitrate_reconfigure_request_t { + target_kbps, + std::move(diagnostic_reason), + }); + } + +#ifdef SUNSHINE_TESTS + namespace testing { + /** + * @brief Return the runtime video bitrate event attached to a test session. + */ + safe::mail_raw_t::event_t video_bitrate_requests(session_t &session) { + return session.video.bitrate_events; + } + } // namespace testing +#endif + /** * @brief Stop the active streaming session and prevent new packets from being queued. */ @@ -2351,6 +2373,7 @@ namespace stream { session->video.idr_events = mail->event(mail::idr); session->video.invalidate_ref_frames_events = mail->event>(mail::invalidate_ref_frames); + session->video.bitrate_events = mail->event(mail::video_bitrate); session->video.lowseq = 0; session->video.ping_payload = launch_session.av_ping_payload; if (config.encryptionFlagsEnabled & SS_ENC_VIDEO) { diff --git a/src/stream.h b/src/stream.h index c9ffcaba980..7a5f4e3900d 100644 --- a/src/stream.h +++ b/src/stream.h @@ -5,7 +5,9 @@ #pragma once // standard includes +#include #include +#include #include // lib includes @@ -104,5 +106,29 @@ namespace stream { * @return Stable per-session snapshot, or no value before the first active window completes. */ std::optional network_metrics_snapshot(session_t &session); + + /** + * @brief Request a runtime video bitrate change for an active stream session. + * + * The request is delivered asynchronously to the encoder-owning thread. When several + * requests are pending, only the latest request is retained. + * + * @param session Active streaming session that owns the video encoder. + * @param target_kbps Requested encoder target in kilobits per second. + * @param diagnostic_reason Stable diagnostic reason recorded with the request. + */ + void request_video_bitrate(session_t &session, std::uint32_t target_kbps, std::string diagnostic_reason); + +#ifdef SUNSHINE_TESTS + namespace testing { + /** + * @brief Return the runtime video bitrate event attached to a test session. + * + * @param session Streaming session allocated by the test. + * @return Latest-wins bitrate request event used by the encoder thread. + */ + safe::mail_raw_t::event_t video_bitrate_requests(session_t &session); + } // namespace testing +#endif } // namespace session } // namespace stream diff --git a/src/video.cpp b/src/video.cpp index 5306f37d706..07b2390769f 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -45,6 +45,51 @@ using namespace std::literals; namespace video { + std::string_view bitrate_reconfigure_status_name(bitrate_reconfigure_status_e status) { + switch (status) { + case bitrate_reconfigure_status_e::applied: + return "applied"; + case bitrate_reconfigure_status_e::unchanged: + return "unchanged"; + case bitrate_reconfigure_status_e::invalid: + return "invalid"; + case bitrate_reconfigure_status_e::unsupported: + return "unsupported"; + case bitrate_reconfigure_status_e::failed: + return "failed"; + } + + return "unknown"; + } + + std::optional apply_pending_bitrate_reconfiguration( + const safe::mail_raw_t::event_t &bitrate_events, + encode_session_t &session, + config_t &config + ) { + auto request = bitrate_events->try_pop(); + if (!request) { + return std::nullopt; + } + + auto result = session.reconfigure_bitrate(request->target_kbps); + BOOST_LOG(info) + << "video_bitrate_reconfigure" + << " old_target_kbps=" << result.old_target_kbps + << " requested_target_kbps=" << result.requested_target_kbps + << " effective_target_kbps=" << result.effective_target_kbps + << " reason=" << request->reason + << " result=" << bitrate_reconfigure_status_name(result.status); + + if ( + result.status == bitrate_reconfigure_status_e::applied || + result.status == bitrate_reconfigure_status_e::unchanged + ) { + config.bitrate = static_cast(result.effective_target_kbps); + } + return result; + } + namespace { /** * @brief Check if we can allow probing for the encoders. @@ -549,6 +594,24 @@ namespace video { return device->convert(img); } + /** + * @brief Reconfigure bitrate on the active native NVENC encoder. + * + * @param target_kbps Requested bitrate in kilobits per second. + * @return Detailed result of the NVENC request. + */ + bitrate_reconfigure_result_t reconfigure_bitrate(std::uint32_t target_kbps) override { + if (!device || !device->nvenc) { + return { + bitrate_reconfigure_status_e::failed, + 0, + target_kbps, + 0, + }; + } + return device->nvenc->reconfigure_bitrate(target_kbps); + } + /** * @brief Mark the frame as a request for an IDR frame. */ @@ -2343,7 +2406,7 @@ namespace video { * @param frame_nr Frame counter updated as frames are encoded. * @param mail Session mail bus. * @param images Captured image event source. - * @param config Video configuration. + * @param config Mutable video configuration retained across encoder reinitialization. * @param disp Display being encoded. * @param encode_device Platform encode device. * @param reinit_event Signal raised while the encoder/display is reinitializing. @@ -2354,7 +2417,7 @@ namespace video { int &frame_nr, // Store progress of the frame number safe::mail_t mail, img_event_t images, - config_t config, + config_t &config, std::shared_ptr disp, std::unique_ptr encode_device, safe::signal_t &reinit_event, @@ -2392,6 +2455,7 @@ namespace video { auto packets = mail::man->queue(mail::video_packets); auto idr_events = mail->event(mail::idr); auto invalidate_ref_frames_events = mail->event>(mail::invalidate_ref_frames); + auto bitrate_events = mail->event(mail::video_bitrate); { // Load a dummy image into the AVFrame to ensure we have something to encode @@ -2451,6 +2515,8 @@ namespace video { break; } + apply_pending_bitrate_reconfiguration(bitrate_events, *session, config); + if (encode(frame_nr++, *session, packets, channel_data, frame_timestamp)) { BOOST_LOG(error) << "Could not encode video packet"sv; return; diff --git a/src/video.h b/src/video.h index b70ed53b3a0..0ff05d2330b 100644 --- a/src/video.h +++ b/src/video.h @@ -6,6 +6,9 @@ // standard includes #include +#include +#include +#include #include // local includes @@ -23,6 +26,43 @@ struct AVPacket; namespace video { + /** + * @brief Result status for a runtime encoder bitrate reconfiguration request. + */ + enum class bitrate_reconfigure_status_e { + applied, ///< The encoder accepted and applied the requested bitrate. + unchanged, ///< The requested bitrate already matches the active bitrate. + invalid, ///< The requested bitrate is outside the backend's numeric domain. + unsupported, ///< The active encoder backend cannot reconfigure bitrate at runtime. + failed, ///< The backend attempted the change but the encoder rejected it. + }; + + /** + * @brief Describe the result of a runtime bitrate reconfiguration request. + */ + struct bitrate_reconfigure_result_t { + bitrate_reconfigure_status_e status; ///< Final status of the request. + std::uint32_t old_target_kbps; ///< Effective target before the request, or zero when unknown. + std::uint32_t requested_target_kbps; ///< Target requested by the caller. + std::uint32_t effective_target_kbps; ///< Effective target after the request, or zero when unknown. + }; + + /** + * @brief Runtime bitrate request transported to the encoder thread. + */ + struct bitrate_reconfigure_request_t { + std::uint32_t target_kbps; ///< Requested encoder target in kilobits per second. + std::string reason; ///< Stable diagnostic reason for the request. + }; + + /** + * @brief Convert a bitrate reconfiguration status to a stable diagnostic name. + * + * @param status Status to convert. + * @return Stable lower-case status name. + */ + std::string_view bitrate_reconfigure_status_name(bitrate_reconfigure_status_e status); + /** * @brief Encoding configuration requested by a remote client. */ @@ -356,6 +396,21 @@ namespace video { struct encode_session_t { virtual ~encode_session_t() = default; + /** + * @brief Reconfigure the encoder bitrate without replacing the active session. + * + * @param target_kbps Requested encoder target in kilobits per second. + * @return Result of the request. Backends are unsupported by default. + */ + virtual bitrate_reconfigure_result_t reconfigure_bitrate(std::uint32_t target_kbps) { + return { + bitrate_reconfigure_status_e::unsupported, + 0, + target_kbps, + 0, + }; + } + /** * @brief Convert a captured frame into the encoder's required input representation. * @@ -383,6 +438,20 @@ namespace video { virtual void invalidate_ref_frames(int64_t first_frame, int64_t last_frame) = 0; }; + /** + * @brief Apply the latest pending bitrate request and retain it across encoder reinitialization. + * + * @param bitrate_events Latest-wins bitrate request event for the stream session. + * @param session Active encoder session. + * @param config Mutable session configuration reused when the encoder is recreated. + * @return Reconfiguration result when a request was pending, otherwise no value. + */ + std::optional apply_pending_bitrate_reconfiguration( + const safe::mail_raw_t::event_t &bitrate_events, + encode_session_t &session, + config_t &config + ); + // encoders extern encoder_t software; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6e6fe5f06e5..1b18e2dda1d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -245,6 +245,8 @@ target_compile_options(${PROJECT_NAME} PRIVATE $<$:${SUNSH target_link_options(${PROJECT_NAME} PRIVATE ${SUNSHINE_LINK_OPTIONS}) if (WIN32) + target_include_directories(${PROJECT_NAME} PRIVATE "${NV_CODEC_HEADERS_13_INCLUDE_DIR}") + # prefer static libraries since we're linking statically # this fixes libcurl linking errors when using non MSYS2 version of CMake set_target_properties(${PROJECT_NAME} PROPERTIES LINK_SEARCH_START_STATIC 1) diff --git a/tests/unit/test_nvenc_reconfigure.cpp b/tests/unit/test_nvenc_reconfigure.cpp new file mode 100644 index 00000000000..4f241da13d9 --- /dev/null +++ b/tests/unit/test_nvenc_reconfigure.cpp @@ -0,0 +1,232 @@ +/** + * @file tests/unit/test_nvenc_reconfigure.cpp + * @brief Test atomic NVENC bitrate reconfiguration state. + */ +#include "../tests_common.h" + +#ifdef _WIN32 + +#include + +#define NVENC_NAMESPACE nvenc_1300 +#define NVENC_SDK_VERSION 1300 +#include + +using namespace nvenc_1300; + +namespace { + + struct nvenc_reconfigure_fixture_t { + NV_ENC_INITIALIZE_PARAMS init_params = {NV_ENC_INITIALIZE_PARAMS_VER}; + NV_ENC_CONFIG encode_config = {NV_ENC_CONFIG_VER}; + + nvenc_reconfigure_fixture_t() { + encode_config.gopLength = 777; + encode_config.rcParams.averageBitRate = 25'000'000; + encode_config.rcParams.maxBitRate = 25'000'000; + encode_config.rcParams.vbvBufferSize = 500'000; + encode_config.rcParams.vbvInitialDelay = 250'000; + init_params.encodeConfig = &encode_config; + init_params.encodeWidth = 1920; + init_params.encodeHeight = 1080; + } + }; + +} // namespace + +TEST(NvencBitrateReconfigureTest, ReportsUnsupportedWithoutCallingDriver) { + nvenc_reconfigure_fixture_t fixture; + nvenc_1300::bitrate_reconfigure_state_t state; + state.initialize(false, fixture.init_params, fixture.encode_config); + bool called = false; + + const auto result = state.reconfigure(40'000, [&](auto *) { + called = true; + return NV_ENC_SUCCESS; + }); + + EXPECT_EQ(video::bitrate_reconfigure_status_e::unsupported, result.status); + EXPECT_EQ(25'000U, result.old_target_kbps); + EXPECT_EQ(40'000U, result.requested_target_kbps); + EXPECT_EQ(25'000U, result.effective_target_kbps); + EXPECT_FALSE(called); +} + +TEST(NvencBitrateReconfigureTest, RejectsInvalidAndUnchangedTargetsWithoutCallingDriver) { + nvenc_reconfigure_fixture_t fixture; + nvenc_1300::bitrate_reconfigure_state_t state; + state.initialize(true, fixture.init_params, fixture.encode_config); + bool called = false; + auto apply = [&](auto *) { + called = true; + return NV_ENC_SUCCESS; + }; + + EXPECT_EQ(video::bitrate_reconfigure_status_e::invalid, state.reconfigure(0, apply).status); + EXPECT_EQ( + video::bitrate_reconfigure_status_e::invalid, + state.reconfigure(std::numeric_limits::max(), apply).status + ); + EXPECT_EQ(video::bitrate_reconfigure_status_e::unchanged, state.reconfigure(25'000, apply).status); + EXPECT_FALSE(called); +} + +TEST(NvencBitrateReconfigureTest, AppliesAtomicRateControlValuesWithoutResetOrIdr) { + nvenc_reconfigure_fixture_t fixture; + nvenc_1300::bitrate_reconfigure_state_t state; + state.initialize(true, fixture.init_params, fixture.encode_config); + + const auto result = state.reconfigure(40'000, [&](auto *params) -> NVENCSTATUS { + EXPECT_NE(nullptr, params); + if (!params) { + return NV_ENC_ERR_INVALID_PTR; + } + EXPECT_NE(nullptr, params->reInitEncodeParams.encodeConfig); + if (!params->reInitEncodeParams.encodeConfig) { + return NV_ENC_ERR_INVALID_PTR; + } + EXPECT_EQ(40'000'000U, params->reInitEncodeParams.encodeConfig->rcParams.averageBitRate); + EXPECT_EQ(40'000'000U, params->reInitEncodeParams.encodeConfig->rcParams.maxBitRate); + EXPECT_EQ(800'000U, params->reInitEncodeParams.encodeConfig->rcParams.vbvBufferSize); + EXPECT_EQ(400'000U, params->reInitEncodeParams.encodeConfig->rcParams.vbvInitialDelay); + EXPECT_EQ(777U, params->reInitEncodeParams.encodeConfig->gopLength); + EXPECT_EQ(1920U, params->reInitEncodeParams.encodeWidth); + EXPECT_EQ(1080U, params->reInitEncodeParams.encodeHeight); + EXPECT_EQ(0U, params->resetEncoder); + EXPECT_EQ(0U, params->forceIDR); + return NV_ENC_SUCCESS; + }); + + EXPECT_EQ(video::bitrate_reconfigure_status_e::applied, result.status); + EXPECT_EQ(25'000U, result.old_target_kbps); + EXPECT_EQ(40'000U, result.effective_target_kbps); +} + +TEST(NvencBitrateReconfigureTest, ScalesFromBaselineWithoutCumulativeDrift) { + nvenc_reconfigure_fixture_t fixture; + nvenc_1300::bitrate_reconfigure_state_t state; + state.initialize(true, fixture.init_params, fixture.encode_config); + + EXPECT_EQ( + video::bitrate_reconfigure_status_e::applied, + state.reconfigure(40'000, [](auto *) { + return NV_ENC_SUCCESS; + }) + .status + ); + + const auto result = state.reconfigure(25'000, [&](auto *params) { + EXPECT_EQ(25'000'000U, params->reInitEncodeParams.encodeConfig->rcParams.averageBitRate); + EXPECT_EQ(25'000'000U, params->reInitEncodeParams.encodeConfig->rcParams.maxBitRate); + EXPECT_EQ(500'000U, params->reInitEncodeParams.encodeConfig->rcParams.vbvBufferSize); + EXPECT_EQ(250'000U, params->reInitEncodeParams.encodeConfig->rcParams.vbvInitialDelay); + return NV_ENC_SUCCESS; + }); + + EXPECT_EQ(video::bitrate_reconfigure_status_e::applied, result.status); + EXPECT_EQ(40'000U, result.old_target_kbps); + EXPECT_EQ(25'000U, result.effective_target_kbps); +} + +TEST(NvencBitrateReconfigureTest, KeepsLastSuccessfulStateWhenDriverFails) { + nvenc_reconfigure_fixture_t fixture; + nvenc_1300::bitrate_reconfigure_state_t state; + state.initialize(true, fixture.init_params, fixture.encode_config); + + const auto failed = state.reconfigure(40'000, [](auto *) { + return NV_ENC_ERR_INVALID_PARAM; + }); + EXPECT_EQ(video::bitrate_reconfigure_status_e::failed, failed.status); + EXPECT_EQ(25'000U, failed.effective_target_kbps); + + const auto retry = state.reconfigure(35'000, [](auto *) { + return NV_ENC_SUCCESS; + }); + EXPECT_EQ(video::bitrate_reconfigure_status_e::applied, retry.status); + EXPECT_EQ(25'000U, retry.old_target_kbps); + EXPECT_EQ(35'000U, retry.effective_target_kbps); +} + +TEST(NvencBitrateReconfigureTest, RejectsMissingBaselineOrDriverCallback) { + nvenc_reconfigure_fixture_t fixture; + nvenc_1300::bitrate_reconfigure_state_t state; + fixture.encode_config.rcParams.averageBitRate = 0; + state.initialize(true, fixture.init_params, fixture.encode_config); + + EXPECT_EQ( + video::bitrate_reconfigure_status_e::failed, + state.reconfigure(40'000, [](auto *) { + return NV_ENC_SUCCESS; + }) + .status + ); + + fixture.encode_config.rcParams.averageBitRate = 25'000'000; + state.initialize(true, fixture.init_params, fixture.encode_config); + EXPECT_EQ( + video::bitrate_reconfigure_status_e::failed, + state.reconfigure(40'000, {}).status + ); + + state.reset(); + EXPECT_EQ( + video::bitrate_reconfigure_status_e::unsupported, + state.reconfigure(40'000, [](auto *) { + return NV_ENC_SUCCESS; + }) + .status + ); +} + +TEST(NvencBitrateReconfigureTest, LeavesDefaultVbvFieldsUnset) { + nvenc_reconfigure_fixture_t fixture; + nvenc_1300::bitrate_reconfigure_state_t state; + fixture.encode_config.rcParams.vbvBufferSize = 0; + fixture.encode_config.rcParams.vbvInitialDelay = 0; + state.initialize(true, fixture.init_params, fixture.encode_config); + + const auto result = state.reconfigure(40'000, [](auto *params) { + EXPECT_EQ(0U, params->reInitEncodeParams.encodeConfig->rcParams.vbvBufferSize); + EXPECT_EQ(0U, params->reInitEncodeParams.encodeConfig->rcParams.vbvInitialDelay); + return NV_ENC_SUCCESS; + }); + + EXPECT_EQ(video::bitrate_reconfigure_status_e::applied, result.status); +} + +TEST(NvencBitrateReconfigureTest, RejectsVbvScalingOverflowWithoutCallingDriver) { + nvenc_reconfigure_fixture_t fixture; + nvenc_1300::bitrate_reconfigure_state_t state; + fixture.encode_config.rcParams.averageBitRate = 1; + fixture.encode_config.rcParams.vbvBufferSize = std::numeric_limits::max(); + state.initialize(true, fixture.init_params, fixture.encode_config); + bool called = false; + + const auto result = state.reconfigure(4'294'967, [&](auto *) { + called = true; + return NV_ENC_SUCCESS; + }); + + EXPECT_EQ(video::bitrate_reconfigure_status_e::invalid, result.status); + EXPECT_FALSE(called); +} + +TEST(NvencBitrateReconfigureTest, RejectsVbvDelayScalingOverflowWithoutCallingDriver) { + nvenc_reconfigure_fixture_t fixture; + nvenc_1300::bitrate_reconfigure_state_t state; + fixture.encode_config.rcParams.averageBitRate = 1; + fixture.encode_config.rcParams.vbvBufferSize = 0; + fixture.encode_config.rcParams.vbvInitialDelay = std::numeric_limits::max(); + state.initialize(true, fixture.init_params, fixture.encode_config); + bool called = false; + + const auto result = state.reconfigure(4'294'967, [&](auto *) { + called = true; + return NV_ENC_SUCCESS; + }); + + EXPECT_EQ(video::bitrate_reconfigure_status_e::invalid, result.status); + EXPECT_FALSE(called); +} + +#endif // _WIN32 diff --git a/tests/unit/test_stream.cpp b/tests/unit/test_stream.cpp index fdc444cf023..16ab90de045 100644 --- a/tests/unit/test_stream.cpp +++ b/tests/unit/test_stream.cpp @@ -5,7 +5,10 @@ #include #include +#include +#include #include +#include #include namespace stream { @@ -37,3 +40,40 @@ TEST(ConcatAndInsertTests, ConcatSmallStrideTest) { auto expected = std::vector {0, 'a', 0, 'b', 0, 'c', 0, 'd', 0, 'e'}; ASSERT_EQ(res, expected); } + +TEST(StreamSessionBitrateRequestTests, PublishesRequestAcrossThreads) { + stream::config_t config {}; + rtsp_stream::launch_session_t launch_session {}; + launch_session.gcm_key.resize(16); + launch_session.iv.resize(16); + auto session = stream::session::alloc(config, launch_session); + auto requests = stream::session::testing::video_bitrate_requests(*session); + + std::jthread producer {[&session] { + stream::session::request_video_bitrate(*session, 15'000, "network-window"); + }}; + producer.join(); + + const auto request = requests->try_pop(); + ASSERT_TRUE(request); + EXPECT_EQ(15'000U, request->target_kbps); + EXPECT_EQ("network-window", request->reason); +} + +TEST(StreamSessionBitrateRequestTests, RetainsOnlyLatestPendingRequest) { + stream::config_t config {}; + rtsp_stream::launch_session_t launch_session {}; + launch_session.gcm_key.resize(16); + launch_session.iv.resize(16); + auto session = stream::session::alloc(config, launch_session); + auto requests = stream::session::testing::video_bitrate_requests(*session); + + stream::session::request_video_bitrate(*session, 25'000, "initial"); + stream::session::request_video_bitrate(*session, 40'000, "latest"); + + const auto request = requests->try_pop(); + ASSERT_TRUE(request); + EXPECT_EQ(40'000U, request->target_kbps); + EXPECT_EQ("latest", request->reason); + EXPECT_FALSE(requests->try_pop()); +} diff --git a/tests/unit/test_video.cpp b/tests/unit/test_video.cpp index 6ad2d3a83a0..a9a4c47d86b 100644 --- a/tests/unit/test_video.cpp +++ b/tests/unit/test_video.cpp @@ -16,6 +16,112 @@ using namespace std::literals; +namespace { + + class bitrate_test_session_t: public video::encode_session_t { + public: + explicit bitrate_test_session_t(std::uint32_t initial_target_kbps = 0): + last_target_kbps {initial_target_kbps} { + } + + int convert(platf::img_t &) override { + return 0; + } + + void request_idr_frame() override { + } + + void request_normal_frame() override { + } + + void invalidate_ref_frames(int64_t, int64_t) override { + } + + video::bitrate_reconfigure_result_t reconfigure_bitrate(std::uint32_t target_kbps) override { + last_target_kbps = target_kbps; + return { + video::bitrate_reconfigure_status_e::applied, + 25'000, + target_kbps, + target_kbps, + }; + } + + std::uint32_t last_target_kbps = 0; + }; + + class unsupported_bitrate_test_session_t: public video::encode_session_t { + public: + int convert(platf::img_t &) override { + return 0; + } + + void request_idr_frame() override { + } + + void request_normal_frame() override { + } + + void invalidate_ref_frames(int64_t, int64_t) override { + } + }; + +} // namespace + +TEST(VideoBitrateReconfigureTest, StatusNamesAreStable) { + EXPECT_EQ("applied", video::bitrate_reconfigure_status_name(video::bitrate_reconfigure_status_e::applied)); + EXPECT_EQ("unchanged", video::bitrate_reconfigure_status_name(video::bitrate_reconfigure_status_e::unchanged)); + EXPECT_EQ("invalid", video::bitrate_reconfigure_status_name(video::bitrate_reconfigure_status_e::invalid)); + EXPECT_EQ("unsupported", video::bitrate_reconfigure_status_name(video::bitrate_reconfigure_status_e::unsupported)); + EXPECT_EQ("failed", video::bitrate_reconfigure_status_name(video::bitrate_reconfigure_status_e::failed)); + EXPECT_EQ("unknown", video::bitrate_reconfigure_status_name(static_cast(99))); +} + +TEST(VideoBitrateReconfigureTest, LatestPendingRequestWins) { + auto mail = std::make_shared(); + auto bitrate_events = mail->event("test-video-bitrate"); + bitrate_test_session_t session; + video::config_t config {}; + config.bitrate = 25'000; + + EXPECT_FALSE(video::apply_pending_bitrate_reconfiguration(bitrate_events, session, config).has_value()); + bitrate_events->raise(video::bitrate_reconfigure_request_t {25'000, "first"}); + bitrate_events->raise(video::bitrate_reconfigure_request_t {40'000, "latest"}); + + const auto result = video::apply_pending_bitrate_reconfiguration(bitrate_events, session, config); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(video::bitrate_reconfigure_status_e::applied, result->status); + EXPECT_EQ(40'000U, session.last_target_kbps); + EXPECT_EQ(40'000, config.bitrate); + EXPECT_FALSE(video::apply_pending_bitrate_reconfiguration(bitrate_events, session, config).has_value()); +} + +TEST(VideoBitrateReconfigureTest, SuccessfulRequestPersistsAcrossEncoderReinitialization) { + auto mail = std::make_shared(); + auto bitrate_events = mail->event("test-video-bitrate-reinit"); + bitrate_test_session_t initial_session; + video::config_t config {}; + config.bitrate = 25'000; + + bitrate_events->raise(video::bitrate_reconfigure_request_t {40'000, "network-window"}); + const auto result = video::apply_pending_bitrate_reconfiguration(bitrate_events, initial_session, config); + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(video::bitrate_reconfigure_status_e::applied, result->status); + + bitrate_test_session_t reinitialized_session {static_cast(config.bitrate)}; + EXPECT_EQ(40'000U, reinitialized_session.last_target_kbps); +} + +TEST(VideoBitrateReconfigureTest, UnsupportedBackendKeepsFixedBehavior) { + unsupported_bitrate_test_session_t session; + const auto result = session.reconfigure_bitrate(40'000); + + EXPECT_EQ(video::bitrate_reconfigure_status_e::unsupported, result.status); + EXPECT_EQ(0U, result.old_target_kbps); + EXPECT_EQ(40'000U, result.requested_target_kbps); + EXPECT_EQ(0U, result.effective_target_kbps); +} + struct EncoderTest: PlatformTestSuite, testing::WithParamInterface { void SetUp() override { BaseTest::SetUp(); From 2154afff8110fb56349a4ffb2208311821540b7f Mon Sep 17 00:00:00 2001 From: leandredesmaretz Date: Sat, 18 Jul 2026 02:41:26 +0200 Subject: [PATCH 3/8] feat(stream): add adaptive NVENC bitrate control Signed-off-by: leandredesmaretz --- cmake/compile_definitions/common.cmake | 2 + docs/configuration.md | 26 + src/adaptive_bitrate.cpp | 345 ++++++++++++ src/adaptive_bitrate.h | 202 +++++++ src/config.cpp | 2 + src/config.h | 1 + src/globals.h | 1 + src/stream.cpp | 127 +++++ src/stream.h | 8 + src/video.cpp | 5 +- src_assets/common/assets/web/config.html | 1 + .../tabs/audiovideo/DisplayModesSettings.vue | 9 + .../assets/web/public/assets/locale/en.json | 2 + tests/unit/test_adaptive_bitrate.cpp | 498 ++++++++++++++++++ tests/unit/test_stream.cpp | 17 + 15 files changed, 1245 insertions(+), 1 deletion(-) create mode 100644 src/adaptive_bitrate.cpp create mode 100644 src/adaptive_bitrate.h create mode 100644 tests/unit/test_adaptive_bitrate.cpp diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index ad3da9372fc..5da44de37b4 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -157,6 +157,8 @@ set(SUNSHINE_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/input.h" "${CMAKE_SOURCE_DIR}/src/audio.cpp" "${CMAKE_SOURCE_DIR}/src/audio.h" + "${CMAKE_SOURCE_DIR}/src/adaptive_bitrate.cpp" + "${CMAKE_SOURCE_DIR}/src/adaptive_bitrate.h" "${CMAKE_SOURCE_DIR}/src/platform/common.h" "${CMAKE_SOURCE_DIR}/src/process.cpp" "${CMAKE_SOURCE_DIR}/src/process.h" diff --git a/docs/configuration.md b/docs/configuration.md index ed503973a4b..a408e0f05b3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1397,6 +1397,32 @@ editing the `conf` file in a text editor. Use the examples as reference. +### adaptive_bitrate + + + + + + + + + + + + + + +
Description + Allow supported Moonlight sessions and encoder backends to reduce the video bitrate during sustained + network loss and recover it gradually after the connection stabilizes. The controller never exceeds the + bitrate requested by Moonlight or the configured `max_bitrate` ceiling. Unsupported clients and encoders + continue to use the fixed requested bitrate. +
Default@code{} + disabled + @endcode
Example@code{} + adaptive_bitrate = enabled + @endcode
+ ### max_bitrate diff --git a/src/adaptive_bitrate.cpp b/src/adaptive_bitrate.cpp new file mode 100644 index 00000000000..61f683b98e6 --- /dev/null +++ b/src/adaptive_bitrate.cpp @@ -0,0 +1,345 @@ +/** + * @file src/adaptive_bitrate.cpp + * @brief Definitions for the per-session adaptive bitrate controller. + */ + +// standard includes +#include +#include +#include + +// local includes +#include "adaptive_bitrate.h" + +namespace stream::adaptive_bitrate { + namespace { + using namespace std::chrono_literals; + + /** Minimum spacing between any two encoder commands. */ + constexpr auto decision_interval = 1s; + + /** Maximum span in which two degraded telemetry windows confirm pressure. */ + constexpr auto bad_window_confirmation_span = 1500ms; + + /** Stable time required before the first recovery increase. */ + constexpr auto stable_recovery_delay = 10s; + + /** Minimum spacing between recovery increases. */ + constexpr auto recovery_increase_interval = 2s; + + /** Cooldown after an oscillation before another upward reversal is allowed. */ + constexpr auto upward_reversal_cooldown = 30s; + + /** Unrecovered data ratio that marks a severe window. */ + constexpr long double unrecovered_loss_threshold = 0.01L; + + /** FEC recovery ratio that marks a pressure window when latency agrees. */ + constexpr long double fec_pressure_threshold = 0.05L; + + /** FEC recovery ratio below which a window may be considered stable. */ + constexpr long double stable_fec_threshold = 0.01L; + + /** + * @brief Compute a packet ratio without overflowing aggregate counters. + * + * @param numerator Packet count of interest. + * @param snapshot Telemetry window providing the observed data counts. + * @return Ratio over received, FEC-recovered, and unrecovered data shards. + */ + long double data_ratio(const std::uint64_t numerator, const network_metrics::snapshot_t &snapshot) { + const auto denominator = static_cast(snapshot.received_data_packets) + + static_cast(snapshot.fec_recovered_data_packets) + + static_cast(snapshot.unrecovered_data_packets); + return denominator == 0.0L ? 0.0L : static_cast(numerator) / denominator; + } + + /** + * @brief Determine whether latency corroborates packet-recovery pressure. + * + * RTT must be both 50% and 15 ms above the stable-path floor. Variance is + * high at 10 ms or half the baseline, whichever is larger. + * + * @param rtt_ms Current smoothed RTT. + * @param rtt_variance_ms Current RTT variance. + * @param baseline_rtt_ms Stable-path RTT floor. + * @return `true` when RTT inflation or variance is materially elevated. + */ + bool latency_is_high( + const std::uint32_t rtt_ms, + const std::uint32_t rtt_variance_ms, + const std::uint32_t baseline_rtt_ms + ) { + if (baseline_rtt_ms == 0) { + return false; + } + + const auto inflated_rtt = rtt_ms != 0 && + static_cast(rtt_ms) >= static_cast(baseline_rtt_ms) + 15 && + static_cast(rtt_ms) * 2 >= static_cast(baseline_rtt_ms) * 3; + const auto high_variance = rtt_variance_ms >= std::max(10, baseline_rtt_ms / 2); + return inflated_rtt || high_variance; + } + + /** + * @brief Check whether an interval has fully elapsed under a monotonic clock. + * + * @tparam Duration Duration type accepted by the comparison. + * @param now Current monotonic time. + * @param since Start time. + * @param interval Required interval. + * @return `true` once the complete interval has elapsed. + */ + template + bool elapsed(const time_point_t now, const time_point_t since, const Duration interval) { + return now >= since && now - since >= interval; + } + } // namespace + + void controller_t::initialize(const settings_t settings) { + state_ = settings.enabled && settings.ceiling_kbps != 0 ? state_e::probing : state_e::fixed_disabled; + ceiling_kbps_ = settings.ceiling_kbps; + floor_kbps_ = std::min(minimum_target_kbps, ceiling_kbps_); + target_kbps_ = ceiling_kbps_; + pending_target_kbps_ = 0; + pending_reason_ = decision_reason_e::capability_probe; + baseline_rtt_ms_ = 0; + last_snapshot_sequence_ = 0; + consecutive_bad_windows_ = 0; + candidate_degradation_ = degradation_e::none; + pending_degradation_ = degradation_e::none; + telemetry_seen_ = false; + fallback_after_acknowledgement_ = false; + last_decision_direction_ = decision_direction_e::none; + first_bad_window_at_.reset(); + stable_since_.reset(); + last_decision_at_.reset(); + last_increase_at_.reset(); + last_direction_reversal_at_.reset(); + } + + void controller_t::observe(const network_metrics::snapshot_t &snapshot, const time_point_t now) { + if (state_ == state_e::fixed_disabled || state_ == state_e::fixed_fallback || + snapshot.sequence <= last_snapshot_sequence_) { + return; + } + last_snapshot_sequence_ = snapshot.sequence; + + if (snapshot.malformed_reports != 0 || snapshot.protocol_mismatch_reports != 0 || + snapshot.rate_limited_reports != 0) { + if (state_ == state_e::pending) { + fallback_after_acknowledgement_ = true; + return; + } + state_ = state_e::fixed_fallback; + pending_target_kbps_ = 0; + return; + } + + if (snapshot.fec_reports != 0) { + telemetry_seen_ = true; + } else if (!telemetry_seen_) { + return; + } + + if (baseline_rtt_ms_ == 0 && snapshot.rtt_ms != 0) { + baseline_rtt_ms_ = snapshot.rtt_ms; + } + + const auto unrecovered_ratio = data_ratio(snapshot.unrecovered_data_packets, snapshot); + const auto recovered_ratio = data_ratio(snapshot.fec_recovered_data_packets, snapshot); + const auto high_latency = latency_is_high(snapshot.rtt_ms, snapshot.rtt_variance_ms, baseline_rtt_ms_); + const auto severe_loss = unrecovered_ratio >= unrecovered_loss_threshold || + (snapshot.idr_requests != 0 && snapshot.unrecovered_data_packets != 0); + const auto fec_pressure = recovered_ratio >= fec_pressure_threshold && high_latency; + + degradation_e degradation = degradation_e::none; + if (severe_loss) { + degradation = degradation_e::unrecovered_loss; + } else if (fec_pressure) { + degradation = degradation_e::fec_pressure; + } + + if (degradation != degradation_e::none) { + stable_since_ = now; + + if (!first_bad_window_at_ || now < *first_bad_window_at_ || + now - *first_bad_window_at_ > bad_window_confirmation_span) { + first_bad_window_at_ = now; + consecutive_bad_windows_ = 1; + candidate_degradation_ = degradation; + return; + } + + ++consecutive_bad_windows_; + if (degradation == degradation_e::unrecovered_loss) { + candidate_degradation_ = degradation_e::unrecovered_loss; + } + + if (consecutive_bad_windows_ >= 2) { + if (candidate_degradation_ == degradation_e::unrecovered_loss || degradation == degradation_e::unrecovered_loss) { + pending_degradation_ = degradation_e::unrecovered_loss; + } else if (pending_degradation_ == degradation_e::none) { + pending_degradation_ = degradation_e::fec_pressure; + } + consecutive_bad_windows_ = 0; + candidate_degradation_ = degradation_e::none; + first_bad_window_at_.reset(); + } + return; + } + + consecutive_bad_windows_ = 0; + candidate_degradation_ = degradation_e::none; + first_bad_window_at_.reset(); + + const auto stable_window = snapshot.unrecovered_data_packets == 0 && + snapshot.idr_requests == 0 && + recovered_ratio < stable_fec_threshold && + !high_latency; + if (!stable_window || !stable_since_) { + stable_since_ = now; + } + + if (stable_window && snapshot.rtt_ms != 0 && + (baseline_rtt_ms_ == 0 || snapshot.rtt_ms < baseline_rtt_ms_)) { + baseline_rtt_ms_ = snapshot.rtt_ms; + } + } + + std::optional controller_t::tick( + const time_point_t now, + const std::uint32_t live_rtt_ms, + const std::uint32_t live_rtt_variance_ms + ) { + const auto emit = [&](const std::uint32_t target_kbps, const decision_reason_e reason) { + auto direction = decision_direction_e::none; + if (target_kbps < target_kbps_) { + direction = decision_direction_e::decrease; + } else if (target_kbps > target_kbps_) { + direction = decision_direction_e::increase; + } + + if (direction != decision_direction_e::none) { + if (last_decision_direction_ != decision_direction_e::none && + direction != last_decision_direction_) { + last_direction_reversal_at_ = now; + } + last_decision_direction_ = direction; + } + + pending_target_kbps_ = target_kbps; + pending_reason_ = reason; + last_decision_at_ = now; + state_ = state_e::pending; + return decision_t {target_kbps, reason}; + }; + + if (state_ == state_e::fixed_disabled || state_ == state_e::fixed_fallback) { + return std::nullopt; + } + + if (state_ == state_e::probing) { + return emit(ceiling_kbps_, decision_reason_e::capability_probe); + } + + if (state_ == state_e::pending) { + return std::nullopt; + } + + if (baseline_rtt_ms_ == 0 && live_rtt_ms != 0) { + baseline_rtt_ms_ = live_rtt_ms; + } + const auto live_latency_high = latency_is_high(live_rtt_ms, live_rtt_variance_ms, baseline_rtt_ms_); + if (live_latency_high) { + stable_since_ = now; + } else if (live_rtt_ms != 0 && live_rtt_ms < baseline_rtt_ms_) { + baseline_rtt_ms_ = live_rtt_ms; + } + + const auto decision_allowed = !last_decision_at_ || elapsed(now, *last_decision_at_, decision_interval); + if (pending_degradation_ != degradation_e::none && decision_allowed) { + if (target_kbps_ <= floor_kbps_) { + pending_degradation_ = degradation_e::none; + } else { + const auto percentage = pending_degradation_ == degradation_e::unrecovered_loss ? 75U : 85U; + auto reduced = static_cast(static_cast(target_kbps_) * percentage / 100U); + reduced = reduced / 100U * 100U; + reduced = std::max(reduced, floor_kbps_); + const auto reason = pending_degradation_ == degradation_e::unrecovered_loss ? + decision_reason_e::unrecovered_loss : + decision_reason_e::fec_pressure; + pending_degradation_ = degradation_e::none; + if (reduced < target_kbps_) { + return emit(reduced, reason); + } + } + } + + if (!telemetry_seen_ || !stable_since_ || target_kbps_ >= ceiling_kbps_ || live_latency_high || + !elapsed(now, *stable_since_, stable_recovery_delay) || + (last_increase_at_ && !elapsed(now, *last_increase_at_, recovery_increase_interval)) || + !decision_allowed) { + return std::nullopt; + } + + const auto increase_step = std::max(500, ceiling_kbps_ / 20); + const auto room = ceiling_kbps_ - target_kbps_; + const auto increased = target_kbps_ + std::min(increase_step, room); + const auto upward_reversal_is_cooling_down = + last_decision_direction_ == decision_direction_e::decrease && + last_direction_reversal_at_ && + (now <= *last_direction_reversal_at_ || now - *last_direction_reversal_at_ <= upward_reversal_cooldown); + if (upward_reversal_is_cooling_down) { + return std::nullopt; + } + return emit(increased, decision_reason_e::stable_recovery); + } + + void controller_t::acknowledge( + const apply_status_e status, + const std::uint32_t effective_target_kbps, + const time_point_t now + ) { + if (state_ != state_e::pending) { + return; + } + + const auto successful = status == apply_status_e::applied || status == apply_status_e::unchanged; + if (effective_target_kbps != 0) { + target_kbps_ = std::clamp(effective_target_kbps, floor_kbps_, ceiling_kbps_); + } else if (successful) { + target_kbps_ = pending_target_kbps_; + } + + if (successful && !fallback_after_acknowledgement_) { + if (pending_reason_ == decision_reason_e::stable_recovery) { + last_increase_at_ = now; + } else if (pending_reason_ == decision_reason_e::unrecovered_loss || + pending_reason_ == decision_reason_e::fec_pressure) { + stable_since_ = now; + } + state_ = state_e::monitoring; + } else { + state_ = state_e::fixed_fallback; + } + + fallback_after_acknowledgement_ = false; + pending_target_kbps_ = 0; + } + + state_e controller_t::state() const { + return state_; + } + + std::uint32_t controller_t::target_kbps() const { + return target_kbps_; + } + + std::uint32_t controller_t::ceiling_kbps() const { + return ceiling_kbps_; + } + + bool controller_t::telemetry_seen() const { + return telemetry_seen_; + } +} // namespace stream::adaptive_bitrate diff --git a/src/adaptive_bitrate.h b/src/adaptive_bitrate.h new file mode 100644 index 00000000000..6631072792f --- /dev/null +++ b/src/adaptive_bitrate.h @@ -0,0 +1,202 @@ +/** + * @file src/adaptive_bitrate.h + * @brief Declarations for the per-session adaptive bitrate controller. + */ +#pragma once + +// standard includes +#include +#include +#include + +// local includes +#include "network_metrics.h" + +namespace stream::adaptive_bitrate { + /** Monotonic time point used by the controller. */ + using time_point_t = std::chrono::steady_clock::time_point; + + /** Lowest adaptive target, unless the client ceiling itself is lower. */ + inline constexpr std::uint32_t minimum_target_kbps = 3'000; + + /** + * @brief Lifecycle state of one per-session controller. + */ + enum class state_e { + fixed_disabled, ///< Adaptation is disabled and the client target is left unchanged. + probing, ///< The controller is waiting to send its no-op capability probe. + monitoring, ///< Telemetry is monitored and bitrate decisions may be emitted. + pending, ///< One encoder request is awaiting acknowledgement. + fixed_fallback, ///< Adaptation stopped and the last effective target is retained. + }; + + /** + * @brief Stable reason attached to an emitted bitrate decision. + */ + enum class decision_reason_e { + capability_probe, ///< Verify runtime support without changing the initial target. + unrecovered_loss, ///< Reduce after consecutive windows with unrecovered loss. + fec_pressure, ///< Reduce after consecutive FEC-heavy windows corroborated by latency. + stable_recovery, ///< Increase after a sustained stable recovery period. + }; + + /** + * @brief Encoder outcome understood by the controller. + * + * This deliberately mirrors, but does not depend on, the video backend result + * enum so the controller remains a small, platform-independent component. + */ + enum class apply_status_e { + applied, ///< The requested target was applied. + unchanged, ///< The backend was already using the requested target. + invalid, ///< The backend rejected the target as invalid. + unsupported, ///< Runtime bitrate changes are unsupported by the backend. + failed, ///< The backend attempted and failed to apply the target. + }; + + /** + * @brief Immutable settings copied into one stream session. + */ + struct settings_t { + bool enabled = false; ///< Opt-in switch; disabled preserves fixed bitrate behavior. + std::uint32_t ceiling_kbps = 0; ///< Effective client video bitrate ceiling in kilobits per second. + }; + + /** + * @brief One encoder command emitted by the controller. + */ + struct decision_t { + std::uint32_t target_kbps; ///< Requested encoder target in kilobits per second. + decision_reason_e reason; ///< Stable diagnostic reason for the command. + }; + + /** + * @brief Pure O(1), per-session adaptive bitrate state machine. + * + * The owner feeds every published Gate A telemetry snapshot to observe(), + * calls tick() from its control loop, and returns encoder results through + * acknowledge(). The class performs no I/O, allocation, locking, or clock + * reads of its own. + */ + class controller_t { + public: + /** + * @brief Reset the controller for a new stream session. + * + * An enabled controller starts by probing runtime encoder support at the + * unchanged client ceiling. A disabled or zero-ceiling controller stays in + * fixed mode. + * + * @param settings Per-session opt-in and effective client ceiling. + */ + void initialize(settings_t settings); + + /** + * @brief Consume one newly published Gate A telemetry window. + * + * Duplicate or out-of-order snapshot sequence numbers are ignored. + * Malformed, mismatched, or rate-limited telemetry moves an active + * controller to fixed fallback at its last effective target, after any + * in-flight encoder command is acknowledged. + * + * @param snapshot Immutable network telemetry aggregate. + * @param now Monotonic observation time. + */ + void observe(const network_metrics::snapshot_t &snapshot, time_point_t now); + + /** + * @brief Advance timers and optionally emit one encoder command. + * + * At most one command is emitted per second and at most one command may be + * outstanding until the synchronous encoder result is acknowledged. RTT by + * itself never causes a decrease, but elevated live RTT or variance prevents + * an increase. + * + * @param now Monotonic control-loop time. + * @param live_rtt_ms Current ENet smoothed round-trip time. + * @param live_rtt_variance_ms Current ENet round-trip-time variance. + * @return A command to send to the encoder, or no value. + */ + [[nodiscard]] std::optional tick( + time_point_t now, + std::uint32_t live_rtt_ms, + std::uint32_t live_rtt_variance_ms + ); + + /** + * @brief Complete the currently outstanding encoder command. + * + * Successful probe acknowledgement activates monitoring. Any invalid, + * unsupported, or failed result enters fixed fallback. Acknowledgements + * received without an outstanding command are ignored. + * + * @param status Backend result mapped to the controller enum. + * @param effective_target_kbps Backend target after the attempt, or zero when unknown. + * @param now Monotonic acknowledgement time. + */ + void acknowledge(apply_status_e status, std::uint32_t effective_target_kbps, time_point_t now); + + /** + * @brief Return the current lifecycle state. + * + * @return Current state. + */ + [[nodiscard]] state_e state() const; + + /** + * @brief Return the last known effective encoder target. + * + * @return Effective target in kilobits per second. + */ + [[nodiscard]] std::uint32_t target_kbps() const; + + /** + * @brief Return the immutable client ceiling for this session. + * + * @return Effective client ceiling in kilobits per second. + */ + [[nodiscard]] std::uint32_t ceiling_kbps() const; + + /** + * @brief Return whether at least one valid FEC report was observed. + * + * @return `true` after valid event-driven telemetry has been seen. + */ + [[nodiscard]] bool telemetry_seen() const; + + private: + /** Direction of the latest non-probe encoder command. */ + enum class decision_direction_e { + none, + decrease, + increase, + }; + + /** Severity retained while confirming consecutive degraded windows. */ + enum class degradation_e { + none, + fec_pressure, + unrecovered_loss, + }; + + state_e state_ = state_e::fixed_disabled; ///< Current lifecycle state. + std::uint32_t floor_kbps_ = 0; ///< Session floor, capped by the client ceiling. + std::uint32_t ceiling_kbps_ = 0; ///< Immutable effective client ceiling. + std::uint32_t target_kbps_ = 0; ///< Last known effective target. + std::uint32_t pending_target_kbps_ = 0; ///< Target awaiting encoder acknowledgement. + decision_reason_e pending_reason_ = decision_reason_e::capability_probe; ///< Outstanding command reason. + std::uint32_t baseline_rtt_ms_ = 0; ///< Stable-path RTT floor used only as corroboration. + std::uint64_t last_snapshot_sequence_ = 0; ///< Last consumed Gate A snapshot sequence. + std::uint8_t consecutive_bad_windows_ = 0; ///< Consecutive degraded windows awaiting confirmation. + degradation_e candidate_degradation_ = degradation_e::none; ///< Strongest unconfirmed degradation. + degradation_e pending_degradation_ = degradation_e::none; ///< Confirmed degradation awaiting a decision. + bool telemetry_seen_ = false; ///< Whether a valid FEC report has been observed. + bool fallback_after_acknowledgement_ = false; ///< Defer invalid-telemetry fallback while an encoder command is in flight. + decision_direction_e last_decision_direction_ = decision_direction_e::none; ///< Latest non-probe command direction. + std::optional first_bad_window_at_; ///< Time of the first consecutive bad window. + std::optional stable_since_; ///< Start of the current no-new-pressure period. + std::optional last_decision_at_; ///< Time of the last emitted encoder command. + std::optional last_increase_at_; ///< Time of the last recovery increase. + std::optional last_direction_reversal_at_; ///< Latest non-probe command direction reversal. + }; +} // namespace stream::adaptive_bitrate diff --git a/src/config.cpp b/src/config.cpp index 5392bca2ee0..8ad5e452afc 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -787,6 +787,7 @@ namespace config { {} // wa }, // display_device + false, // adaptive_bitrate 0, // max_bitrate 0 // minimum_fps_target (0 = framerate) }; @@ -1684,6 +1685,7 @@ namespace config { video.dd.wa.hdr_toggle_delay = std::chrono::milliseconds {value}; } + bool_f(vars, "adaptive_bitrate", video.adaptive_bitrate); int_f(vars, "max_bitrate", video.max_bitrate); double_between_f(vars, "minimum_fps_target", video.minimum_fps_target, {0.0, 1000.0}); diff --git a/src/config.h b/src/config.h index 2795cf17958..60cf5507624 100644 --- a/src/config.h +++ b/src/config.h @@ -201,6 +201,7 @@ namespace config { workarounds_t wa; ///< Display-device compatibility workarounds. } dd; ///< Display-device integration settings. + bool adaptive_bitrate; ///< Allow supported Moonlight sessions to lower and recover the encoder bitrate from network telemetry. int max_bitrate; ///< Maximum bitrate ceiling in kbps for bitrate requested from the client. double minimum_fps_target; ///< Lowest framerate that will be used when streaming. Range 0-1000, 0 = half of client's requested framerate. }; diff --git a/src/globals.h b/src/globals.h index 95926ab3b84..ce889378e29 100644 --- a/src/globals.h +++ b/src/globals.h @@ -58,6 +58,7 @@ namespace mail { MAIL(idr); ///< IDR. MAIL(invalidate_ref_frames); ///< Invalidate ref frames. MAIL(video_bitrate); ///< Latest runtime video bitrate request. + MAIL(video_bitrate_result); ///< Latest runtime video bitrate result returned by the encoder thread. MAIL(gamepad_feedback); ///< Gamepad feedback. MAIL(hdr); ///< HDR. #undef MAIL diff --git a/src/stream.cpp b/src/stream.cpp index 4ee00cbb2ad..4161c25c648 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -20,6 +20,7 @@ extern "C" { } // local includes +#include "adaptive_bitrate.h" #include "config.h" #include "display_device.h" #include "globals.h" @@ -85,6 +86,50 @@ static_assert( namespace stream { + /** + * @brief Map an encoder reconfiguration result into the controller's backend-neutral status. + * + * @param status Encoder result status. + * @return Equivalent adaptive bitrate acknowledgement status. + */ + adaptive_bitrate::apply_status_e adaptive_bitrate_apply_status(const video::bitrate_reconfigure_status_e status) { + switch (status) { + case video::bitrate_reconfigure_status_e::applied: + return adaptive_bitrate::apply_status_e::applied; + case video::bitrate_reconfigure_status_e::unchanged: + return adaptive_bitrate::apply_status_e::unchanged; + case video::bitrate_reconfigure_status_e::invalid: + return adaptive_bitrate::apply_status_e::invalid; + case video::bitrate_reconfigure_status_e::unsupported: + return adaptive_bitrate::apply_status_e::unsupported; + case video::bitrate_reconfigure_status_e::failed: + return adaptive_bitrate::apply_status_e::failed; + } + + return adaptive_bitrate::apply_status_e::failed; + } + + /** + * @brief Convert an adaptive bitrate decision reason to a stable log and request label. + * + * @param reason Controller decision reason. + * @return Stable lower-case diagnostic label. + */ + std::string_view adaptive_bitrate_reason_name(const adaptive_bitrate::decision_reason_e reason) { + switch (reason) { + case adaptive_bitrate::decision_reason_e::capability_probe: + return "capability_probe"sv; + case adaptive_bitrate::decision_reason_e::unrecovered_loss: + return "unrecovered_loss"sv; + case adaptive_bitrate::decision_reason_e::fec_pressure: + return "fec_pressure"sv; + case adaptive_bitrate::decision_reason_e::stable_recovery: + return "stable_recovery"sv; + } + + return "unknown"sv; + } + /** * @brief Enumerates supported socket options. */ @@ -504,6 +549,7 @@ namespace stream { safe::mail_raw_t::event_t idr_events; safe::mail_raw_t::event_t> invalidate_ref_frames_events; safe::mail_raw_t::event_t bitrate_events; + safe::mail_raw_t::event_t bitrate_result_events; std::unique_ptr qos; } video; ///< Video worker thread state for the active stream. @@ -546,6 +592,8 @@ namespace stream { sync_util::sync_t> latest_snapshot; ///< Latest stable window for future consumers. } network_metrics; ///< Per-session network telemetry state. + adaptive_bitrate::controller_t adaptive_bitrate_controller; ///< Per-session controller owned by the control thread. + std::uint32_t launch_session_id; ///< RTSP launch-session ID associated with this stream. std::string client_cert; ///< PEM certificate for the paired client owning the stream. @@ -1138,6 +1186,16 @@ namespace stream { *session->network_metrics.latest_snapshot = *snapshot; } + const auto adaptive_state_before_observe = session->adaptive_bitrate_controller.state(); + session->adaptive_bitrate_controller.observe(*snapshot, now); + if (adaptive_state_before_observe != adaptive_bitrate::state_e::fixed_fallback && + session->adaptive_bitrate_controller.state() == adaptive_bitrate::state_e::fixed_fallback) { + BOOST_LOG(warning) + << "adaptive_bitrate_fallback" + << " session_id=" << session->launch_session_id + << " reason=invalid_telemetry"; + } + BOOST_LOG(debug) << "network_metrics" << " session_id=" << session->launch_session_id @@ -1370,6 +1428,46 @@ namespace stream { } else { publish_network_metrics(session, now); + while (auto result = session->video.bitrate_result_events->try_pop()) { + const auto adaptive_state_before_acknowledge = session->adaptive_bitrate_controller.state(); + session->adaptive_bitrate_controller.acknowledge( + adaptive_bitrate_apply_status(result->status), + result->effective_target_kbps, + now + ); + BOOST_LOG(info) + << "adaptive_bitrate_feedback" + << " session_id=" << session->launch_session_id + << " status=" << video::bitrate_reconfigure_status_name(result->status) + << " requested_kbps=" << result->requested_target_kbps + << " effective_kbps=" << result->effective_target_kbps; + if (adaptive_state_before_acknowledge == adaptive_bitrate::state_e::pending && + session->adaptive_bitrate_controller.state() == adaptive_bitrate::state_e::fixed_fallback) { + const auto successful_result = + result->status == video::bitrate_reconfigure_status_e::applied || + result->status == video::bitrate_reconfigure_status_e::unchanged; + BOOST_LOG(warning) + << "adaptive_bitrate_fallback" + << " session_id=" << session->launch_session_id + << " reason=" << (successful_result ? "invalid_telemetry"sv : "encoder_result"sv) + << " status=" << video::bitrate_reconfigure_status_name(result->status); + } + } + + if (auto decision = session->adaptive_bitrate_controller.tick( + now, + session->control.peer->roundTripTime, + session->control.peer->roundTripTimeVariance + )) { + const auto reason = adaptive_bitrate_reason_name(decision->reason); + session::request_video_bitrate(*session, decision->target_kbps, std::string {reason}); + BOOST_LOG(info) + << "adaptive_bitrate_decision" + << " session_id=" << session->launch_session_id + << " target_kbps=" << decision->target_kbps + << " reason=" << reason; + } + auto &feedback_queue = session->control.feedback_queue; while (feedback_queue->peek()) { auto feedback_msg = feedback_queue->pop(); @@ -2236,6 +2334,13 @@ namespace stream { safe::mail_raw_t::event_t video_bitrate_requests(session_t &session) { return session.video.bitrate_events; } + + /** + * @brief Return the video bitrate copied into a test session. + */ + std::uint32_t configured_video_bitrate(session_t &session) { + return static_cast(std::max(0, session.config.monitor.bitrate)); + } } // namespace testing #endif @@ -2374,6 +2479,28 @@ namespace stream { session->video.idr_events = mail->event(mail::idr); session->video.invalidate_ref_frames_events = mail->event>(mail::invalidate_ref_frames); session->video.bitrate_events = mail->event(mail::video_bitrate); + session->video.bitrate_result_events = mail->event(mail::video_bitrate_result); + + auto bitrate_ceiling_kbps = static_cast(std::max(0, config.monitor.bitrate)); + if (::config::video.max_bitrate > 0) { + bitrate_ceiling_kbps = std::min(bitrate_ceiling_kbps, static_cast(::config::video.max_bitrate)); + } + session->config.monitor.bitrate = static_cast(bitrate_ceiling_kbps); + const auto client_supports_fec_status = (config.mlFeatureFlags & ML_FF_FEC_STATUS) != 0; + const auto adaptive_bitrate_enabled = ::config::video.adaptive_bitrate && client_supports_fec_status; + session->adaptive_bitrate_controller.initialize( + { + adaptive_bitrate_enabled, + bitrate_ceiling_kbps, + } + ); + BOOST_LOG(debug) + << "adaptive_bitrate_session" + << " session_id=" << session->launch_session_id + << " configured=" << ::config::video.adaptive_bitrate + << " fec_status_supported=" << client_supports_fec_status + << " enabled=" << adaptive_bitrate_enabled + << " ceiling_kbps=" << bitrate_ceiling_kbps; session->video.lowseq = 0; session->video.ping_payload = launch_session.av_ping_payload; if (config.encryptionFlagsEnabled & SS_ENC_VIDEO) { diff --git a/src/stream.h b/src/stream.h index 7a5f4e3900d..d3aa36b4b88 100644 --- a/src/stream.h +++ b/src/stream.h @@ -128,6 +128,14 @@ namespace stream { * @return Latest-wins bitrate request event used by the encoder thread. */ safe::mail_raw_t::event_t video_bitrate_requests(session_t &session); + + /** + * @brief Return the video bitrate copied into a test session after host ceiling enforcement. + * + * @param session Streaming session allocated by the test. + * @return Effective video bitrate in kilobits per second. + */ + std::uint32_t configured_video_bitrate(session_t &session); } // namespace testing #endif } // namespace session diff --git a/src/video.cpp b/src/video.cpp index 07b2390769f..b071c484ecf 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -2456,6 +2456,7 @@ namespace video { auto idr_events = mail->event(mail::idr); auto invalidate_ref_frames_events = mail->event>(mail::invalidate_ref_frames); auto bitrate_events = mail->event(mail::video_bitrate); + auto bitrate_result_events = mail->event(mail::video_bitrate_result); { // Load a dummy image into the AVFrame to ensure we have something to encode @@ -2515,7 +2516,9 @@ namespace video { break; } - apply_pending_bitrate_reconfiguration(bitrate_events, *session, config); + if (auto result = apply_pending_bitrate_reconfiguration(bitrate_events, *session, config)) { + bitrate_result_events->raise(*result); + } if (encode(frame_nr++, *session, packets, channel_data, frame_timestamp)) { BOOST_LOG(error) << "Could not encode video packet"sv; diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index e62c61ed838..01c5fadfc28 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -260,6 +260,7 @@

{{ $t('config.configuration') }}

"dd_config_revert_delay": 3000, "dd_config_revert_on_disconnect": "disabled", "dd_mode_remapping": {"mixed": [], "resolution_only": [], "refresh_rate_only": []}, + "adaptive_bitrate": "disabled", "max_bitrate": 0, "minimum_fps_target": 0 }, diff --git a/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayModesSettings.vue b/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayModesSettings.vue index 51b5b8c227c..d9cd54d7eb2 100644 --- a/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayModesSettings.vue +++ b/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayModesSettings.vue @@ -2,6 +2,7 @@ import { ref } from 'vue' import { $tp } from '../../../platform-i18n' import PlatformLayout from '../../../PlatformLayout.vue' +import Checkbox from '../../../Checkbox.vue' const props = defineProps([ 'platform', @@ -11,6 +12,14 @@ const config = ref(props.config)