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.
60 lines
2.4 KiB
C#
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));
|
|
}
|
|
}
|