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.
93 lines
4.3 KiB
C#
93 lines
4.3 KiB
C#
namespace ytLive.Models;
|
|
|
|
/// <summary>One finished broadcast, as evidence about a tier's rate.</summary>
|
|
/// <param name="Tier">The quality tier's display name, e.g. "1080p60".</param>
|
|
/// <param name="Encoder">The probed encoder, e.g. "h264_nvenc" — history against one encoder is not
|
|
/// evidence about another, so a GPU or driver change cannot import stale conclusions.</param>
|
|
/// <param name="RatedMbps">The rate the session started at (the creator's or the tier default).</param>
|
|
/// <param name="SettledMbps">The rate the session ended at after any step-down.</param>
|
|
/// <param name="Steps">How many quality steps the session spent. Zero means the rate held.</param>
|
|
public readonly record struct SessionRateResult(
|
|
string Tier, string Encoder, double RatedMbps, double SettledMbps, int Steps)
|
|
{
|
|
public bool Stepped => Steps > 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// What the app has learned about a connection, as pure policy — the only thing allowed to suggest a
|
|
/// different starting rate, and it never applies one itself.
|
|
///
|
|
/// <para><b>High-water mark</b> is the highest rate that <i>finished</i> a session with zero steps, not
|
|
/// the highest number ever seen: one easy session (a menu screen, a quiet game) must not set a target
|
|
/// the machine cannot hold, which would make every later session look like a regression.</para>
|
|
///
|
|
/// <para><b>Asymmetric on purpose.</b> Raising needs a 10% improvement across five clean sessions, so
|
|
/// fixing nothing is slow; lowering needs only repeated step-downs, because fixing damage should not
|
|
/// wait. A creator may always override either way — see <c>MainViewModel.RatedMbps</c>.</para>
|
|
/// </summary>
|
|
public static class RateHistory
|
|
{
|
|
/// <summary>Sessions considered. Below this there is no evidence, only noise.</summary>
|
|
public const int Window = 5;
|
|
|
|
/// <summary>How far above the current rate an upward proposal must sit to be worth asking.</summary>
|
|
public const double RaiseThreshold = 0.10;
|
|
|
|
/// <summary>Step-downs in the window that make a downward proposal worth asking.</summary>
|
|
public const int LowerAfterSteps = 3;
|
|
|
|
/// <summary>A suggested starting rate, and which way it moves.</summary>
|
|
public readonly record struct Proposal(double Mbps, bool Raise);
|
|
|
|
/// <summary>
|
|
/// The suggestion for one tier, or null when the evidence does not warrant one. <paramref name="encoder"/>
|
|
/// null considers every encoder; callers pass the current one so a hardware change starts clean.
|
|
/// </summary>
|
|
public static Proposal? Suggest(
|
|
IEnumerable<SessionRateResult> history, string tier, string? encoder, double currentMbps)
|
|
{
|
|
var window = history
|
|
.Where(entry => entry.Tier == tier && (encoder is null || entry.Encoder == encoder))
|
|
.TakeLast(Window)
|
|
.ToList();
|
|
|
|
if (window.Count < Window) return null;
|
|
|
|
if (window.Count(entry => entry.Stepped) >= LowerAfterSteps)
|
|
return new Proposal(Math.Round(Median(window.Select(entry => entry.SettledMbps)), 1), Raise: false);
|
|
|
|
var highWater = window.Max(entry => entry.Stepped ? 0 : entry.RatedMbps);
|
|
return highWater >= currentMbps * (1 + RaiseThreshold)
|
|
? new Proposal(Math.Round(highWater, 0), Raise: true)
|
|
: null;
|
|
}
|
|
|
|
private static double Median(IEnumerable<double> values)
|
|
{
|
|
var sorted = values.OrderBy(value => value).ToArray();
|
|
if (sorted.Length == 0) return 0;
|
|
var middle = sorted.Length / 2;
|
|
return sorted.Length % 2 == 1
|
|
? sorted[middle]
|
|
: (sorted[middle - 1] + sorted[middle]) / 2;
|
|
}
|
|
|
|
// ─── Storage form: one line per session, unit-separated fields ───
|
|
|
|
private const char Field = '\u001F';
|
|
|
|
public static string Format(SessionRateResult entry)
|
|
=> string.Join(Field, entry.Tier, entry.Encoder,
|
|
entry.RatedMbps.ToString("0.#"), entry.SettledMbps.ToString("0.#"), entry.Steps);
|
|
|
|
public static SessionRateResult? Parse(string? line)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line)) return null;
|
|
var parts = line.Split(Field);
|
|
if (parts.Length != 5 || !double.TryParse(parts[2], out var rated)
|
|
|| !double.TryParse(parts[3], out var settled) || !int.TryParse(parts[4], out var steps))
|
|
return null;
|
|
return new SessionRateResult(parts[0], parts[1], rated, settled, steps);
|
|
}
|
|
}
|