[client] audio: fix underrun clock recovery

Recover stable local playback in the device clock domain and restart
rate control from the measured feed-forward clock ratio.

Ignore catch-up packet sizing until the normal source cadence returns.
This commit is contained in:
Geoffrey McRae
2026-08-22 05:50:25 +10:00
parent 5b106015de
commit d8e7966087
3 changed files with 160 additions and 56 deletions

View File

@@ -170,13 +170,15 @@ typedef struct
uint64_t mediaPosition; uint64_t mediaPosition;
int64_t lastArrivalTime; int64_t lastArrivalTime;
double arrivalJitterSec; double arrivalJitterSec;
double sourcePhaseBaselineSec; double sourcePhaseBaselineSec;
double sourcePhaseReserveSec; double sourcePhaseReserveSec;
double sourcePacketDurationSec; double sourcePacketDurationSec;
bool sourcePhaseBaselineValid; unsigned int sourcePacketPacedCount;
bool mediaClockValid; bool sourcePhaseBaselineValid;
bool mediaPositionValid; bool sourcePacketRecovering;
bool mediaClockFromSource; bool mediaClockValid;
bool mediaPositionValid;
bool mediaClockFromSource;
PlaybackRateSample rateSamples[PLAYBACK_RATE_MAX_SAMPLES]; PlaybackRateSample rateSamples[PLAYBACK_RATE_MAX_SAMPLES];
unsigned int rateSampleStart; unsigned int rateSampleStart;
@@ -1422,25 +1424,27 @@ static bool playbackStart(const LG_AudioFormat * format,
audio.playback.sourceData.deviceTimingSequence = 0; audio.playback.sourceData.deviceTimingSequence = 0;
audio.playback.sourceData.deviceDiscontinuity = 0; audio.playback.sourceData.deviceDiscontinuity = 0;
playbackDeviceClockAcquireReset(&audio.playback.sourceData); playbackDeviceClockAcquireReset(&audio.playback.sourceData);
audio.playback.sourceData.offsetError = 0.0; audio.playback.sourceData.offsetError = 0.0;
audio.playback.sourceData.offsetErrorIntegral = 0.0; audio.playback.sourceData.offsetErrorIntegral = 0.0;
audio.playback.sourceData.ratioIntegral = 0.0; audio.playback.sourceData.ratioIntegral = 0.0;
audio.playback.sourceData.lastRatio = 1.0; audio.playback.sourceData.lastRatio = 1.0;
audio.playback.sourceData.lastClockRatio = 1.0; audio.playback.sourceData.lastClockRatio = 1.0;
audio.playback.sourceData.nextFeedbackTime = 0; audio.playback.sourceData.nextFeedbackTime = 0;
audio.playback.sourceData.nextGraphTime = 0; audio.playback.sourceData.nextGraphTime = 0;
audio.playback.sourceData.bufferOverrunPending = false; audio.playback.sourceData.bufferOverrunPending = false;
audio.playback.sourceData.backlogTrimArmed = true; audio.playback.sourceData.backlogTrimArmed = true;
audio.playback.sourceData.bufferOverruns = 0; audio.playback.sourceData.bufferOverruns = 0;
audio.playback.sourceData.nextLogTime = audio.playback.sourceData.nextLogTime =
nanotime() + INT64_C(5000000000); nanotime() + INT64_C(5000000000);
audio.playback.sourceData.arrivalJitterSec = 0.0; audio.playback.sourceData.arrivalJitterSec = 0.0;
audio.playback.sourceData.sourcePhaseBaselineSec = 0.0; audio.playback.sourceData.sourcePhaseBaselineSec = 0.0;
audio.playback.sourceData.sourcePhaseReserveSec = 0.0; audio.playback.sourceData.sourcePhaseReserveSec = 0.0;
audio.playback.sourceData.sourcePacketDurationSec = 0.0; audio.playback.sourceData.sourcePacketDurationSec = 0.0;
audio.playback.sourceData.sourcePacketPacedCount = 0;
audio.playback.sourceData.sourcePhaseBaselineValid = false; audio.playback.sourceData.sourcePhaseBaselineValid = false;
audio.playback.sourceData.deviceClock.valid = false; audio.playback.sourceData.sourcePacketRecovering = false;
audio.playback.sourceData.outputClock.valid = false; audio.playback.sourceData.deviceClock.valid = false;
audio.playback.sourceData.outputClock.valid = false;
playbackPrepareMediaClock(&audio.playback.sourceData, sourceClock); playbackPrepareMediaClock(&audio.playback.sourceData, sourceClock);
atomic_store_explicit( atomic_store_explicit(
@@ -2130,6 +2134,26 @@ static int playbackSlewBuffer(
return advanced; return advanced;
} }
static bool playbackUseLowWaterRecovery(bool providerRateControl,
bool bufferUnderrun, const PlaybackSourceData * sourceData)
{
return providerRateControl ||
(bufferUnderrun && (!sourceData->deviceClock.valid ||
!sourceData->deviceClockStable));
}
static void playbackResetRateControl(PlaybackSourceData * sourceData,
bool providerRateControl)
{
sourceData->offsetError = 0.0;
sourceData->offsetErrorIntegral = 0.0;
sourceData->ratioIntegral = 0.0;
sourceData->lastRatio = providerRateControl ?
1.0 : sourceData->lastClockRatio;
if (providerRateControl)
sourceData->nextFeedbackTime = 0;
}
static PlaybackDataResult playbackData(const void * data, size_t frameCount, static PlaybackDataResult playbackData(const void * data, size_t frameCount,
const LG_AudioClock * sourceClock, int64_t arrivalTime) const LG_AudioClock * sourceClock, int64_t arrivalTime)
{ {
@@ -2191,17 +2215,18 @@ static PlaybackDataResult playbackData(const void * data, size_t frameCount,
inputFrames = sourceData->framesIn; inputFrames = sourceData->framesIn;
} }
const bool providerRateControl = const bool providerRateControl =
audio.playback.rateControl == PLAYBACK_RATE_PROVIDER; audio.playback.rateControl == PLAYBACK_RATE_PROVIDER;
/* An unbounded ring represents an underrun by advancing the reader beyond /* An unbounded ring represents an underrun by advancing the reader beyond
* the writer. Do not carry that logical debt into resumed playback: bounded * the writer. Do not carry that logical debt into resumed playback: bounded
* rate correction would otherwise discard fresh audio for many seconds. */ * rate correction would otherwise discard fresh audio for many seconds. */
const bool bufferUnderrun = const bool bufferUnderrun =
STREAM_ACTIVE(playbackGetState()) && STREAM_ACTIVE(playbackGetState()) &&
ringbuffer_getCount(audio.playback.buffer) < 0; ringbuffer_getCount(audio.playback.buffer) < 0;
bool discontinuity = bool discontinuity =
bufferUnderrun || (sourceClock && sourceClock->discontinuity); bufferUnderrun || (sourceClock && sourceClock->discontinuity);
const int64_t packetTime = const int64_t previousArrivalTime = sourceData->lastArrivalTime;
const int64_t packetTime =
playbackMapMediaTime(sourceData, sourceClock, frames, playbackMapMediaTime(sourceData, sourceClock, frames,
audio.playback.sampleRate, arrivalTime, &discontinuity); audio.playback.sampleRate, arrivalTime, &discontinuity);
if (sourceData->bufferOverrunPending) if (sourceData->bufferOverrunPending)
@@ -2212,7 +2237,7 @@ static PlaybackDataResult playbackData(const void * data, size_t frameCount,
sourceData->lastArrivalTime = arrivalTime; sourceData->lastArrivalTime = arrivalTime;
const bool sourceRateWasValid = const bool sourceRateWasValid =
sourceData->sourceRateValid; sourceData->sourceRateValid;
const int64_t sourceRateTimeMs = providerRateControl ? const int64_t sourceRateTimeMs = providerRateControl ?
(arrivalTime - sourceData->mediaLocalOrigin) / INT64_C(1000000) : (arrivalTime - sourceData->mediaLocalOrigin) / INT64_C(1000000) :
@@ -2247,13 +2272,50 @@ static PlaybackDataResult playbackData(const void * data, size_t frameCount,
* source media clock and must not become buffer reserve. Positive * source media clock and must not become buffer reserve. Positive
* deviation means the latency model temporarily overstates how much audio * deviation means the latency model temporarily overstates how much audio
* remains in the ring. */ * remains in the ring. */
const double sourcePhaseSec = const double sourcePhaseSec =
sourceData->sourceClock.phaseResidualSec; sourceData->sourceClock.phaseResidualSec;
const double packetSec = const double packetSec =
frames * nominalFrameSec; frames * nominalFrameSec;
const double decayedPacketDurationSec =
sourceData->sourcePacketDurationSec *
exp(-packetSec / PLAYBACK_PHASE_RESERVE_DECAY_SEC);
const bool cadenceReset = discontinuity ||
state == STREAM_STATE_KEEP_ALIVE ||
state == STREAM_STATE_RESUMING;
if (cadenceReset && sourceData->sourcePacketDurationSec > 0.0)
{
sourceData->sourcePacketRecovering = true;
sourceData->sourcePacketPacedCount = 0;
}
else if (sourceData->sourcePacketRecovering)
{
if (packetSec <= decayedPacketDurationSec + nominalFrameSec)
{
sourceData->sourcePacketRecovering = false;
sourceData->sourcePacketPacedCount = 0;
}
else
{
const bool paced = previousArrivalTime != INT64_MIN &&
arrivalTime > previousArrivalTime &&
(arrivalTime - previousArrivalTime) * 2.0e-9 >= packetSec;
sourceData->sourcePacketPacedCount = paced ?
sourceData->sourcePacketPacedCount + 1 : 0;
if (sourceData->sourcePacketPacedCount >= 2)
{
sourceData->sourcePacketRecovering = false;
sourceData->sourcePacketPacedCount = 0;
}
}
}
/* Catch-up packets describe audio accumulated during a gap, not the future
* delivery cadence. Resume when the prior cadence returns or a larger
* cadence is confirmed by consecutive plausibly paced packets. */
sourceData->sourcePacketDurationSec = sourceData->sourcePacketDurationSec =
max(packetSec, sourceData->sourcePacketDurationSec * !sourceData->sourcePacketRecovering ?
exp(-packetSec / PLAYBACK_PHASE_RESERVE_DECAY_SEC)); max(packetSec, decayedPacketDurationSec) :
decayedPacketDurationSec;
if (!sourceData->sourcePhaseBaselineValid || if (!sourceData->sourcePhaseBaselineValid ||
sourceData->sourceClock.updates == 1) sourceData->sourceClock.updates == 1)
@@ -2361,7 +2423,7 @@ static PlaybackDataResult playbackData(const void * data, size_t frameCount,
} }
} }
const int maxPeriodFrames = const int maxPeriodFrames =
max(audio.playback.deviceMaxPeriodFrames, sourceData->devPeriodFrames); max(audio.playback.deviceMaxPeriodFrames, sourceData->devPeriodFrames);
const double backlogGuardFrames = const double backlogGuardFrames =
max((double)maxPeriodFrames, max((double)maxPeriodFrames,
@@ -2369,34 +2431,35 @@ static PlaybackDataResult playbackData(const void * data, size_t frameCount,
/* The device period, delivery jitter, packet phase, and resampler delay /* The device period, delivery jitter, packet phase, and resampler delay
* define the minimum viable latency. Provider feedback directly controls * define the minimum viable latency. Provider feedback directly controls
* the source rate, so latencyOffset only applies to local rate control. */ * the source rate, so latencyOffset only applies to local rate control. */
const double latencyOffsetFrames = providerRateControl ? 0.0 : const double latencyOffsetFrames = providerRateControl ? 0.0 :
max(g_params.audioLatencyOffset, 0) * max(g_params.audioLatencyOffset, 0) *
audio.playback.sampleRate / 1000.0; audio.playback.sampleRate / 1000.0;
const double arrivalJitterFrames = const double arrivalJitterFrames =
sourceData->arrivalJitterSec * audio.playback.sampleRate; sourceData->arrivalJitterSec * audio.playback.sampleRate;
const double arrivalReserveFrames = const double arrivalReserveFrames =
arrivalJitterFrames + 0.001 * audio.playback.sampleRate; arrivalJitterFrames + 0.001 * audio.playback.sampleRate;
const double minimumLowWaterReserveFrames = const double minimumLowWaterReserveFrames =
maxPeriodFrames * 0.1 + arrivalReserveFrames; maxPeriodFrames * 0.1 + arrivalReserveFrames;
const double minimumLowWaterFrames = const double minimumLowWaterFrames =
maxPeriodFrames + minimumLowWaterReserveFrames; maxPeriodFrames + minimumLowWaterReserveFrames;
const double targetLowWaterFrames = const double targetLowWaterFrames =
minimumLowWaterFrames + latencyOffsetFrames; minimumLowWaterFrames + latencyOffsetFrames;
const double minimumBufferFrames = const double minimumBufferFrames =
minimumLowWaterFrames + sourceReserveFrames; minimumLowWaterFrames + sourceReserveFrames;
const double targetBufferFrames = const double targetBufferFrames =
minimumBufferFrames + latencyOffsetFrames; minimumBufferFrames + latencyOffsetFrames;
const double resamplerDelayFrames = const double resamplerDelayFrames =
audio.playback.rateControl == PLAYBACK_RATE_SOFTWARE ? audio.playback.rateControl == PLAYBACK_RATE_SOFTWARE ?
PLAYBACK_RESAMPLER_DELAY_FRAMES : 0.0; PLAYBACK_RESAMPLER_DELAY_FRAMES : 0.0;
const double minimumLatencyFrames = const double minimumLatencyFrames =
minimumBufferFrames + resamplerDelayFrames; minimumBufferFrames + resamplerDelayFrames;
const double targetLatencyFrames = const double targetLatencyFrames =
minimumLatencyFrames + latencyOffsetFrames; minimumLatencyFrames + latencyOffsetFrames;
double devPosition = DBL_MIN; double devPosition = DBL_MIN;
state = playbackGetState(); state = playbackGetState();
if ((providerRateControl || bufferUnderrun) && if (playbackUseLowWaterRecovery(providerRateControl,
bufferUnderrun, sourceData) &&
(discontinuity || (discontinuity ||
state == STREAM_STATE_KEEP_ALIVE || state == STREAM_STATE_KEEP_ALIVE ||
state == STREAM_STATE_RESUMING)) state == STREAM_STATE_RESUMING))
@@ -2409,14 +2472,7 @@ static PlaybackDataResult playbackData(const void * data, size_t frameCount,
sourceData->outputPosition += actualSlew; sourceData->outputPosition += actualSlew;
curPosition += actualSlew; curPosition += actualSlew;
sourceData->offsetError = 0.0; playbackResetRateControl(sourceData, providerRateControl);
sourceData->offsetErrorIntegral = 0.0;
sourceData->ratioIntegral = 0.0;
if (providerRateControl && bufferUnderrun)
{
sourceData->lastRatio = 1.0;
sourceData->nextFeedbackTime = 0;
}
if (state == STREAM_STATE_KEEP_ALIVE || if (state == STREAM_STATE_KEEP_ALIVE ||
state == STREAM_STATE_RESUMING) state == STREAM_STATE_RESUMING)
atomic_compare_exchange_strong_explicit( atomic_compare_exchange_strong_explicit(
@@ -2437,9 +2493,7 @@ static PlaybackDataResult playbackData(const void * data, size_t frameCount,
sourceData->outputPosition += actualSlew; sourceData->outputPosition += actualSlew;
curPosition += actualSlew; curPosition += actualSlew;
sourceData->offsetError = 0.0; playbackResetRateControl(sourceData, providerRateControl);
sourceData->offsetErrorIntegral = 0.0;
sourceData->ratioIntegral = 0.0;
if (state == STREAM_STATE_KEEP_ALIVE || if (state == STREAM_STATE_KEEP_ALIVE ||
state == STREAM_STATE_RESUMING) state == STREAM_STATE_RESUMING)
atomic_compare_exchange_strong_explicit( atomic_compare_exchange_strong_explicit(

View File

@@ -820,6 +820,7 @@ if(ENABLE_AUDIO)
provider provider
playback-retry playback-retry
jitter jitter
recovery
consent consent
quiesce quiesce
) )

View File

@@ -654,6 +654,54 @@ static void testPlaybackJitter(void)
CHECK(source.arrivalJitterSec < 0.011); CHECK(source.arrivalJitterSec < 0.011);
} }
static void testPlaybackRecovery(void)
{
reset();
startAudio();
const LG_AudioFormat format = makeFormat(LG_AUDIO_FMT_S16_LE);
CHECK(playbackStart(&format, NULL, false, false));
CHECK(audio.playback.rateControl == PLAYBACK_RATE_BACKEND);
playbackSetState(STREAM_STATE_RUN);
PlaybackSourceData * source = &audio.playback.sourceData;
const int64_t arrival = nanotime();
playbackClockReset(&source->deviceClock,
arrival, 0.0, 1.0 / format.sampleRate);
source->deviceClockStable = true;
source->outputPosition = 0;
source->lastRatio = 0.999;
source->lastClockRatio = 1.0002;
source->sourcePacketDurationSec = 0.010;
playbackPublishDeviceTiming(16, arrival, 0, 0.0, 0);
CHECK(ringbuffer_consume(audio.playback.buffer, NULL, 16) == 16);
CHECK(ringbuffer_getCount(audio.playback.buffer) == -16);
uint8_t frames[1200 * 2 * 2] = { 0 };
CHECK(playbackData(frames, 1200, NULL, arrival) ==
PLAYBACK_DATA_PROCESSED);
CHECK(source->sourcePacketDurationSec < 0.010);
CHECK(source->sourcePacketDurationSec > 0.009);
CHECK(source->sourcePacketRecovering);
CHECK(source->outputPosition == 1770);
CHECK(ringbuffer_getCount(audio.playback.buffer) == 1754);
CHECK(fabs(atomic_load(&audio.playback.backendResampleRatio) -
source->lastClockRatio) < 0.000001);
CHECK(playbackData(frames, 1200, NULL, arrival + 1000) ==
PLAYBACK_DATA_PROCESSED);
CHECK(source->sourcePacketDurationSec < 0.010);
CHECK(source->sourcePacketRecovering);
CHECK(playbackData(frames, 480, NULL,
arrival + INT64_C(10000000)) == PLAYBACK_DATA_PROCESSED);
CHECK(!source->sourcePacketRecovering);
CHECK(fabs(source->sourcePacketDurationSec - 0.010) < 0.000001);
stopAudio();
}
static void testConsent(void) static void testConsent(void)
{ {
reset(); reset();
@@ -781,6 +829,7 @@ static const struct Test tests[] =
{ "provider" , testProvider }, { "provider" , testProvider },
{ "playback-retry", testPlaybackRetry }, { "playback-retry", testPlaybackRetry },
{ "jitter" , testPlaybackJitter }, { "jitter" , testPlaybackJitter },
{ "recovery" , testPlaybackRecovery },
{ "consent" , testConsent }, { "consent" , testConsent },
{ "quiesce" , testQuiesce }, { "quiesce" , testQuiesce },
}; };