feat(quality): learn the starting rate from real sessions, on the creator's permission

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.
This commit is contained in:
2026-11-12 08:24:12 -08:00
parent f2cab5ca0f
commit b0f8263414
12 changed files with 407 additions and 1 deletions
+80
View File
@@ -1,3 +1,5 @@
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.ViewModels;
@@ -86,4 +88,82 @@ public partial class MainViewModel
OnPropertyChanged(nameof(RatedMbps));
OnPropertyChanged(nameof(RateHint));
}
// ─── Between-session suggestion (Models/RateHistory) ───
//
// Never applied on its own. The creator is asked once per materially different answer and may say
// "keep mine"; that refusal is remembered against the proposed value, so the same question is not
// asked again until the evidence actually moves.
private RateHistory.Proposal? _rateProposal;
public bool HasRateProposal => _rateProposal is not null;
public string? RateProposalText
{
get
{
if (_rateProposal is not { } proposal) return null;
var tier = SelectedQuality.Display;
return proposal.Raise
? $"Your last {RateHistory.Window} sessions on {tier} held {proposal.Mbps:0.#} Mbps. " +
$"Start the next stream there instead of {RatedMbps:0.#}?"
: $"Your last {RateHistory.Window} sessions on {tier} had to drop quality. " +
$"Start the next stream at {proposal.Mbps:0.#} Mbps instead of {RatedMbps:0.#}?";
}
}
public ICommand AcceptRateProposalCommand { get; private set; } = null!;
public ICommand DismissRateProposalCommand { get; private set; } = null!;
private void InitializeRateProposalCommands()
{
AcceptRateProposalCommand = new RelayCommand(_ =>
{
if (_rateProposal is not { } proposal) return;
RatedMbps = proposal.Mbps; // explicit consent is the only path that changes it
ClearRateProposal();
});
DismissRateProposalCommand = new RelayCommand(_ =>
{
if (_rateProposal is { } proposal)
_layoutStore.SaveDismissedRateProposal(SelectedQuality.Display, proposal.Mbps);
ClearRateProposal();
});
}
/// <summary>Recomputes the suggestion for the selected tier. Called at startup and whenever the tier
/// or the rate changes; silent unless the evidence warrants a change and the creator has not already
/// refused this exact number.</summary>
internal void RefreshRateProposal()
{
_rateProposal = null;
if (_layoutStore is not null)
{
var tier = SelectedQuality;
var history = _layoutStore.LoadRateHistory();
// Before this run's first stream the encoder is unknown; the last session on this tier is
// the honest guess, and evidence about a different encoder stays out either way.
var lastForTier = history.LastOrDefault(entry => entry.Tier == tier.Display);
var encoder = CurrentHealth.VideoEncoder
?? (string.IsNullOrEmpty(lastForTier.Encoder) ? null : lastForTier.Encoder);
var suggestion = RateHistory.Suggest(history, tier.Display, encoder, RatedMbps);
if (suggestion is { } candidate
&& _layoutStore.LoadDismissedRateProposal(tier.Display) != candidate.Mbps)
{
_rateProposal = candidate;
}
}
OnPropertyChanged(nameof(HasRateProposal));
OnPropertyChanged(nameof(RateProposalText));
}
private void ClearRateProposal()
{
_rateProposal = null;
OnPropertyChanged(nameof(HasRateProposal));
OnPropertyChanged(nameof(RateProposalText));
}
}
+25
View File
@@ -19,12 +19,14 @@ 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),
@@ -37,12 +39,35 @@ public partial class MainViewModel
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)
+1
View File
@@ -492,6 +492,7 @@ public partial class MainViewModel : ViewModelBase
ApplyStreamQuality(value);
OnPropertyChanged(nameof(RatedMbps));
OnPropertyChanged(nameof(RateHint));
RefreshRateProposal();
}
}
}
+4
View File
@@ -349,6 +349,10 @@ public partial class MainViewModel : ViewModelBase
_stickyAlertsUntilDismissed = _layoutStore.LoadStickyAlertsUntilDismissed();
_recordFolder = _layoutStore.LoadRecordFolder();
// What the last few sessions on this tier actually held, if it is worth asking about.
InitializeRateProposalCommands();
RefreshRateProposal();
// Load saved hotkey bindings (filled with defaults for any unbound actions).
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();