Files
LlamaCasty/ytLive.Tests/AudioClockTests.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

60 lines
2.4 KiB
C#

using System;
using Xunit;
using ytLive.Services.Audio;
namespace ytLive.Tests;
/// <summary>
/// The mix's sample count comes from the clock, not from a tick count — the fix for the 2026-11-12
/// drift (video 9.888 s against audio 10.112 s on a 9.9 s recording: the audio ran 2.1% long and the
/// sound slid ~200 ms behind the lips by the end of a ten-second clip).
/// </summary>
public class AudioClockTests
{
[Fact]
public void The_mix_follows_the_wall_clock_even_when_the_tick_runs_short()
{
const int rate = 48000;
const int maxChunk = rate / 5;
// The measured scheduler: iterations land every 9.8 ms, not the requested 10 ms.
var tickSeconds = 0.0098;
long produced = 0;
long naive = 0;
var ticks = 0;
var elapsed = 0.0;
while (elapsed < 10.0)
{
var frames = AudioClock.FramesDue(elapsed, produced, rate, maxChunk);
produced += frames;
naive += (int)Math.Round(rate * 0.010); // what the old "480 per tick" loop wrote
ticks++;
elapsed += tickSeconds;
}
var expected = (long)Math.Round(elapsed * rate);
// The old loop drifted by the tick-rate error — about 2% of ten seconds — and kept drifting.
Assert.True(naive - expected > rate * 0.01,
$"the fixed-chunk loop should reproduce the drift (naive {naive} vs expected {expected})");
// Asking the clock keeps the produced count on the clock, within one chunk of payment.
Assert.True(Math.Abs(produced - expected) <= maxChunk,
$"clock-driven count must track the clock (produced {produced} vs expected {expected})");
// And a stalled loop pays its debt off gradually rather than dumping it in one write.
Assert.Equal(maxChunk, AudioClock.FramesDue(5.0, 0, rate, maxChunk));
Assert.Equal(0, AudioClock.FramesDue(5.0, rate * 5, rate, maxChunk));
// Audio that never reached the encoder stays owed. Counting it would shorten the track by the
// start-up gap — the mirror image of the drift this clock exists to prevent (27 dropped start-up
// writes made the first clock-driven recording 226 ms short).
var produced2 = 0L;
for (var i = 0; i < 27; i++)
produced2 = AudioClock.Advance(produced2, 480, wrote: false);
Assert.Equal(0, produced2);
Assert.Equal(480, AudioClock.Advance(produced2, 480, wrote: true));
}
}