diff --git a/MainWindow.xaml b/MainWindow.xaml
index 3b469b9..486bed0 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -159,6 +159,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Models/RateHistory.cs b/Models/RateHistory.cs
new file mode 100644
index 0000000..cbf5d93
--- /dev/null
+++ b/Models/RateHistory.cs
@@ -0,0 +1,92 @@
+namespace ytLive.Models;
+
+/// One finished broadcast, as evidence about a tier's rate.
+/// The quality tier's display name, e.g. "1080p60".
+/// 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.
+/// The rate the session started at (the creator's or the tier default).
+/// The rate the session ended at after any step-down.
+/// How many quality steps the session spent. Zero means the rate held.
+public readonly record struct SessionRateResult(
+ string Tier, string Encoder, double RatedMbps, double SettledMbps, int Steps)
+{
+ public bool Stepped => Steps > 0;
+}
+
+///
+/// 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.
+///
+/// High-water mark is the highest rate that finished 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.
+///
+/// Asymmetric on purpose. 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 MainViewModel.RatedMbps.
+///
+public static class RateHistory
+{
+ /// Sessions considered. Below this there is no evidence, only noise.
+ public const int Window = 5;
+
+ /// How far above the current rate an upward proposal must sit to be worth asking.
+ public const double RaiseThreshold = 0.10;
+
+ /// Step-downs in the window that make a downward proposal worth asking.
+ public const int LowerAfterSteps = 3;
+
+ /// A suggested starting rate, and which way it moves.
+ public readonly record struct Proposal(double Mbps, bool Raise);
+
+ ///
+ /// The suggestion for one tier, or null when the evidence does not warrant one.
+ /// null considers every encoder; callers pass the current one so a hardware change starts clean.
+ ///
+ public static Proposal? Suggest(
+ IEnumerable 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 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);
+ }
+}
diff --git a/Models/StreamConfig.cs b/Models/StreamConfig.cs
index e0ddd6d..0a8c3c9 100644
--- a/Models/StreamConfig.cs
+++ b/Models/StreamConfig.cs
@@ -29,6 +29,11 @@ public class StreamHealth
public TimeSpan StreamDuration { get; set; }
public string? LastError { get; set; }
+ /// The encoder the probe actually chose ("h264_nvenc", "libopenh264", …). Filled once the
+ /// encoder starts, and the key the rate history is filed under — evidence about NVENC says nothing
+ /// about QSV on another machine.
+ public string? VideoEncoder { get; set; }
+
// YouTube-specific (liveStreams.status)
public string? HealthStatus { get; set; } // "good", "ok", "bad", "noData"
public string? HealthMessage { get; set; }
diff --git a/Services/Encoder/FfmpegEncoder.cs b/Services/Encoder/FfmpegEncoder.cs
index 2c8ba1e..2df2af3 100644
--- a/Services/Encoder/FfmpegEncoder.cs
+++ b/Services/Encoder/FfmpegEncoder.cs
@@ -82,6 +82,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
var encoder = options.VideoEncoder ?? await ProbeEncoderAsync(ffmpegPath, cancellationToken).ConfigureAwait(false);
+ _health.VideoEncoder = encoder;
var args = FfmpegArgs.Build(options, encoder);
var startInfo = new ProcessStartInfo
diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs
index 8899bcd..35a9b92 100644
--- a/Services/LayoutStore.Settings.cs
+++ b/Services/LayoutStore.Settings.cs
@@ -2,7 +2,6 @@ using Microsoft.Data.Sqlite;
using Notification.Core;
using ytLive.Services.Compositor;
using ytLive.Models;
-
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
@@ -33,6 +32,38 @@ public partial class LayoutStore : IDisposable
public double? LoadRatedMbps(string tierDisplay)
=> double.TryParse(GetSetting($"Video.Rate.{tierDisplay}"), out var mbps) && mbps > 0 ? mbps : null;
+ /// Finished sessions, oldest first — the evidence behind any rate suggestion. Capped so the
+ /// row never grows without bound: 40 sessions is several weeks of streaming for anyone.
+ public List LoadRateHistory()
+ {
+ var raw = GetSetting("Video.RateHistory");
+ if (string.IsNullOrWhiteSpace(raw)) return new List();
+ return raw.Split('\n')
+ .Select(RateHistory.Parse)
+ .Where(entry => entry is not null)
+ .Select(entry => entry!.Value)
+ .ToList();
+ }
+
+ public void AppendRateHistory(SessionRateResult entry)
+ {
+ var history = LoadRateHistory();
+ history.Add(entry);
+ if (history.Count > RateHistoryCap)
+ history.RemoveRange(0, history.Count - RateHistoryCap);
+ UpsertSetting("Video.RateHistory", string.Join('\n', history.Select(RateHistory.Format)));
+ }
+
+ private const int RateHistoryCap = 40;
+
+ /// The proposed rate the creator already refused for this tier, or zero for none. Stored as the
+ /// number rather than a flag, so a materially different suggestion may still be asked.
+ public double LoadDismissedRateProposal(string tierDisplay)
+ => double.TryParse(GetSetting($"Video.RateProposalDismissed.{tierDisplay}"), out var mbps) ? mbps : 0;
+
+ public void SaveDismissedRateProposal(string tierDisplay, double mbps)
+ => UpsertSetting($"Video.RateProposalDismissed.{tierDisplay}", mbps.ToString("0.#"));
+
public void SaveRatedMbps(string tierDisplay, double? mbps)
{
if (mbps is not > 0)
diff --git a/ViewModels/MainViewModel.Rate.cs b/ViewModels/MainViewModel.Rate.cs
index cd4b510..fbca674 100644
--- a/ViewModels/MainViewModel.Rate.cs
+++ b/ViewModels/MainViewModel.Rate.cs
@@ -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();
+ });
+ }
+
+ /// 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.
+ 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));
+ }
}
diff --git a/ViewModels/MainViewModel.StepDown.cs b/ViewModels/MainViewModel.StepDown.cs
index 43d7179..58b4085 100644
--- a/ViewModels/MainViewModel.StepDown.cs
+++ b/ViewModels/MainViewModel.StepDown.cs
@@ -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;
/// Starts the watch for this broadcast. Called once per session, before the pump starts.
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
}
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+
/// 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.
private void OnPumpStatsReported(object? sender, FramePumpStats stats)
diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs
index 9273ab9..008392e 100644
--- a/ViewModels/MainViewModel.Streaming.cs
+++ b/ViewModels/MainViewModel.Streaming.cs
@@ -492,6 +492,7 @@ public partial class MainViewModel : ViewModelBase
ApplyStreamQuality(value);
OnPropertyChanged(nameof(RatedMbps));
OnPropertyChanged(nameof(RateHint));
+ RefreshRateProposal();
}
}
}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index e86e97d..2cf2aff 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -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();
diff --git a/docs/ai/output.md b/docs/ai/output.md
index b8cbf20..29381ed 100644
--- a/docs/ai/output.md
+++ b/docs/ai/output.md
@@ -98,6 +98,15 @@ triad is **capped VBR, not CBR**: measured 2026-11-12 on the bundled BtbN build,
itself when `bitrate == maxrate` (`libavcodec/qsvenc.c`, `select_rc_mode`), so the triad is its whole ask;
OpenH264's `-rc_mode bitrate` changed nothing measurable here and is not passed. YouTube's encoder spec
requires CBR. Never assume `-maxrate` equals CBR — it does not.
+
+**The starting rate is learned, never imposed** (`Models/RateHistory`, `ViewModels/MainViewModel.Rate.cs`).
+Each finished broadcast is filed locally — tier, probed encoder, rated, settled, steps spent — and the
+only thing that may change a starting rate is a proposal the creator clicks. A **high-water mark** is the
+highest rate that *finished* a session with zero steps, never the highest number seen, so one easy session
+cannot set a target the machine cannot hold. Raising needs 10% above the current rate across five clean
+sessions of the same tier **and encoder** (history against NVENC is not evidence about QSV); lowering needs
+three step-downs in five, because fixing damage should not wait. "Keep mine" is remembered against that
+specific number, so the same question is not asked again until the evidence moves. Nothing is uploaded.
**Record output (TASK 18):** `FfmpegArgs.Build` emits one self-contained block per output, each its own
`-map 0:v -map 1:a` + codec tags (`AddVideoTags` helper). Stream block = `-f flv `; record block =
`-f mp4 `. `EncoderOptions.StreamEnabled`/`RecordEnabled`/`RecordPath` gate each block, so
diff --git a/ytLive.Tests/RateHistoryTests.cs b/ytLive.Tests/RateHistoryTests.cs
new file mode 100644
index 0000000..5814e77
--- /dev/null
+++ b/ytLive.Tests/RateHistoryTests.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+
+namespace ytLive.Tests;
+
+///
+/// The rate the app suggests next session, from real sessions — and the two rules that keep it from
+/// becoming a nuisance: five sessions of evidence per tier and encoder, a 10% gap before asking for
+/// more, and a lower bar for backing off.
+///
+public class RateHistoryTests
+{
+ private static SessionRateResult Session(
+ double rated, double settled = -1, int steps = 0, string tier = "1080p60", string encoder = "h264_nvenc")
+ => new(tier, encoder, rated, settled < 0 ? rated : settled, steps);
+
+ [Fact]
+ public void Suggestions_need_five_sessions_of_the_same_tier_and_encoder_and_persist_with_them()
+ {
+ // Four sessions is not evidence.
+ Assert.Null(RateHistory.Suggest(
+ Enumerable.Range(0, 4).Select(_ => Session(15)), "1080p60", "h264_nvenc", 12));
+
+ // Five clean sessions holding 15 against a 12 target: worth asking, and the ask is 10% up.
+ var raised = RateHistory.Suggest(
+ Enumerable.Range(0, 5).Select(_ => Session(15)), "1080p60", "h264_nvenc", 12);
+ Assert.NotNull(raised);
+ Assert.True(raised!.Value.Raise);
+ Assert.Equal(15, raised.Value.Mbps);
+
+ // The same evidence means nothing below the threshold (12 → 12.6 is under 10%).
+ Assert.Null(RateHistory.Suggest(
+ Enumerable.Range(0, 5).Select(_ => Session(13)), "1080p60", "h264_nvenc", 12));
+
+ // A different encoder is a different machine: no suggestion.
+ Assert.Null(RateHistory.Suggest(
+ Enumerable.Range(0, 5).Select(_ => Session(15)), "1080p60", "h264_qsv", 12));
+
+ // Three step-downs out of five asks to back off to the settled median, faster than it asks to
+ // climb — fixing damage should not wait for a 10% gap.
+ var lowered = RateHistory.Suggest(
+ new[]
+ {
+ Session(12, 9.6, 1), Session(12, 8.0, 2), Session(12), Session(12, 7.2, 1), Session(12),
+ },
+ "1080p60", "h264_nvenc", 12);
+ Assert.NotNull(lowered);
+ Assert.False(lowered!.Value.Raise);
+ Assert.Equal(9.6, lowered.Value.Mbps); // median of 9.6, 8.0, 12, 7.2, 12
+
+ // And the storage form survives a round trip through the settings row, which is where the
+ // evidence actually lives between runs.
+ var db = Path.Combine(Path.GetTempPath(), $"llamacasty-rate-history-{Guid.NewGuid():N}.db");
+ using (var store = new LayoutStore(db))
+ {
+ foreach (var entry in Enumerable.Range(0, 5).Select(_ => Session(15)))
+ store.AppendRateHistory(entry);
+
+ var reloaded = store.LoadRateHistory();
+ Assert.Equal(5, reloaded.Count);
+ Assert.Equal(Session(15), reloaded[0]);
+ Assert.Equal(15, RateHistory.Suggest(reloaded, "1080p60", "h264_nvenc", 12)!.Value.Mbps);
+ }
+ try { File.Delete(db); } catch (IOException) { }
+ }
+}
diff --git a/ytLive.Tests/RatedBitrateTests.cs b/ytLive.Tests/RatedBitrateTests.cs
index 1323a2d..88a5454 100644
--- a/ytLive.Tests/RatedBitrateTests.cs
+++ b/ytLive.Tests/RatedBitrateTests.cs
@@ -3,6 +3,7 @@ using System.IO;
using System.Linq;
using Xunit;
using ytLive.Models;
+using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
@@ -58,4 +59,65 @@ public class RatedBitrateTests
try { File.Delete(tempDb); } catch (IOException) { }
}
}
+
+ ///
+ /// The suggestion the creator is asked about before a stream, and the refusal that stops it coming
+ /// back: raising is proposed only from five clean sessions above the 10% gap, applied only on a
+ /// click, and "keep mine" suppresses that exact number for the tier.
+ ///
+ [Fact]
+ public void A_suggested_rate_is_offered_between_sessions_and_only_a_click_applies_it()
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-rate-proposal-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ try
+ {
+ using (var seed = new LayoutStore(tempDb))
+ {
+ for (var i = 0; i < 5; i++)
+ seed.AppendRateHistory(new SessionRateResult("1080p60", "h264_nvenc", 15.0, 15.0, 0));
+ Assert.Equal(5, seed.LoadRateHistory().Count);
+ }
+
+ var vm = new MainViewModel();
+ vm.SelectedQuality = vm.QualityOptions.Single(tier => tier.Display == "1080p60");
+
+ var historyCount = new LayoutStore(tempDb).LoadRateHistory().Count;
+
+ // Offered, not applied: the rate is still the shipped default until the creator agrees.
+ Assert.True(vm.HasRateProposal, $"history={historyCount}");
+ Assert.Contains("15", vm.RateProposalText);
+ Assert.Equal(12.0, vm.RatedMbps);
+
+ vm.AcceptRateProposalCommand.Execute(null);
+ Assert.Equal(15.0, vm.RatedMbps);
+ Assert.False(vm.HasRateProposal);
+
+ // Honest evidence now says 15 is what this line holds, so the same question stops being
+ // asked: 15 is no longer 10% above the rate in use.
+ var second = new MainViewModel();
+ second.SelectedQuality = second.QualityOptions.Single(tier => tier.Display == "1080p60");
+ Assert.False(second.HasRateProposal);
+
+ // Back to 12 by hand: the evidence qualifies again, and this time the creator says no.
+ second.RatedMbps = 12;
+ second.SelectedQuality = second.QualityOptions.Single(tier => tier.Display == "1080p30");
+ second.SelectedQuality = second.QualityOptions.Single(tier => tier.Display == "1080p60");
+ Assert.True(second.HasRateProposal);
+
+ second.DismissRateProposalCommand.Execute(null);
+ Assert.False(second.HasRateProposal);
+ Assert.Equal(12.0, second.RatedMbps);
+
+ // Refusal is remembered against that number, so a fresh run stays quiet.
+ var third = new MainViewModel();
+ third.SelectedQuality = third.QualityOptions.Single(tier => tier.Display == "1080p60");
+ Assert.False(third.HasRateProposal);
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ try { File.Delete(tempDb); } catch (IOException) { }
+ }
+ }
}