Each finished broadcast is filed locally — tier, probed encoder, rated, settled, steps spent — and Models/RateHistory turns that into at most a suggestion. The app never applies one: it asks before the next stream, "Use it" sets the rate, "Keep mine" remembers that exact number so the same question does not return until the evidence moves. Nothing leaves the machine. Two rules keep it from nagging. The high-water mark is the highest rate that *finished* a session with zero steps, not the highest seen, so one easy session (a menu screen, a quiet game) cannot set a target the machine cannot hold. And it is asymmetric: raising needs 10% above the current rate across five clean sessions of the same tier and encoder, lowering needs three step-downs in five — fixing damage should not wait for a percentage, and risking it should. The encoder key is real, not decorative: StreamHealth.VideoEncoder is now filled from the probe (FfmpegEncoder), so history recorded against NVENC is not applied to a machine that later runs QSV. At startup, before anything has streamed, the last session on that tier is the honest guess. 672 pass, 0 warnings.
131 lines
5.1 KiB
C#
131 lines
5.1 KiB
C#
using System;
|
||
using System.Windows;
|
||
using ytLive.Helpers;
|
||
using ytLive.Models;
|
||
using ytLive.Services;
|
||
using ytLive.Services.Encoder;
|
||
|
||
namespace ytLive.ViewModels;
|
||
|
||
/// <summary>
|
||
/// TASK 33, slice 3: the session's step-down watch — policy feed, one encoder relaunch, one toast.
|
||
///
|
||
/// <para>Kept in its own file because it is the only place that both reads the pump's stats and acts on
|
||
/// the session; the pure decision lives in <see cref="StepDownPolicy"/>, the sentence in
|
||
/// <see cref="StepDownPlanner"/>.</para>
|
||
/// </summary>
|
||
public partial class MainViewModel
|
||
{
|
||
private StepDownPlanner? _stepDownPlanner;
|
||
private (int Width, int Height, int Fps, double BitrateMbps)? _encoderTierOverride;
|
||
private bool _stepDownBusy;
|
||
private double _sessionRatedMbps;
|
||
|
||
/// <summary>Starts the watch for this broadcast. Called once per session, before the pump starts.</summary>
|
||
private void StartStepDownWatch()
|
||
{
|
||
var quality = EffectiveQuality(OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
|
||
_encoderTierOverride = null;
|
||
_sessionRatedMbps = quality.Bitrate;
|
||
_stepDownPlanner = new StepDownPlanner(
|
||
new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate,
|
||
floorBitrateMbps: RateLadder.For(quality).Minimum),
|
||
_sessionStartedAtUtc ?? DateTime.UtcNow);
|
||
_framePump.StatsReported += OnPumpStatsReported;
|
||
}
|
||
|
||
/// <summary>Ends the watch. Called from StopStream on every path, so a stale planner can never
|
||
/// carry a spent budget into the next broadcast.</summary>
|
||
private void StopStepDownWatch()
|
||
{
|
||
_framePump.StatsReported -= OnPumpStatsReported;
|
||
RecordSessionRate();
|
||
_stepDownPlanner = null;
|
||
_encoderTierOverride = null;
|
||
_stepDownBusy = false;
|
||
_screenCaptureManager.ShedLoad(false); // never carry a shed cadence into the next show
|
||
}
|
||
|
||
/// <summary>
|
||
/// One finished session, filed as evidence for the rate-history policy (Models/RateHistory).
|
||
/// Best effort: a store failure must never be the reason a stop fails.
|
||
/// </summary>
|
||
private void RecordSessionRate()
|
||
{
|
||
if (_stepDownPlanner is not { } planner) return;
|
||
try
|
||
{
|
||
_layoutStore.AppendRateHistory(new SessionRateResult(
|
||
SelectedQuality.Display,
|
||
CurrentHealth.VideoEncoder ?? "unknown",
|
||
_sessionRatedMbps,
|
||
planner.Current.BitrateMbps,
|
||
planner.Policy.StepsUsed));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
AppLog.Write($"Rate history not recorded: {ex.Message}", AppLogMessageType.Warning, AppLogErrorClass.Class2);
|
||
}
|
||
}
|
||
|
||
/// <summary>Runs on the PUMP thread, so it must never block: the restart it may trigger stops the
|
||
/// very loop that raised this event. Decide here (pure and fast), act on the dispatcher.</summary>
|
||
private void OnPumpStatsReported(object? sender, FramePumpStats stats)
|
||
{
|
||
if (_stepDownPlanner is not { } planner) return;
|
||
|
||
var decision = planner.Observe(stats, DateTime.UtcNow);
|
||
if (!decision.Acted) return;
|
||
|
||
var dispatcher = Application.Current?.Dispatcher;
|
||
if (dispatcher == null) return;
|
||
_ = dispatcher.InvokeAsync(() => ApplyStepDownAsync(decision));
|
||
}
|
||
|
||
private async void ApplyStepDownAsync(StepDownDecision decision)
|
||
{
|
||
var planner = _stepDownPlanner;
|
||
if (planner == null || _stepDownBusy) return;
|
||
|
||
_stepDownBusy = true;
|
||
var landed = planner.Current;
|
||
try
|
||
{
|
||
if (decision.Action == StepAction.ShedCaptureCadence)
|
||
{
|
||
_screenCaptureManager.ShedLoad(true);
|
||
AppLog.Write($"Capture load shed: {decision.Why}");
|
||
return;
|
||
}
|
||
|
||
_encoderTierOverride = landed;
|
||
AppLog.Write($"Quality step-down: {decision.Why}");
|
||
await _framePump.ChangeEncoderAsync(
|
||
_audioMixer.StopLive,
|
||
() => _audioMixer.StartLive(InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName)));
|
||
|
||
// Show what is actually going out. The dropdown keeps the creator's choice — the policy
|
||
// does not get to rewrite their intent, only the encoder's output.
|
||
CurrentHealth.FPS = landed.Fps;
|
||
CurrentHealth.CurrentBitrate = landed.BitrateMbps;
|
||
ResolutionBadgeText = $"{landed.Width}×{landed.Height} @ {landed.Fps} fps · lowered";
|
||
OnPropertyChanged(nameof(CurrentHealth));
|
||
OnPropertyChanged(nameof(ResolutionBadgeText));
|
||
|
||
var copy = StepDownPlanner.Describe(decision, landed);
|
||
if (copy.Length > 0)
|
||
_notifications.Info("Quality lowered", copy);
|
||
|
||
AppLog.Write($"Quality step-down complete: {StepDownPlanner.ReportLine(DateTime.Now, decision)}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
AppLog.Write($"Quality step-down failed: {ex.Message}", AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||
}
|
||
finally
|
||
{
|
||
_stepDownBusy = false;
|
||
}
|
||
}
|
||
}
|