diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index 502c9d63d2a..5da44de37b4 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" ) @@ -155,11 +157,15 @@ 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" "${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/docs/configuration.md b/docs/configuration.md index ed503973a4b..e563bf2a1eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1397,6 +1397,34 @@ editing the `conf` file in a text editor. Use the examples as reference. +### adaptive_bitrate + + + + + + + + + + + + + + +
Description + Allow supported Moonlight sessions to reduce the video bitrate during sustained network loss. Runtime + bitrate changes are currently supported by the Windows native NVENC backend. Recovery is gradual and + requires both a quiet period without new loss pressure and a fresh, healthy ENet RTT sample produced by a + reliable acknowledgement from Moonlight; the absence of FEC reports alone never permits an increase. The + controller never exceeds the bitrate requested by Moonlight or the configured `max_bitrate` ceiling. + Unsupported clients and encoders continue to use their fixed effective 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..3e425908b8b --- /dev/null +++ b/src/adaptive_bitrate.cpp @@ -0,0 +1,464 @@ +/** + * @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_check; + baseline_rtt_ms_ = 0; + last_control_liveness_token_ = 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; + recovery_blocked_by_rate_limit_ = false; + first_bad_window_at_.reset(); + stable_since_.reset(); + decision_history_ = {}; + } + + bool controller_t::handle_invalid_telemetry(const network_metrics::snapshot_t &snapshot) { + if (snapshot.malformed_reports == 0 && snapshot.protocol_mismatch_reports == 0) { + return false; + } + + if (state_ == state_e::pending) { + fallback_after_acknowledgement_ = true; + } else { + state_ = state_e::fixed_fallback; + pending_target_kbps_ = 0; + } + return true; + } + + bool controller_t::handle_rate_limited_telemetry(const network_metrics::snapshot_t &snapshot) { + if (snapshot.rate_limited_reports == 0) { + return false; + } + + reset_degradation_confirmation(); + pending_degradation_ = degradation_e::none; + stable_since_.reset(); + recovery_blocked_by_rate_limit_ = true; + return true; + } + + bool controller_t::accept_telemetry_signal(const network_metrics::snapshot_t &snapshot) { + if (snapshot.fec_reports != 0) { + telemetry_seen_ = true; + return true; + } + return telemetry_seen_ || snapshot.frame_loss_requests != 0; + } + + controller_t::degradation_e controller_t::classify_degradation( + const network_metrics::snapshot_t &snapshot, + const long double unrecovered_ratio, + const long double recovered_ratio, + const bool high_latency + ) { + using enum degradation_e; + + if (const auto severe_loss = unrecovered_ratio >= unrecovered_loss_threshold || + snapshot.incomplete_fec_reports != 0 || + snapshot.frame_loss_requests != 0; + severe_loss) { + return unrecovered_loss; + } + if (recovered_ratio >= fec_pressure_threshold && high_latency) { + return fec_pressure; + } + return none; + } + + void controller_t::reset_degradation_confirmation() { + consecutive_bad_windows_ = 0; + candidate_degradation_ = degradation_e::none; + first_bad_window_at_.reset(); + } + + void controller_t::observe_degradation(const degradation_e degradation, const time_point_t now) { + using enum degradation_e; + + 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 == unrecovered_loss) { + candidate_degradation_ = unrecovered_loss; + } + + if (consecutive_bad_windows_ < 2) { + return; + } + + if (candidate_degradation_ == unrecovered_loss || degradation == unrecovered_loss) { + pending_degradation_ = unrecovered_loss; + } else if (pending_degradation_ == none) { + pending_degradation_ = fec_pressure; + } + reset_degradation_confirmation(); + } + + void controller_t::observe_stability( + const network_metrics::snapshot_t &snapshot, + const long double recovered_ratio, + const bool high_latency, + const time_point_t now + ) { + reset_degradation_confirmation(); + + const auto stable_window = snapshot.unrecovered_data_packets == 0 && + snapshot.incomplete_fec_reports == 0 && + snapshot.frame_loss_requests == 0 && + recovered_ratio < stable_fec_threshold && + !high_latency; + if (!stable_window || !stable_since_) { + stable_since_ = now; + } + + if (stable_window && snapshot.fec_reports != 0) { + if (recovery_blocked_by_rate_limit_) { + stable_since_ = now; + } + recovery_blocked_by_rate_limit_ = false; + } + + if (stable_window && snapshot.rtt_ms != 0 && + (baseline_rtt_ms_ == 0 || snapshot.rtt_ms < baseline_rtt_ms_)) { + baseline_rtt_ms_ = snapshot.rtt_ms; + } + } + + 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 (handle_invalid_telemetry(snapshot) || + handle_rate_limited_telemetry(snapshot) || + !accept_telemetry_signal(snapshot)) { + 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_); + if (const auto degradation = classify_degradation(snapshot, unrecovered_ratio, recovered_ratio, high_latency); + degradation != degradation_e::none) { + observe_degradation(degradation, now); + return; + } + + observe_stability(snapshot, recovered_ratio, high_latency, now); + } + + bool controller_t::consume_control_liveness(const std::uint32_t control_liveness_token) { + const auto fresh_control_liveness = + control_liveness_token != 0 && control_liveness_token != last_control_liveness_token_; + if (fresh_control_liveness) { + last_control_liveness_token_ = control_liveness_token; + } + return fresh_control_liveness; + } + + bool controller_t::observe_live_latency( + const time_point_t now, + const std::uint32_t live_rtt_ms, + const std::uint32_t live_rtt_variance_ms + ) { + if (baseline_rtt_ms_ == 0 && live_rtt_ms != 0) { + baseline_rtt_ms_ = live_rtt_ms; + } + const auto high_latency = latency_is_high(live_rtt_ms, live_rtt_variance_ms, baseline_rtt_ms_); + if (high_latency) { + stable_since_ = now; + } else if (live_rtt_ms != 0 && live_rtt_ms < baseline_rtt_ms_) { + baseline_rtt_ms_ = live_rtt_ms; + } + return high_latency; + } + + bool controller_t::decision_is_allowed(const time_point_t now) const { + return !decision_history_.last_decision_at || + elapsed(now, *decision_history_.last_decision_at, decision_interval); + } + + decision_t controller_t::emit_decision( + const std::uint32_t target_kbps, + const decision_reason_e reason, + const time_point_t now + ) { + using enum decision_direction_e; + + auto direction = none; + if (target_kbps < target_kbps_) { + direction = decrease; + } else if (target_kbps > target_kbps_) { + direction = increase; + } + + if (direction != none) { + if (decision_history_.direction != none && direction != decision_history_.direction) { + decision_history_.last_direction_reversal_at = now; + } + decision_history_.direction = direction; + } + + pending_target_kbps_ = target_kbps; + pending_reason_ = reason; + decision_history_.last_decision_at = now; + state_ = state_e::pending; + return {target_kbps, reason}; + } + + std::optional controller_t::reduce_for_pending_degradation( + const time_point_t now, + const bool decision_allowed + ) { + if (pending_degradation_ == degradation_e::none || !decision_allowed) { + return std::nullopt; + } + + if (target_kbps_ <= floor_kbps_) { + pending_degradation_ = degradation_e::none; + return std::nullopt; + } + + 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 std::nullopt; + } + return emit_decision(reduced, reason, now); + } + + bool controller_t::recovery_is_allowed( + const time_point_t now, + const bool live_latency_high, + const bool fresh_control_liveness, + const bool decision_allowed + ) const { + return telemetry_seen_ && + stable_since_.has_value() && + target_kbps_ < ceiling_kbps_ && + !live_latency_high && + fresh_control_liveness && + !recovery_blocked_by_rate_limit_ && + elapsed(now, *stable_since_, stable_recovery_delay) && + (!decision_history_.last_increase_at || + elapsed(now, *decision_history_.last_increase_at, recovery_increase_interval)) && + decision_allowed; + } + + bool controller_t::upward_reversal_is_cooling_down(const time_point_t now) const { + return decision_history_.direction == decision_direction_e::decrease && + decision_history_.last_direction_reversal_at.has_value() && + (now <= *decision_history_.last_direction_reversal_at || + now - *decision_history_.last_direction_reversal_at <= upward_reversal_cooldown); + } + + 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 std::uint32_t control_liveness_token + ) { + const auto fresh_control_liveness = consume_control_liveness(control_liveness_token); + + if (state_ == state_e::fixed_disabled || state_ == state_e::fixed_fallback) { + return std::nullopt; + } + + if (state_ == state_e::probing) { + return emit_decision(ceiling_kbps_, decision_reason_e::capability_check, now); + } + + if (state_ == state_e::pending) { + return std::nullopt; + } + + const auto live_latency_high = observe_live_latency(now, live_rtt_ms, live_rtt_variance_ms); + const auto decision_allowed = decision_is_allowed(now); + if (auto decrease = reduce_for_pending_degradation(now, decision_allowed)) { + return decrease; + } + + if (!recovery_is_allowed(now, live_latency_high, fresh_control_liveness, decision_allowed) || + upward_reversal_is_cooling_down(now)) { + 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); + return emit_decision(increased, decision_reason_e::stable_recovery, now); + } + + void controller_t::acknowledge( + const apply_status_e status, + const std::uint32_t effective_target_kbps, + const time_point_t now + ) { + using enum decision_reason_e; + + 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_ == stable_recovery) { + decision_history_.last_increase_at = now; + } else if (pending_reason_ == unrecovered_loss || pending_reason_ == 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..b1a8138d0d1 --- /dev/null +++ b/src/adaptive_bitrate.h @@ -0,0 +1,359 @@ +/** + * @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 backend capability check. + 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_check, ///< Check backend 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 FEC 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 FEC telemetry window. + * + * Duplicate or out-of-order snapshot sequence numbers are ignored. + * Malformed or protocol-mismatched telemetry moves an active controller to + * fixed fallback at its last effective target, after any in-flight encoder + * command is acknowledged. A rate-limited window instead holds the current + * target and clears accumulated degradation and recovery evidence. Recovery + * remains blocked until a later valid, stable FEC report makes event silence + * trustworthy again. An already outstanding command remains pending and may + * still be 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. Recovery also requires a nonzero control-liveness token that + * differs from the most recently observed token. Sunshine supplies ENet's + * last-receive time, which is refreshed in the same acknowledgement handler + * as the smoothed RTT sample. Consequently, elapsed wall-clock time and stale + * RTT values cannot cause upward recovery without a newly received RTT sample. + * + * @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. + * @param control_liveness_token Latest control-channel receive token, such + * as ENet's peer last-receive time. Zero means no valid liveness sample. + * @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, + std::uint32_t control_liveness_token + ); + + /** + * @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, + }; + + /** + * @brief Timing and direction retained across encoder decisions. + */ + struct decision_history_t { + decision_direction_e direction = decision_direction_e::none; ///< Latest non-probe command direction. + 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. + }; + + /** + * @brief Handle malformed or protocol-mismatched telemetry. + * + * @param snapshot Telemetry snapshot to inspect. + * @return `true` when the snapshot was handled and observation must stop. + */ + bool handle_invalid_telemetry(const network_metrics::snapshot_t &snapshot); + + /** + * @brief Hold adaptation after telemetry report limiting. + * + * @param snapshot Telemetry snapshot to inspect. + * @return `true` when rate limiting was observed and observation must stop. + */ + bool handle_rate_limited_telemetry(const network_metrics::snapshot_t &snapshot); + + /** + * @brief Record whether a snapshot carries actionable loss telemetry. + * + * @param snapshot Telemetry snapshot to inspect. + * @return `true` when the snapshot can update controller evidence. + */ + bool accept_telemetry_signal(const network_metrics::snapshot_t &snapshot); + + /** + * @brief Classify the strongest degradation present in one telemetry window. + * + * @param snapshot Telemetry snapshot to classify. + * @param unrecovered_ratio Ratio of unrecovered data shards. + * @param recovered_ratio Ratio of FEC-recovered data shards. + * @param high_latency Whether latency corroborates recovery pressure. + * @return Classified degradation, or `none` for a healthy window. + */ + static degradation_e classify_degradation( + const network_metrics::snapshot_t &snapshot, + long double unrecovered_ratio, + long double recovered_ratio, + bool high_latency + ); + + /** + * @brief Accumulate one degraded telemetry window. + * + * @param degradation Degradation observed in the window. + * @param now Monotonic observation time. + */ + void observe_degradation(degradation_e degradation, time_point_t now); + + /** + * @brief Update recovery evidence from one non-degraded telemetry window. + * + * @param snapshot Telemetry snapshot to observe. + * @param recovered_ratio Ratio of FEC-recovered data shards. + * @param high_latency Whether latency remains elevated. + * @param now Monotonic observation time. + */ + void observe_stability( + const network_metrics::snapshot_t &snapshot, + long double recovered_ratio, + bool high_latency, + time_point_t now + ); + + /** + * @brief Clear unconfirmed degraded-window evidence. + */ + void reset_degradation_confirmation(); + + /** + * @brief Consume a fresh control-channel liveness sample. + * + * @param control_liveness_token Latest control-channel receive token. + * @return `true` when the token is valid and was not previously consumed. + */ + bool consume_control_liveness(std::uint32_t control_liveness_token); + + /** + * @brief Update the stable RTT floor from live control-channel telemetry. + * + * @param now Monotonic control-loop time. + * @param live_rtt_ms Current smoothed RTT. + * @param live_rtt_variance_ms Current RTT variance. + * @return `true` when live latency is materially elevated. + */ + bool observe_live_latency(time_point_t now, std::uint32_t live_rtt_ms, std::uint32_t live_rtt_variance_ms); + + /** + * @brief Check whether the encoder command spacing interval has elapsed. + * + * @param now Monotonic control-loop time. + * @return `true` when another decision may be emitted. + */ + bool decision_is_allowed(time_point_t now) const; + + /** + * @brief Record and return one encoder decision. + * + * @param target_kbps Requested encoder target. + * @param reason Stable diagnostic reason for the command. + * @param now Monotonic decision time. + * @return Recorded encoder decision. + */ + decision_t emit_decision(std::uint32_t target_kbps, decision_reason_e reason, time_point_t now); + + /** + * @brief Emit a decrease for confirmed degradation when possible. + * + * @param now Monotonic control-loop time. + * @param decision_allowed Whether command spacing permits a decision. + * @return A decrease command, or no value. + */ + std::optional reduce_for_pending_degradation(time_point_t now, bool decision_allowed); + + /** + * @brief Check all stable recovery prerequisites. + * + * @param now Monotonic control-loop time. + * @param live_latency_high Whether live latency remains elevated. + * @param fresh_control_liveness Whether a new reliable acknowledgement was observed. + * @param decision_allowed Whether command spacing permits a decision. + * @return `true` when recovery may increase the bitrate. + */ + bool recovery_is_allowed( + time_point_t now, + bool live_latency_high, + bool fresh_control_liveness, + bool decision_allowed + ) const; + + /** + * @brief Check whether an upward direction reversal remains in cooldown. + * + * @param now Monotonic control-loop time. + * @return `true` while another upward reversal is blocked. + */ + bool upward_reversal_is_cooling_down(time_point_t now) const; + + 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_check; ///< Outstanding command reason. + std::uint32_t baseline_rtt_ms_ = 0; ///< Stable-path RTT floor used only as corroboration. + std::uint32_t last_control_liveness_token_ = 0; ///< Most recently consumed control-channel RTT sample token. + std::uint64_t last_snapshot_sequence_ = 0; ///< Last consumed FEC telemetry 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. + bool recovery_blocked_by_rate_limit_ = false; ///< Require a valid stable FEC report after telemetry overload. + 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. + decision_history_t decision_history_; ///< Timing and direction of prior encoder decisions. + }; +} // 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 2c7ee9b121b..ce889378e29 100644 --- a/src/globals.h +++ b/src/globals.h @@ -57,6 +57,8 @@ 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(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/network_metrics.cpp b/src/network_metrics.cpp new file mode 100644 index 00000000000..b94899bcf7e --- /dev/null +++ b/src/network_metrics.cpp @@ -0,0 +1,209 @@ +/** + * @file src/network_metrics.cpp + * @brief Definitions for per-session Moonlight network telemetry. + */ + +// standard includes +#include + +// 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; + constexpr std::uint16_t max_data_packets = 0x03FF; + constexpr std::uint16_t max_fec_shards = 255; + const auto total_packets = static_cast(status.total_data_packets) + status.total_parity_packets; + const auto expected_parity_packets = + (static_cast(status.total_data_packets) * status.fec_percentage + 99U) / 100U; + 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_fec_layout = status.total_data_packets <= max_data_packets && + status.total_parity_packets == expected_parity_packets && + (status.total_data_packets != 0 || status.fec_percentage == 0) && + (status.fec_percentage == 0 || total_packets <= max_fec_shards); + 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_fec_layout && 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], + }; + + if (!has_valid_relationships(status)) { + return std::nullopt; + } + + return status; + } + + std::uint32_t report_limit_for_framerate(const std::uint32_t frames_per_second) { + constexpr std::uint64_t headroom_numerator = 5; + constexpr std::uint64_t headroom_denominator = 4; + constexpr std::uint64_t milliseconds_per_second = 1000; + constexpr auto window_milliseconds = static_cast(aggregation_window.count()); + constexpr auto denominator = milliseconds_per_second * headroom_denominator; + + const auto scaled_reports = static_cast(frames_per_second) * + max_fec_blocks_per_frame * + window_milliseconds * + headroom_numerator; + const auto estimated_reports = (scaled_reports + denominator - 1) / denominator; + return static_cast(std::clamp( + estimated_reports, + default_report_limit_per_window, + hard_max_reports_per_window + )); + } + + tracker_t::tracker_t(const time_point_t window_start, const std::uint32_t report_limit): + window_start_ {window_start}, + report_limit_ {std::clamp(report_limit, default_report_limit_per_window, hard_max_reports_per_window)} { + } + + bool tracker_t::accumulator_t::has_activity() const { + return processed_reports != 0 || frame_loss_requests != 0; + } + + ingest_result_e tracker_t::ingest(const std::string_view payload, const bool feature_advertised) { + if (accumulator_.processed_reports >= report_limit_) { + ++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) { + ++accumulator_.malformed_reports; + return ingest_result_e::malformed; + } + + ++accumulator_.fec_reports; + if (status->total_data_packets == 0) { + ++accumulator_.incomplete_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; + if (const auto received_shards = + static_cast(status->received_data_packets) + status->received_parity_packets; + 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_frame_loss_request() { + ++accumulator_.frame_loss_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, + .incomplete_fec_reports = accumulator_.incomplete_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, + .frame_loss_requests = accumulator_.frame_loss_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..00acb4c299f --- /dev/null +++ b/src/network_metrics.h @@ -0,0 +1,184 @@ +/** + * @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; + + /** Default and minimum number of FEC reports processed per session window. */ + inline constexpr std::uint32_t default_report_limit_per_window = 512; + + /** Protocol maximum number of FEC blocks emitted for one video frame. */ + inline constexpr std::uint32_t max_fec_blocks_per_frame = 4; + + /** Hard upper bound for a configured per-session report limit. */ + inline constexpr std::uint32_t hard_max_reports_per_window = 4096; + + /** 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 incomplete_fec_reports; ///< Reports emitted for a missing FEC block before counters were initialized. + 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 frame_loss_requests; ///< IDR or reference-frame invalidation 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 Derive a bounded telemetry report limit from the requested frame rate. + * + * The estimate allows four FEC blocks per frame during the 500 ms aggregation + * window, plus 25 percent headroom for reports arriving in short bursts. The + * result is rounded up, never falls below default_report_limit_per_window, + * and never exceeds hard_max_reports_per_window. + * + * @param frames_per_second Requested stream frame rate. + * @return Safe per-session FEC report limit for one aggregation window. + */ + [[nodiscard]] std::uint32_t report_limit_for_framerate(std::uint32_t frames_per_second); + + /** + * @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. + * @param report_limit Maximum reports admitted in each aggregation window. + * Values outside the supported range are clamped. + */ + explicit tracker_t( + time_point_t window_start = std::chrono::steady_clock::now(), + std::uint32_t report_limit = default_report_limit_per_window + ); + + /** + * @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 client frame-loss recovery request in the current window. + */ + void record_frame_loss_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 incomplete_fec_reports = 0; ///< Reports for a skipped block with uninitialized counters. + 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 frame_loss_requests = 0; ///< IDR or reference-frame invalidation 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 frame-loss 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::uint32_t report_limit_; ///< Maximum reports admitted in one 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/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..19ae0b02488 100644 --- a/src/nvenc/nvenc_base.h +++ b/src/nvenc/nvenc_base.h @@ -9,6 +9,7 @@ #include "nvenc_config.h" #include "nvenc_encoded_frame.h" #include "nvenc_encoder.h" +#include "nvenc_reconfigure.h" #include "nvenc_sdk.h" #include "src/logging.h" #include "src/video.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..7ec3576c35a --- /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.has_value()) { + 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.has_value()) { + 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 fa0151997bd..14b82cf089e 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -20,12 +20,14 @@ extern "C" { } // local includes +#include "adaptive_bitrate.h" #include "config.h" #include "display_device.h" #include "globals.h" #include "input.h" #include "logging.h" #include "network.h" +#include "network_metrics.h" #include "platform/common.h" #include "process.h" #include "stream.h" @@ -77,8 +79,59 @@ 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 { + /** + * @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) { + using enum adaptive_bitrate::decision_reason_e; + + switch (reason) { + case capability_check: + return "capability_check"sv; + case unrecovered_loss: + return "unrecovered_loss"sv; + case fec_pressure: + return "fec_pressure"sv; + case stable_recovery: + return "stable_recovery"sv; + } + + return "unknown"sv; + } + /** * @brief Enumerates supported socket options. */ @@ -497,6 +550,8 @@ 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. @@ -534,6 +589,12 @@ 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. + } 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. @@ -1101,6 +1162,184 @@ namespace stream { return 0; } + /** + * @brief Publish an elapsed network telemetry window for one session. + * + * The caller must be the session's control thread so tracker and controller + * ownership remain single-threaded. + * + * @param session Active streaming session to update. + * @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 `true` when a completed window was published to the controller. + */ + static bool publish_network_metrics( + session_t &session, + const network_metrics::time_point_t now, + const std::uint32_t rtt_ms, + const std::uint32_t rtt_variance_ms + ) { + auto snapshot = session.network_metrics.tracker.poll( + now, + rtt_ms, + rtt_variance_ms + ); + if (!snapshot) { + return false; + } + + 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 + << " sequence=" << snapshot->sequence + << " window_ms=" << snapshot->window_duration_ms + << " fec_reports=" << snapshot->fec_reports + << " incomplete_fec_reports=" << snapshot->incomplete_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 + << " frame_loss_requests=" << snapshot->frame_loss_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; + return true; + } + + /** + * @brief Poll an elapsed window before ingesting one control-channel FEC report. + * + * The caller must be the session's control thread. Polling first ensures the + * new report starts the next window after an elapsed window is published. + * + * @param session Active streaming session to update. + * @param payload Raw SS_FRAME_FEC_STATUS payload. + * @param now Monotonic report arrival time. + * @param rtt_ms Current ENet smoothed round-trip time. + * @param rtt_variance_ms Current ENet round-trip-time variance. + * @return Tracker disposition for the supplied report. + */ + static network_metrics::ingest_result_e ingest_frame_fec_status( + session_t &session, + const std::string_view payload, + const network_metrics::time_point_t now, + const std::uint32_t rtt_ms, + const std::uint32_t rtt_variance_ms + ) { + publish_network_metrics(session, now, rtt_ms, rtt_variance_ms); + return session.network_metrics.tracker.ingest( + payload, + (session.config.mlFeatureFlags & ML_FF_FEC_STATUS) != 0 + ); + } + + /** + * @brief Record one client request caused by a lost or unusable video frame. + * + * The caller must be the session's control thread. Polling first keeps the + * request in the window that begins at its arrival time. + * + * @param session Active streaming session to update. + * @param now Monotonic request arrival time. + * @param rtt_ms Current ENet smoothed round-trip time. + * @param rtt_variance_ms Current ENet round-trip-time variance. + */ + static void record_frame_loss_request( + session_t &session, + const network_metrics::time_point_t now, + const std::uint32_t rtt_ms, + const std::uint32_t rtt_variance_ms + ) { + publish_network_metrics(session, now, rtt_ms, rtt_variance_ms); + session.network_metrics.tracker.record_frame_loss_request(); + } + + /** + * @brief Deliver a latest-wins adaptive bitrate command to the encoder thread. + * + * @param session Active streaming session that owns the encoder mailbox. + * @param target_kbps Requested encoder target in kilobits per second. + */ + static void request_video_bitrate(session_t &session, const std::uint32_t target_kbps) { + session.video.bitrate_events->raise(video::bitrate_reconfigure_request_t {target_kbps}); + } + + /** + * @brief Acknowledge encoder results and dispatch one adaptive bitrate decision. + * + * The caller must be the session's control thread. Encoder results cross the + * mailbox boundary before the controller is ticked, preserving controller + * ownership and allowing a new command only after acknowledgement. + * + * @param session Active streaming session to update. + * @param now Monotonic control-loop time. + * @param rtt_ms Current ENet smoothed round-trip time. + * @param rtt_variance_ms Current ENet round-trip-time variance. + * @param control_liveness_token ENet receive token refreshed with the RTT by a reliable acknowledgement. + */ + static void process_adaptive_bitrate( + session_t &session, + const network_metrics::time_point_t now, + const std::uint32_t rtt_ms, + const std::uint32_t rtt_variance_ms, + const std::uint32_t control_liveness_token + ) { + 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, + rtt_ms, + rtt_variance_ms, + control_liveness_token + )) { + const auto reason = adaptive_bitrate_reason_name(decision->reason); + BOOST_LOG(info) + << "adaptive_bitrate_decision" + << " session_id=" << session.launch_session_id + << " target_kbps=" << decision->target_kbps + << " reason=" << reason; + request_video_bitrate(session, decision->target_kbps); + } + } + /** * @brief Run the broadcast control-channel worker thread. * @@ -1135,9 +1374,29 @@ 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) { + const auto now = std::chrono::steady_clock::now(); + ingest_frame_fec_status( + *session, + payload, + now, + session->control.peer->roundTripTime, + session->control.peer->roundTripTimeVariance + ); + }); + server->map(packetTypes[IDX_REQUEST_IDR_FRAME], [&](session_t *session, const std::string_view &payload) { BOOST_LOG(debug) << "type [IDX_REQUEST_IDR_FRAME]"sv; + record_frame_loss_request( + *session, + std::chrono::steady_clock::now(), + session->control.peer->roundTripTime, + session->control.peer->roundTripTimeVariance + ); session->video.idr_events->raise(true); }); @@ -1151,6 +1410,12 @@ namespace stream { << "firstFrame [" << firstFrame << ']' << std::endl << "lastFrame [" << lastFrame << ']'; + record_frame_loss_request( + *session, + std::chrono::steady_clock::now(), + session->control.peer->roundTripTime, + session->control.peer->roundTripTimeVariance + ); session->video.invalidate_ref_frames_events->raise(std::make_pair(firstFrame, lastFrame)); }); @@ -1299,6 +1564,20 @@ namespace stream { if (!session->control.peer) { has_session_awaiting_peer = true; } else { + publish_network_metrics( + *session, + now, + session->control.peer->roundTripTime, + session->control.peer->roundTripTimeVariance + ); + process_adaptive_bitrate( + *session, + now, + session->control.peer->roundTripTime, + session->control.peer->roundTripTimeVariance, + session->control.peer->lastReceiveTime + ); + auto &feedback_queue = session->control.feedback_queue; while (feedback_queue->peek()) { auto feedback_msg = feedback_queue->pop(); @@ -2139,6 +2418,95 @@ namespace stream { return session.client_cert; } +#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; + } + + /** + * @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)); + } + + /** + * @brief Ingest one FEC report through the control-thread orchestration path. + */ + network_metrics::ingest_result_e ingest_frame_fec_status( + session_t &session, + const std::string_view payload, + const network_metrics::time_point_t now, + const std::uint32_t rtt_ms, + const std::uint32_t rtt_variance_ms + ) { + return stream::ingest_frame_fec_status(session, payload, now, rtt_ms, rtt_variance_ms); + } + + /** + * @brief Record one frame-loss request through the control-thread orchestration path. + */ + void record_frame_loss_request( + session_t &session, + const network_metrics::time_point_t now, + const std::uint32_t rtt_ms, + const std::uint32_t rtt_variance_ms + ) { + stream::record_frame_loss_request(session, now, rtt_ms, rtt_variance_ms); + } + + /** + * @brief Publish an elapsed telemetry window through the production observation path. + */ + bool publish_network_metrics( + session_t &session, + const network_metrics::time_point_t now, + const std::uint32_t rtt_ms, + const std::uint32_t rtt_variance_ms + ) { + return stream::publish_network_metrics(session, now, rtt_ms, rtt_variance_ms); + } + + /** + * @brief Run pending-result acknowledgement and decision dispatch as the control thread would. + */ + void process_adaptive_bitrate( + session_t &session, + const network_metrics::time_point_t now, + const std::uint32_t rtt_ms, + const std::uint32_t rtt_variance_ms, + const std::uint32_t control_liveness_token + ) { + stream::process_adaptive_bitrate(session, now, rtt_ms, rtt_variance_ms, control_liveness_token); + } + + /** + * @brief Publish one encoder result into the control-thread acknowledgement mailbox. + */ + void publish_video_bitrate_result(session_t &session, video::bitrate_reconfigure_result_t result) { + session.video.bitrate_result_events->raise(std::move(result)); + } + + /** + * @brief Return whether the adaptive controller consumed valid FEC telemetry. + */ + bool adaptive_bitrate_telemetry_seen(session_t &session) { + return session.adaptive_bitrate_controller.telemetry_seen(); + } + + /** + * @brief Return whether the adaptive controller entered fixed fallback. + */ + bool adaptive_bitrate_in_fallback(session_t &session) { + return session.adaptive_bitrate_controller.state() == adaptive_bitrate::state_e::fixed_fallback; + } + } // namespace testing +#endif + /** * @brief Stop the active streaming session and prevent new packets from being queued. */ @@ -2273,6 +2641,34 @@ 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); + + 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; + 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); + 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; + const auto requested_fps = static_cast(std::max(0, config.monitor.framerate)); + session->network_metrics.tracker = network_metrics::tracker_t { + std::chrono::steady_clock::now(), + network_metrics::report_limit_for_framerate(requested_fps), + }; 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 c0b0f962378..ac64a1fa593 100644 --- a/src/stream.h +++ b/src/stream.h @@ -5,7 +5,13 @@ #pragma once // standard includes +#include +#include +#include #include +#ifdef SUNSHINE_TESTS + #include +#endif // lib includes #include @@ -14,6 +20,9 @@ #include "audio.h" #include "crypto.h" #include "video.h" +#ifdef SUNSHINE_TESTS + #include "network_metrics.h" +#endif namespace stream { constexpr auto VIDEO_STREAM_PORT = 9; ///< GameStream base-port offset used for the video UDP stream. @@ -31,7 +40,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 +103,115 @@ namespace stream { * @return PEM certificate associated with the session's client. */ const std::string &client_cert(session_t &session); + +#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); + + /** + * @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); + + /** + * @brief Ingest one FEC report through the control-thread orchestration path. + * + * @param session Streaming session allocated by the test. + * @param payload Raw SS_FRAME_FEC_STATUS payload. + * @param now Monotonic arrival time. + * @param rtt_ms Test ENet round-trip time used if an elapsed window is published first. + * @param rtt_variance_ms Test ENet round-trip-time variance. + * @return Tracker disposition for the supplied report. + */ + network_metrics::ingest_result_e ingest_frame_fec_status( + session_t &session, + std::string_view payload, + network_metrics::time_point_t now, + std::uint32_t rtt_ms, + std::uint32_t rtt_variance_ms + ); + + /** + * @brief Record one IDR or reference-frame invalidation request through production orchestration. + * + * @param session Streaming session allocated by the test. + * @param now Monotonic request arrival time. + * @param rtt_ms Test ENet round-trip time. + * @param rtt_variance_ms Test ENet round-trip-time variance. + */ + void record_frame_loss_request( + session_t &session, + network_metrics::time_point_t now, + std::uint32_t rtt_ms, + std::uint32_t rtt_variance_ms + ); + + /** + * @brief Publish an elapsed telemetry window through the production observation path. + * + * @param session Streaming session allocated by the test. + * @param now Monotonic publication time. + * @param rtt_ms Test ENet round-trip time. + * @param rtt_variance_ms Test ENet round-trip-time variance. + * @return `true` when a window was published and observed by the controller. + */ + bool publish_network_metrics( + session_t &session, + network_metrics::time_point_t now, + std::uint32_t rtt_ms, + std::uint32_t rtt_variance_ms + ); + + /** + * @brief Run pending-result acknowledgement and decision dispatch as the control thread would. + * + * @param session Streaming session allocated by the test. + * @param now Monotonic control-loop time. + * @param rtt_ms Test ENet round-trip time. + * @param rtt_variance_ms Test ENet round-trip-time variance. + * @param control_liveness_token Test token that changes with a fresh control-channel RTT sample. + */ + void process_adaptive_bitrate( + session_t &session, + network_metrics::time_point_t now, + std::uint32_t rtt_ms, + std::uint32_t rtt_variance_ms, + std::uint32_t control_liveness_token + ); + + /** + * @brief Publish one encoder result into the control-thread acknowledgement mailbox. + * + * @param session Streaming session allocated by the test. + * @param result Encoder result to acknowledge on the next orchestration pass. + */ + void publish_video_bitrate_result(session_t &session, video::bitrate_reconfigure_result_t result); + + /** + * @brief Return whether the adaptive controller consumed valid FEC telemetry. + * + * @param session Streaming session allocated by the test. + * @return `true` after at least one valid report was published. + */ + bool adaptive_bitrate_telemetry_seen(session_t &session); + + /** + * @brief Return whether the adaptive controller entered fixed fallback. + * + * @param session Streaming session allocated by the test. + * @return `true` when adaptation stopped after an invalid encoder result or telemetry window. + */ + bool adaptive_bitrate_in_fallback(session_t &session); + } // namespace testing +#endif } // namespace session } // namespace stream diff --git a/src/video.cpp b/src/video.cpp index 5306f37d706..f053f065aa7 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -45,6 +45,52 @@ using namespace std::literals; namespace video { + std::string_view bitrate_reconfigure_status_name(bitrate_reconfigure_status_e status) { + using enum bitrate_reconfigure_status_e; + + switch (status) { + case applied: + return "applied"; + case unchanged: + return "unchanged"; + case invalid: + return "invalid"; + case unsupported: + return "unsupported"; + case 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 + << " 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 +595,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 +2407,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 +2418,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 +2456,8 @@ 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); + auto bitrate_result_events = mail->event(mail::video_bitrate_result); { // Load a dummy image into the AVFrame to ensure we have something to encode @@ -2451,6 +2517,10 @@ namespace video { break; } + 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; return; diff --git a/src/video.h b/src/video.h index b70ed53b3a0..24a84cd2c4c 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,42 @@ 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. + }; + + /** + * @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 +395,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 +437,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/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..9a973c7bff3 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,15 @@ const config = ref(props.config)