feat(33): structured pump stats + the session planner and its plain-words copy
Slice 2. 588 green, 0 warnings. - `FramePumpStats` and `FramePump.StatsReported`: the 5 s window the log line already describes, as data. `Dropped` is the ENCODER's counter (frames submitted, not accepted); `Stalls`/`AvgRenderMs` are the pump's. `FramesRendered` is deliberately not used for classification — a static scene reuses the paste cache and renders few frames while completely healthy, so it would read as starvation. - `StepDownPlanner`: one per broadcast, owns the stateful policy, converts stats → sample, exposes the landed tier for the restart. It never acts — the VM owns the effects, which is what keeps the ladder testable without an encoder. - `Describe` / `ReportLine`: the sentences a creator sees. Machine cause says LlamaCasty couldn't keep up and the connection was not the problem; upload cause blames the upload and promises no further thrash. A test asserts the machine copy never leaks "dropped" — mechanism strings stay in the log. Remaining for the task: VM subscription, the encoder restart at the landed tier, the toast, and shedding our own capture cost before stepping fps.
This commit is contained in:
@@ -133,6 +133,10 @@ public sealed class FramePump : IDisposable
|
||||
/// Raised before the pump stops, so the session can relaunch the push instead of ending.</summary>
|
||||
public event EventHandler<string>? EncoderFailed;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public event EventHandler<FramePumpStats>? StatsReported;
|
||||
|
||||
public FramePump(
|
||||
Func<Scene?> sceneProvider,
|
||||
Func<SceneElement, VideoFrame?> 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;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>One 5-second window of pump and encoder evidence, structured.
|
||||
///
|
||||
/// <para>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:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="Stalls"/> and <see cref="AvgRenderMs"/> are the MACHINE's evidence — the pump
|
||||
/// could not produce a frame in time.</item>
|
||||
/// <item><see cref="Dropped"/> 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.</item>
|
||||
/// <item><see cref="FramesRendered"/> is deliberately NOT used for classification: a static scene
|
||||
/// legitimately reuses the paste cache and renders few frames while completely healthy.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
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);
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Current tier after any applied steps, in the shape the encoder restart needs.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>One line for the session report, so a later question is answered from data.</summary>
|
||||
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})",
|
||||
};
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user