Files
LlamaCasty/Services/Audio/NamedPipeAudioWriter.cs
T
gramps 102cb96b28 fix(audio): only count audio that actually reached the encoder
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.
2026-11-12 11:39:20 -08:00

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\&lt;name&gt;</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();
}
}
}