diff --git a/Services/Encoder/FramePump.cs b/Services/Encoder/FramePump.cs index 37d3274..d7f7c59 100644 --- a/Services/Encoder/FramePump.cs +++ b/Services/Encoder/FramePump.cs @@ -248,17 +248,24 @@ public sealed class FramePump : IDisposable } } - /// TASK 33: relaunch the pump and encoder at a new tier, over the same URL and audio pipe. + /// TASK 33: relaunch the pump and encoder at a new tier, over the same URL and named 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. + /// quality step-down must never do. The audio pipe must be closed before the pump stops and freshly + /// opened before it starts again; otherwise ffmpeg waits on the still-open audio input, then the new + /// process tries to attach to a broken server. The cost is a short freeze, already accepted by the ladder. /// - public async Task ChangeEncoderAsync(CancellationToken cancellationToken = default) + public async Task ChangeEncoderAsync(Action stopAudio, Action startAudio, + CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(stopAudio); + ArgumentNullException.ThrowIfNull(startAudio); + stopAudio(); await StopAsync(cancellationToken); if (cancellationToken.IsCancellationRequested) return false; + startAudio(); await StartAsync(cancellationToken); return IsRunning; } diff --git a/ViewModels/MainViewModel.StepDown.cs b/ViewModels/MainViewModel.StepDown.cs index 1592c1b..921c33a 100644 --- a/ViewModels/MainViewModel.StepDown.cs +++ b/ViewModels/MainViewModel.StepDown.cs @@ -74,7 +74,9 @@ public partial class MainViewModel _encoderTierOverride = landed; AppLog.Write($"Quality step-down: {decision.Why}"); - await _framePump.ChangeEncoderAsync(); + await _framePump.ChangeEncoderAsync( + _audioMixer.StopLive, + () => _audioMixer.StartLive(InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName))); // 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. diff --git a/docs/ai/output.md b/docs/ai/output.md index 3f39872..d8ddb2e 100644 --- a/docs/ai/output.md +++ b/docs/ai/output.md @@ -248,8 +248,10 @@ devices, no timers). mixer at construction, read live each tick, so the volume sliders and mute buttons are stream-honest. (Before this wiring the seams defaulted to unity and the controls were decorative.) `StopStream` → `StopLive()` (closes the pipe → ffmpeg audio EOF) **BEFORE** stopping the frame pump (video EOF) — - the reverse order stalls on pipe backpressure. `FfmpegEncoder` itself is untouched; the VM owns the - pipe lifecycle. **Recordings are fragmented MP4 (TASK 32·4, 2026-11-05):** the record block carries + the reverse order stalls on pipe backpressure. Encoder step-down follows the same order: close the + audio pipe, flush/stop the pump, create a fresh server on the same name, then restart the pump so ffmpeg + connects to a live server. `FfmpegEncoder` itself is untouched; the VM owns the pipe lifecycle. + **Recordings are fragmented MP4 (TASK 32·4, 2026-11-05):** the record block carries `-movflags +frag_keyframe+empty_moov+default_base_moof`, so the index is written first and a playable fragment is flushed at every keyframe. A plain MP4 puts its index at the end, which is why a power cut or a killed process previously left the whole take unreadable. The FLV stream block never gets it. diff --git a/ytLive.Tests/EncoderRestartTests.cs b/ytLive.Tests/EncoderRestartTests.cs index 542c6dc..d1f3d54 100644 --- a/ytLive.Tests/EncoderRestartTests.cs +++ b/ytLive.Tests/EncoderRestartTests.cs @@ -26,6 +26,7 @@ public sealed class EncoderRestartTests var tier = (Width: 1920, Height: 1080, Fps: 60, Bitrate: 8.0); var started = new List(); var frames = new List(); + var order = new List(); var scene = new Scene { Name = "Live" }; scene.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true }); @@ -49,7 +50,12 @@ public sealed class EncoderRestartTests encoderFactory: () => { var encoder = new CapturingEncoder(); - encoder.Started += options => started.Add(options); + encoder.Started += options => + { + started.Add(options); + order.Add($"encoder-start-{options.Fps}"); + }; + encoder.Stopped = () => order.Add("encoder-stop"); frames.Add(encoder); return encoder; }, @@ -63,7 +69,9 @@ public sealed class EncoderRestartTests // 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 pump.ChangeEncoderAsync( + stopAudio: () => order.Add("audio-stop"), + startAudio: () => order.Add("audio-start"))); Assert.True(await frames[1].FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5)), "restarted session never produced a frame"); Assert.Equal(2, started.Count); @@ -71,6 +79,7 @@ public sealed class EncoderRestartTests Assert.Equal(30, started[1].Fps); Assert.Equal(6400, started[1].BitrateKbps); Assert.NotSame(frames[0], frames[1]); + Assert.Equal(new[] { "encoder-start-60", "audio-stop", "encoder-stop", "audio-start", "encoder-start-30" }, order); await pump.StopAsync(); } @@ -79,6 +88,7 @@ public sealed class EncoderRestartTests { public readonly TaskCompletionSource FirstFrame = new(TaskCreationOptions.RunContinuationsAsynchronously); public event Action? Started; + public Action? Stopped { get; set; } public int DroppedFrames => 0; @@ -97,7 +107,11 @@ public sealed class EncoderRestartTests return Task.CompletedTask; } - public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken = default) + { + Stopped?.Invoke(); + return Task.CompletedTask; + } public void Dispose() { } }