fix(stepdown): restart the audio pipe around encoder changes
A quality step-down now closes the named audio pipe before stopping/flushing the pump, then opens a fresh server before restarting the pump. This prevents ffmpeg from waiting on the old audio input and avoids reconnecting to a broken pipe. EncoderRestartTests pins the audio-stop → video-stop → audio-start → video-start order. 616 tests green, clean build with 0 warnings.
This commit is contained in:
@@ -248,17 +248,24 @@ public sealed class FramePump : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>TASK 33: relaunch the pump and encoder at a new tier, over the same URL and audio pipe.
|
/// <summary>TASK 33: relaunch the pump and encoder at a new tier, over the same URL and named audio pipe.
|
||||||
///
|
///
|
||||||
/// <para>Full stop + start rather than an encoder swap, deliberately: <see cref="StopAsync"/> awaits
|
/// <para>Full stop + start rather than an encoder swap, deliberately: <see cref="StopAsync"/> awaits
|
||||||
/// the loop and flushes the encoder, so no in-flight submit can land on a disposed process. A bare
|
/// 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
|
/// 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.</para>
|
/// 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.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<bool> ChangeEncoderAsync(CancellationToken cancellationToken = default)
|
public async Task<bool> ChangeEncoderAsync(Action stopAudio, Action startAudio,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(stopAudio);
|
||||||
|
ArgumentNullException.ThrowIfNull(startAudio);
|
||||||
|
stopAudio();
|
||||||
await StopAsync(cancellationToken);
|
await StopAsync(cancellationToken);
|
||||||
if (cancellationToken.IsCancellationRequested) return false;
|
if (cancellationToken.IsCancellationRequested) return false;
|
||||||
|
startAudio();
|
||||||
await StartAsync(cancellationToken);
|
await StartAsync(cancellationToken);
|
||||||
return IsRunning;
|
return IsRunning;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ public partial class MainViewModel
|
|||||||
|
|
||||||
_encoderTierOverride = landed;
|
_encoderTierOverride = landed;
|
||||||
AppLog.Write($"Quality step-down: {decision.Why}");
|
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
|
// 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.
|
// does not get to rewrite their intent, only the encoder's output.
|
||||||
|
|||||||
+4
-2
@@ -248,8 +248,10 @@ devices, no timers).
|
|||||||
mixer at construction, read live each tick, so the volume sliders and mute buttons are stream-honest.
|
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` →
|
(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) —
|
`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
|
the reverse order stalls on pipe backpressure. Encoder step-down follows the same order: close the
|
||||||
pipe lifecycle. **Recordings are fragmented MP4 (TASK 32·4, 2026-11-05):** the record block carries
|
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
|
`-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
|
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.
|
or a killed process previously left the whole take unreadable. The FLV stream block never gets it.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public sealed class EncoderRestartTests
|
|||||||
var tier = (Width: 1920, Height: 1080, Fps: 60, Bitrate: 8.0);
|
var tier = (Width: 1920, Height: 1080, Fps: 60, Bitrate: 8.0);
|
||||||
var started = new List<EncoderOptions>();
|
var started = new List<EncoderOptions>();
|
||||||
var frames = new List<CapturingEncoder>();
|
var frames = new List<CapturingEncoder>();
|
||||||
|
var order = new List<string>();
|
||||||
|
|
||||||
var scene = new Scene { Name = "Live" };
|
var scene = new Scene { Name = "Live" };
|
||||||
scene.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true });
|
scene.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true });
|
||||||
@@ -49,7 +50,12 @@ public sealed class EncoderRestartTests
|
|||||||
encoderFactory: () =>
|
encoderFactory: () =>
|
||||||
{
|
{
|
||||||
var encoder = new CapturingEncoder();
|
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);
|
frames.Add(encoder);
|
||||||
return encoder;
|
return encoder;
|
||||||
},
|
},
|
||||||
@@ -63,7 +69,9 @@ public sealed class EncoderRestartTests
|
|||||||
// options func now reports the new tier — which the restart must re-read.
|
// options func now reports the new tier — which the restart must re-read.
|
||||||
tier = (1920, 1080, 30, 6.4);
|
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.True(await frames[1].FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5)), "restarted session never produced a frame");
|
||||||
|
|
||||||
Assert.Equal(2, started.Count);
|
Assert.Equal(2, started.Count);
|
||||||
@@ -71,6 +79,7 @@ public sealed class EncoderRestartTests
|
|||||||
Assert.Equal(30, started[1].Fps);
|
Assert.Equal(30, started[1].Fps);
|
||||||
Assert.Equal(6400, started[1].BitrateKbps);
|
Assert.Equal(6400, started[1].BitrateKbps);
|
||||||
Assert.NotSame(frames[0], frames[1]);
|
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();
|
await pump.StopAsync();
|
||||||
}
|
}
|
||||||
@@ -79,6 +88,7 @@ public sealed class EncoderRestartTests
|
|||||||
{
|
{
|
||||||
public readonly TaskCompletionSource<bool> FirstFrame = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
public readonly TaskCompletionSource<bool> FirstFrame = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
public event Action<EncoderOptions>? Started;
|
public event Action<EncoderOptions>? Started;
|
||||||
|
public Action? Stopped { get; set; }
|
||||||
|
|
||||||
public int DroppedFrames => 0;
|
public int DroppedFrames => 0;
|
||||||
|
|
||||||
@@ -97,7 +107,11 @@ public sealed class EncoderRestartTests
|
|||||||
return Task.CompletedTask;
|
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() { }
|
public void Dispose() { }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user