Measured on the creator's recording (ty-20261112-1118-423-0000-2.mp4), which drifts out of sync over ten seconds: wall clock (pump start -> audio stop) 9.875 s video: 594 frames, uniform 1/60 9.888 s correct audio: 485,376 samples @ 48 kHz 10.112 s +2.1%, 224 ms long So it was not VFR and not wall-clock PTS: the video is CFR and matches the clock (the only PTS anomaly in the file is one 38 ms gap from two lost frames). The audio track is what was too long, and a static offset cannot fix a rate error — which is why the opening looked aligned (the 175 ms mic delay cancels the camera's constant pipeline latency) and the end did not. Cause: LiveLoopAsync wrote a fixed 480 frames per iteration and trusted that each iteration was 10 ms. Under a raised timer resolution they land near 9.8 ms — 102 ticks per second where 100 are expected, i.e. 102% of an audio second per wall second. FFmpeg stamps audio by sample count, so every second of show pushed the sound ~2% later against a correct picture. Video never had the bug because the pump paces off a stopwatch. Fix: Services/Audio/AudioClock asks the clock how many frames are owed (expected - produced, clamped to one 200 ms chunk) and the loop produces exactly that, padding on underrun. A stalled loop pays its debt over the following ticks instead of dumping one huge write. FillAndMix takes a frame count, and the master limiter/meter take spans, so a partial chunk is processed without touching stale buffer tail. 674 tests (one log-rotation flake under full-suite load while the app itself is running and writing startup.log; passes alone), 0 warnings. Ear check pending.
51 lines
1.9 KiB
C#
51 lines
1.9 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));
|
|
}
|
|
}
|