Second measurement of the same recording family (ty-20261112-1133-701-0000-2.mp4): wall clock (audio start -> audio stop) 6.477 s video: 386 frames 6.455 s correct audio: 294 packets 6.252 s 226 ms SHORT Same magnitude as the original bug, opposite sign — the clock fixed the tick-rate error and exposed the next one underneath. Audio started 49 ms before the pump, ffmpeg connected ~80 ms after, and every chunk written in that window was dropped (dropped writes 27). The loop still counted those frames as produced, so the track lost exactly that much time. Now: the clock does not start until the pipe is connected, and AudioClock.Advance counts a frame only when its write reached the pipe. A dropped write stays owed and is paid off on the next tick, so a start-up gap costs content (silence, which there was anyway) instead of length. Two measurements, two different signs, one lesson: the audio track's LENGTH has to be the clock's, and anything that removes samples without removing time — a fast tick, a dropped write — shows up as drift no static offset can fix. 674 tests (one notification-timing flake under full-suite load; passes alone), 0 warnings.
122 lines
3.5 KiB
C#
122 lines
3.5 KiB
C#
using System.IO.Pipes;
|
|
using System.Runtime.InteropServices;
|
|
|
|
namespace ytLive.Services.Audio;
|
|
|
|
/// <summary>
|
|
/// The mixer's sink for the live loop: interleaved stereo PCM float at 48 kHz
|
|
/// written to a named pipe, byte-identical to what ffmpeg expects from
|
|
/// <c>-f f32le -ar 48000 -ac 2 -i \\.\pipe\<name></c>. The encoder side is
|
|
/// handled by FfmpegArgs/EncoderOptions; this side just owns the server end.
|
|
/// </summary>
|
|
public interface IAudioPipeWriter : IDisposable
|
|
{
|
|
/// <summary>Creates the named pipe server and waits (async) for the client.</summary>
|
|
void Start(string pipeName);
|
|
|
|
/// <summary>True once ffmpeg has connected and the pipe is writable.</summary>
|
|
bool IsConnected { get; }
|
|
|
|
/// <summary>Count of <see cref="WriteAsync"/> calls that wrote nothing because
|
|
/// the pipe wasn't connected yet or was already closed. Part of the silent-
|
|
/// recording diagnosis: nonzero means audio was dropped before ffmpeg ever
|
|
/// connected to the pipe.</summary>
|
|
long DroppedWrites { get; }
|
|
|
|
/// <summary>Writes interleaved float samples. Returns false when nothing was written because the
|
|
/// client has not connected or the pipe is closed — the caller must not count that audio as
|
|
/// produced, or a start-up gap silently shortens the track.</summary>
|
|
Task<bool> WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>Closes the server end — ffmpeg sees EOF and ends the audio input.</summary>
|
|
void Stop();
|
|
}
|
|
|
|
public sealed class NamedPipeAudioWriter : IAudioPipeWriter
|
|
{
|
|
private readonly object _sync = new();
|
|
private NamedPipeServerStream? _pipe;
|
|
|
|
/// <inheritdoc/>
|
|
public long DroppedWrites { get; private set; }
|
|
|
|
public void Start(string pipeName)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
if (_pipe != null)
|
|
throw new InvalidOperationException("Already started.");
|
|
_pipe = new NamedPipeServerStream(
|
|
pipeName,
|
|
PipeDirection.Out,
|
|
1,
|
|
PipeTransmissionMode.Byte,
|
|
PipeOptions.Asynchronous);
|
|
}
|
|
_ = Task.Run(WaitForConnectionAsync);
|
|
}
|
|
|
|
public bool IsConnected
|
|
{
|
|
get
|
|
{
|
|
lock (_sync)
|
|
{
|
|
return _pipe is { IsConnected: true };
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task<bool> WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default)
|
|
{
|
|
NamedPipeServerStream? pipe;
|
|
lock (_sync)
|
|
{
|
|
pipe = _pipe;
|
|
}
|
|
if (pipe == null || !pipe.IsConnected || samples.IsEmpty)
|
|
{
|
|
if (pipe?.IsConnected == false || pipe == null)
|
|
DroppedWrites++;
|
|
return false;
|
|
}
|
|
|
|
var bytes = MemoryMarshal.AsBytes(samples.Span).ToArray();
|
|
await pipe.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
|
|
public void Stop()
|
|
{
|
|
NamedPipeServerStream? pipe;
|
|
lock (_sync)
|
|
{
|
|
pipe = _pipe;
|
|
_pipe = null;
|
|
}
|
|
pipe?.Dispose();
|
|
}
|
|
|
|
public void Dispose() => Stop();
|
|
|
|
private async Task WaitForConnectionAsync()
|
|
{
|
|
NamedPipeServerStream? pipe;
|
|
lock (_sync)
|
|
{
|
|
pipe = _pipe;
|
|
}
|
|
if (pipe == null)
|
|
return;
|
|
|
|
try
|
|
{
|
|
await pipe.WaitForConnectionAsync().ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
Stop();
|
|
}
|
|
}
|
|
}
|