diff --git a/Services/Encoder/FramePump.cs b/Services/Encoder/FramePump.cs
index 49127c9..37d3274 100644
--- a/Services/Encoder/FramePump.cs
+++ b/Services/Encoder/FramePump.cs
@@ -248,6 +248,21 @@ public sealed class FramePump : IDisposable
}
}
+ /// TASK 33: relaunch the pump and encoder at a new tier, over the same URL and audio pipe.
+ ///
+ /// Full stop + start rather than an encoder swap, deliberately: awaits
+ /// the loop and flushes the encoder, so no in-flight submit can land on a disposed process. A bare
+ /// swap races exactly that and would fault the loop — which ends the broadcast, the one thing a
+ /// quality step-down must never do. The cost is a short freeze, already accepted by the ladder.
+ ///
+ public async Task ChangeEncoderAsync(CancellationToken cancellationToken = default)
+ {
+ await StopAsync(cancellationToken);
+ if (cancellationToken.IsCancellationRequested) return false;
+ await StartAsync(cancellationToken);
+ return IsRunning;
+ }
+
public async Task StopAsync(CancellationToken cancellationToken = default)
{
IFfmpegEncoder? encoder;
diff --git a/TASKS.md b/TASKS.md
index 8510a1e..764923a 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); policy + `StepDownPlanner` + 16 tests done; remaining: VM subscribe + encoder restart + toast, 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, planner, VM watch, encoder relaunch and the toast are in (17 tests); remaining: shed our own capture cost before the fps step, and the session-report line | [`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/ViewModels/MainViewModel.StepDown.cs b/ViewModels/MainViewModel.StepDown.cs
new file mode 100644
index 0000000..7db4615
--- /dev/null
+++ b/ViewModels/MainViewModel.StepDown.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Windows;
+using ytLive.Helpers;
+using ytLive.Models;
+using ytLive.Services;
+using ytLive.Services.Encoder;
+
+namespace ytLive.ViewModels;
+
+///
+/// TASK 33, slice 3: the session's step-down watch — policy feed, one encoder relaunch, one toast.
+///
+/// Kept in its own file because it is the only place that both reads the pump's stats and acts on
+/// the session; the pure decision lives in , the sentence in
+/// .
+///
+public partial class MainViewModel
+{
+ private StepDownPlanner? _stepDownPlanner;
+ private (int Width, int Height, int Fps, double BitrateMbps)? _encoderTierOverride;
+ private bool _stepDownBusy;
+
+ /// Starts the watch for this broadcast. Called once per session, before the pump starts.
+ private void StartStepDownWatch()
+ {
+ var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
+ _encoderTierOverride = null;
+ _stepDownPlanner = new StepDownPlanner(
+ new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate),
+ _sessionStartedAtUtc ?? DateTime.UtcNow);
+ _framePump.StatsReported += OnPumpStatsReported;
+ }
+
+ /// Ends the watch. Called from StopStream on every path, so a stale planner can never
+ /// carry a spent budget into the next broadcast.
+ private void StopStepDownWatch()
+ {
+ _framePump.StatsReported -= OnPumpStatsReported;
+ _stepDownPlanner = null;
+ _encoderTierOverride = null;
+ _stepDownBusy = false;
+ }
+
+ /// 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)
+ {
+ if (_stepDownPlanner is not { } planner) return;
+
+ var decision = planner.Observe(stats, DateTime.UtcNow);
+ if (!decision.Acted) return;
+
+ var dispatcher = Application.Current?.Dispatcher;
+ if (dispatcher == null) return;
+ _ = dispatcher.InvokeAsync(() => ApplyStepDownAsync(decision));
+ }
+
+ private async void ApplyStepDownAsync(StepDownDecision decision)
+ {
+ var planner = _stepDownPlanner;
+ if (planner == null || _stepDownBusy) return;
+
+ _stepDownBusy = true;
+ var landed = planner.Current;
+ try
+ {
+ _encoderTierOverride = landed;
+ AppLog.Write($"Quality step-down: {decision.Why}");
+ await _framePump.ChangeEncoderAsync();
+
+ // Show what is actually going out. The dropdown keeps the creator's choice — the policy
+ // does not get to rewrite their intent, only the encoder's output.
+ CurrentHealth.FPS = landed.Fps;
+ CurrentHealth.CurrentBitrate = landed.BitrateMbps;
+ ResolutionBadgeText = $"{landed.Width}×{landed.Height} @ {landed.Fps} fps · lowered";
+ OnPropertyChanged(nameof(CurrentHealth));
+ OnPropertyChanged(nameof(ResolutionBadgeText));
+
+ var copy = StepDownPlanner.Describe(decision, landed);
+ if (copy.Length > 0)
+ _notifications.Info("Quality lowered", copy);
+
+ AppLog.Write($"Quality step-down complete: {StepDownPlanner.ReportLine(DateTime.Now, decision)}");
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Quality step-down failed: {ex.Message}", AppLogMessageType.Error, AppLogErrorClass.Class2);
+ }
+ finally
+ {
+ _stepDownBusy = false;
+ }
+ }
+}
diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs
index 7261c15..9f46bcb 100644
--- a/ViewModels/MainViewModel.Streaming.Operations.cs
+++ b/ViewModels/MainViewModel.Streaming.Operations.cs
@@ -163,6 +163,7 @@ public partial class MainViewModel : ViewModelBase
if (!TryStartAudioPipe()) return;
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
+ StartStepDownWatch();
var recordStarted = false;
if (alsoRecord)
@@ -405,6 +406,7 @@ public partial class MainViewModel : ViewModelBase
private async void StopStream()
{
CancelStreamRecovery(); // a stop wins over a relaunch in flight
+ StopStepDownWatch();
RemoveAttentionAlert("stream-recovery");
var wasLive = IsLive;
var wasRecording = IsRecording;
@@ -494,6 +496,11 @@ public partial class MainViewModel : ViewModelBase
{
var url = _rtmpUrlProvider();
var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
+ // TASK 33: while a session is live, a step-down owns the emitted tier even though the dropdown
+ // still shows what the creator picked. Their intent is not edited by the policy.
+ if (_encoderTierOverride is { } landed)
+ quality = new QualityOption($"{landed.Width}p{landed.Fps}", landed.Fps,
+ landed.BitrateMbps, landed.Width, landed.Height);
var compositor = BuildCompositorOptions();
return new EncoderOptions
{
diff --git a/ytLive.Tests/EncoderRestartTests.cs b/ytLive.Tests/EncoderRestartTests.cs
new file mode 100644
index 0000000..542c6dc
--- /dev/null
+++ b/ytLive.Tests/EncoderRestartTests.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+using ytLive.Services.Compositor;
+using ytLive.Services.Encoder;
+
+namespace ytLive.Tests;
+
+///
+/// TASK 33 slice 3 — the step-down's one dangerous act: relaunching the encoder mid-broadcast.
+///
+/// The restart is a full pump stop/start rather than an encoder swap, and this test is why: a swap
+/// leaves an in-flight submit pointing at a disposed process, which faults the loop and ends the
+/// broadcast. Here the pump must come back and produce a frame with a new encoder instance and
+/// the tier the policy landed on.
+///
+public sealed class EncoderRestartTests
+{
+ [Fact]
+ public async Task ChangeEncoder_RelauchesWithANewEncoderAtTheNewTier()
+ {
+ var tier = (Width: 1920, Height: 1080, Fps: 60, Bitrate: 8.0);
+ var started = new List();
+ var frames = new List();
+
+ var scene = new Scene { Name = "Live" };
+ scene.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true });
+ var black = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
+
+ using var pump = new FramePump(
+ sceneProvider: () => scene,
+ frameResolver: _ => black,
+ compositorOptions: () => new CompositorOptions
+ {
+ SourceRectX = 0, SourceRectY = 0,
+ SourceRectWidth = 1920, SourceRectHeight = 1080,
+ OutputWidth = 1920, OutputHeight = 1080,
+ },
+ encoderOptions: () => new EncoderOptions
+ {
+ RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
+ Width = tier.Width, Height = tier.Height, Fps = tier.Fps,
+ BitrateKbps = (int)(tier.Bitrate * 1000),
+ },
+ encoderFactory: () =>
+ {
+ var encoder = new CapturingEncoder();
+ encoder.Started += options => started.Add(options);
+ frames.Add(encoder);
+ return encoder;
+ },
+ log: _ => { },
+ pacingDelay: (_, _) => Task.Delay(5));
+
+ await pump.StartAsync();
+ Assert.True(await frames[0].FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5)), "first session never produced a frame");
+
+ // The policy lands on 1080p30 @ 6.4 Mbps; the VM has already written the override, so the
+ // options func now reports the new tier — which the restart must re-read.
+ tier = (1920, 1080, 30, 6.4);
+
+ Assert.True(await pump.ChangeEncoderAsync());
+ Assert.True(await frames[1].FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5)), "restarted session never produced a frame");
+
+ Assert.Equal(2, started.Count);
+ Assert.Equal(60, started[0].Fps);
+ Assert.Equal(30, started[1].Fps);
+ Assert.Equal(6400, started[1].BitrateKbps);
+ Assert.NotSame(frames[0], frames[1]);
+
+ await pump.StopAsync();
+ }
+
+ private sealed class CapturingEncoder : IFfmpegEncoder
+ {
+ public readonly TaskCompletionSource FirstFrame = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public event Action? Started;
+
+ public int DroppedFrames => 0;
+
+ public event EventHandler? HealthUpdated { add { } remove { } }
+ public event EventHandler? ProcessFailed { add { } remove { } }
+
+ public Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
+ {
+ Started?.Invoke(options);
+ return Task.CompletedTask;
+ }
+
+ public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
+ {
+ FirstFrame.TrySetResult(true);
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+
+ public void Dispose() { }
+ }
+}