diff --git a/Services/Encoder/FramePump.cs b/Services/Encoder/FramePump.cs index fd86d47..49127c9 100644 --- a/Services/Encoder/FramePump.cs +++ b/Services/Encoder/FramePump.cs @@ -133,6 +133,10 @@ public sealed class FramePump : IDisposable /// Raised before the pump stops, so the session can relaunch the push instead of ending. public event EventHandler? EncoderFailed; + /// TASK 33: the same 5-second window the stats line reports, structured — so a policy can + /// tell machine starvation from encoder backpressure without parsing prose. + public event EventHandler? StatsReported; + public FramePump( Func sceneProvider, Func frameResolver, @@ -426,6 +430,21 @@ public sealed class FramePump : IDisposable _log?.Invoke($"FramePump parts ({statRenders} renders/5s): {parts}"); _timings.Reset(); + // Structured twin of the line above, raised before the counters reset. `dropped` is the + // ENCODER's count (ffmpeg blocked); a renderer that duplicates frames is already visible + // as render time + stalls, and FramesRendered is ambiguous by design (paste cache). + StatsReported?.Invoke(this, new FramePumpStats( + WindowSeconds: 5, + FramesSubmitted: statFrames, + FramesRendered: statRenders, + AvgRenderMs: statRenders > 0 ? renderTicks / freq * 1000 / statRenders : 0, + AvgSubmitMs: statFrames > 0 ? submitTicks / freq * 1000 / statFrames : 0, + AvgWaitMs: statRenders > 0 ? waitTicks / freq * 1000 / statRenders : 0, + WorstRenderMs: worstRender / freq * 1000, + WorstSubmitMs: worstSubmit / freq * 1000, + Dropped: dropped, + Stalls: stalls)); + worstRender = 0; worstSubmit = 0; renderTicks = submitTicks = resolveTicks = waitTicks = 0; diff --git a/Services/Encoder/FramePumpStats.cs b/Services/Encoder/FramePumpStats.cs new file mode 100644 index 0000000..4e3b974 --- /dev/null +++ b/Services/Encoder/FramePumpStats.cs @@ -0,0 +1,26 @@ +namespace ytLive.Services.Encoder; + +/// One 5-second window of pump and encoder evidence, structured. +/// +/// The stats line in the log is for humans; this is for the policy that has to decide whether +/// the machine or the connection is the problem. Note which counter is which: +/// +/// and are the MACHINE's evidence — the pump +/// could not produce a frame in time. +/// is the ENCODER's — frames submitted but not accepted downstream. It +/// rises when ffmpeg is blocked, which under CBR means the socket, not the renderer. +/// is deliberately NOT used for classification: a static scene +/// legitimately reuses the paste cache and renders few frames while completely healthy. +/// +/// +public readonly record struct FramePumpStats( + double WindowSeconds, + int FramesSubmitted, + int FramesRendered, + double AvgRenderMs, + double AvgSubmitMs, + double AvgWaitMs, + double WorstRenderMs, + double WorstSubmitMs, + int Dropped, + int Stalls); diff --git a/Services/StepDownPlanner.cs b/Services/StepDownPlanner.cs new file mode 100644 index 0000000..cbe59af --- /dev/null +++ b/Services/StepDownPlanner.cs @@ -0,0 +1,77 @@ +using System; +using ytLive.Models; +using ytLive.Services.Encoder; + +namespace ytLive.Services; + +/// +/// TASK 33 slice 2: the session's adaptor between the pump's evidence and the pure policy. One planner +/// per broadcast, because the policy is stateful (budget, cooldown, current tier) and a step must +/// survive across windows. +/// +/// It deliberately does not act. It answers "should this session change something, and why"; the +/// view model owns the restart, the toast and the session-report line. Keeping the effects out is what +/// makes the whole ladder testable without an encoder running. +/// +public sealed class StepDownPlanner +{ + private readonly StepDownPolicy _policy; + private readonly DateTime _startedUtc; + + public StepDownPlanner(StepDownPolicy policy, DateTime startedUtc) + { + _policy = policy; + _startedUtc = startedUtc; + } + + public StepDownPolicy Policy => _policy; + + /// Current tier after any applied steps, in the shape the encoder restart needs. + public (int Width, int Height, int Fps, double BitrateMbps) Current => + (_policy.Width, _policy.Height, _policy.Fps, _policy.BitrateMbps); + + public StepDownDecision Observe(in FramePumpStats stats, DateTime nowUtc) + { + var sample = new StepDownSample( + WindowSeconds: stats.WindowSeconds, + PumpStalls: stats.Stalls, + PumpDrops: 0, // the pump pads with the last frame; FramesRendered is ambiguous + EncoderDrops: stats.Dropped, + AvgRenderMs: stats.AvgRenderMs, + AvgSubmitWaitMs: stats.AvgSubmitMs); + + return _policy.Observe(sample, nowUtc - _startedUtc); + } + + /// The plain sentence the creator sees, chosen by cause. Never a mechanism string: "ffmpeg + /// exited", "dropped 8", an HTTP code are log findings, not an explanation of what happened to them. + /// + public static string Describe(in StepDownDecision decision, (int Width, int Height, int Fps, double BitrateMbps) landed) + => decision.Action switch + { + StepAction.LowerFrameRate => + "LlamaCasty couldn't keep up with your scene, so it lowered the frame rate to " + + $"{landed.Fps}. Your connection wasn't the problem.", + StepAction.LowerResolution => + "LlamaCasty couldn't keep up with your scene, so it lowered the picture to " + + $"{landed.Width}×{landed.Height}. Your connection wasn't the problem.", + StepAction.LowerBitrate => + $"Your upload couldn't hold the bitrate, so LlamaCasty lowered it to {landed.BitrateMbps:0.0} Mbps " + + "to stop the freezing. It will stay there for this stream.", + StepAction.RaiseFrameRate or StepAction.RaiseResolution or StepAction.RaiseBitrate => + "Conditions were clean for a while, so LlamaCasty restored one step of quality.", + _ => string.Empty, + }; + + /// One line for the session report, so a later question is answered from data. + public static string ReportLine(DateTime whenLocal, in StepDownDecision decision) => + decision.Action switch + { + StepAction.None => string.Empty, + StepAction.LowerFrameRate or StepAction.LowerResolution => + $"{whenLocal:HH:mm} quality stepped down — encoder ({decision.Why})", + StepAction.LowerBitrate => + $"{whenLocal:HH:mm} quality stepped down — upload ({decision.Why})", + _ => $"{whenLocal:HH:mm} quality restored ({decision.Why})", + }; +} diff --git a/TASKS.md b/TASKS.md index e992112..8510a1e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -49,7 +49,7 @@ cited first), then 35 / 37 / 40·A/C/D / 55·2–3. TASK 32 is complete, Back on | 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) | | 3·20 | ✅ | Session report — **derived from `superChatEvents.list` on demand, not persisted** (YouTube is the authority; no ledger). Per-currency totals, money off by default (done 2026-11-05) | [`task-03`](TASKS/task-03-capture-pipeline.md) | | 32 | ✅ | Stream resilience: fragmented MP4, blip retry, poll-driven RECONNECTING (no countdown — YouTube's cutoff is not a constant), ingest pre-flight, one-click Back on air (`BackOnAirTests`) | [`task-32`](TASKS/task-32-stream-resilience.md) | -| 33 | 🔶 | Auto step-down — **`StepDownPolicy` + 12 unit tests done 2026-11-09** (two ladders, budgets, floors, cooldown, ambiguity, recovery behind a flag); remaining: pump/health wiring, encoder restart, toast + session line, capture-cost shed | [`task-33`](TASKS/task-33-bandwidth-step-down.md) | +| 33 | 🔶 | Auto step-down — **`StepDownPolicy` + 12 unit tests done 2026-11-09** (two ladders, budgets, floors, cooldown, ambiguity, recovery behind a flag); policy + `StepDownPlanner` + 16 tests done; remaining: VM subscribe + encoder restart + toast, capture-cost shed | [`task-33`](TASKS/task-33-bandwidth-step-down.md) | | 34 | ☐ | Scheduled streams (Text-drawer version) | [`task-34`](TASKS/task-34-scheduled-streams.md) | | 35 | ☐ | Scene-linked audio | [`task-35`](TASKS/task-35-scene-linked-audio.md) | | 37 | ☐ | Defaults vs current layout split (build-id); enables one-click revert to defaults | [`task-37`](TASKS/task-37-defaults-current-split.md) | diff --git a/ytLive.Tests/StepDownPlannerTests.cs b/ytLive.Tests/StepDownPlannerTests.cs new file mode 100644 index 0000000..6bbcb85 --- /dev/null +++ b/ytLive.Tests/StepDownPlannerTests.cs @@ -0,0 +1,102 @@ +using System; +using Xunit; +using ytLive.Models; +using ytLive.Services; +using ytLive.Services.Encoder; + +namespace ytLive.Tests; + +/// +/// TASK 33 slice 2 — the adaptor between the pump's structured stats and the pure policy, plus the two +/// sentences a creator ever sees. The copy is pinned here because "own the mistake" is a product rule, +/// not a wording preference: a mechanism string in a toast is the bug. +/// +public sealed class StepDownPlannerTests +{ + private static FramePumpStats Strained(double renderMs = 45, int stalls = 12, int dropped = 0) => + new(WindowSeconds: 5, FramesSubmitted: 150, FramesRendered: 150, + AvgRenderMs: renderMs, AvgSubmitMs: 1, AvgWaitMs: 14, + WorstRenderMs: renderMs * 2, WorstSubmitMs: 2, Dropped: dropped, Stalls: stalls); + + [Fact] + public void MachineEvidenceAfterThreeWindows_LowersTheFrameRateAndSaysWhoseFaultItWas() + { + var now = new DateTime(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc); + var planner = new StepDownPlanner( + new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0), now); + + StepDownDecision decision = default; + for (var i = 0; i < 4 && !decision.Acted; i++) + { + now = now.AddSeconds(5); + decision = planner.Observe(Strained(), now); + } + + Assert.True(decision.Acted); + Assert.Equal(StepAction.LowerFrameRate, decision.Action); + + var landed = planner.Current; + Assert.Equal(30, landed.Fps); + + var copy = StepDownPlanner.Describe(decision, landed); + Assert.Contains("LlamaCasty couldn't keep up", copy); + Assert.Contains("connection wasn't the problem", copy); + Assert.DoesNotContain("dropped", copy, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void EncoderDrops_LowerTheBitrateAndTheCopyBlamesTheUpload() + { + var now = new DateTime(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc); + var planner = new StepDownPlanner( + new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0), now); + + StepDownDecision decision = default; + for (var i = 0; i < 4 && !decision.Acted; i++) + { + now = now.AddSeconds(5); + decision = planner.Observe(Strained(renderMs: 1, stalls: 0, dropped: 30), now); + } + + Assert.Equal(StepAction.LowerBitrate, decision.Action); + + var landed = planner.Current; + Assert.Equal(60, landed.Fps); + Assert.Equal(1920, landed.Width); + Assert.True(landed.BitrateMbps < 8.0); + + var copy = StepDownPlanner.Describe(decision, landed); + Assert.Contains("upload couldn't hold", copy); + Assert.DoesNotContain("couldn't keep up", copy); + } + + [Fact] + public void TheSessionReportLine_NamesTheCauseInOnePlace() + { + var when = new DateTime(2026, 11, 9, 12, 4, 0); + var machine = new StepDownDecision(StepCause.Machine, StepAction.LowerFrameRate, "frame rate 60 → 30"); + var upload = new StepDownDecision(StepCause.Upload, StepAction.LowerBitrate, "8.0 → 7.2 Mbps on 10% loss"); + + Assert.Equal("12:04 quality stepped down — encoder (frame rate 60 → 30)", + StepDownPlanner.ReportLine(when, machine)); + Assert.Equal("12:04 quality stepped down — upload (8.0 → 7.2 Mbps on 10% loss)", + StepDownPlanner.ReportLine(when, upload)); + Assert.Equal(string.Empty, + StepDownPlanner.ReportLine(when, new StepDownDecision(StepCause.None, StepAction.None, null))); + } + + [Fact] + public void ShortWindows_AreIgnored_SoAStartupBurstCannotTriggerAStep() + { + var now = new DateTime(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc); + var planner = new StepDownPlanner( + new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0), now); + + var burst = Strained() with { WindowSeconds = 1 }; + for (var i = 0; i < 20; i++) + { + now = now.AddSeconds(1); + Assert.False(planner.Observe(burst, now).Acted); + } + } +}