[client] audio: redesign playback synchronization

Model playback latency from the device period, arrival jitter, source
packet phase, and resampler delay. Treat audio:latencyOffset as an
explicit addition to this minimum and align the first device pull to
the next packet deadline. This starts playback near its steady-state
target without unnecessary prefill or startup underruns. Use a
512-frame default period as a practical low-latency baseline.

Replace the startup clock hold with a one-sided proportional
acquisition controller, then hand off to source/device rate
feed-forward and a slow phase loop. Calibrate the logical device
timeline at handoff, discard correction that opposes the current
error, and slew-limit rate changes. This prevents startup drain,
integral wind-up, overshoot, and long convergence while preserving
clock-drift compensation.

Allow audio backends to expose a real-time resampler and use
PipeWire's adaptive resampler when version 1.4 or newer supports it.
Retain libsamplerate as the fallback and add audio:resampler to select
the implementation. Wait for PipeWire stream setup to complete and
propagate rate-control failures cleanly.

Track PipeWire input-consumption and output-equivalent clocks
separately. The input clock measures ring latency while the output
clock drives feed-forward using the ratio that governed each request.
This removes delayed self-feedback that made adaptive resampling
oscillate between the correction limits.

Reduce audio:debug output to useful latency, clock, jitter, and xrun
values, and scale the playback graph from the startup estimate. Update
the option names and documentation for the new latency model.
This commit is contained in:
Geoffrey McRae
2026-07-29 04:55:16 +10:00
parent 814f1797fe
commit 737614bc69
7 changed files with 642 additions and 286 deletions

View File

@@ -60,6 +60,12 @@ struct PipeWire
atomic_bool latencyUpdateRequested; atomic_bool latencyUpdateRequested;
atomic_uint bufferErrors; atomic_uint bufferErrors;
atomic_uint timingErrors; atomic_uint timingErrors;
#if PW_CHECK_VERSION(1, 4, 0)
double appliedResampleRatio;
atomic_int resampleError;
#endif
enum pw_stream_state connectionState;
bool resamplerEnabled;
int channels; int channels;
int sampleRate; int sampleRate;
@@ -105,6 +111,10 @@ static void pipewire_reportPlaybackErrors(void)
&pw.playback.bufferErrors, 0, memory_order_relaxed); &pw.playback.bufferErrors, 0, memory_order_relaxed);
const unsigned int timingErrors = atomic_exchange_explicit( const unsigned int timingErrors = atomic_exchange_explicit(
&pw.playback.timingErrors, 0, memory_order_relaxed); &pw.playback.timingErrors, 0, memory_order_relaxed);
#if PW_CHECK_VERSION(1, 4, 0)
const int resampleError = atomic_exchange_explicit(
&pw.playback.resampleError, 0, memory_order_relaxed);
#endif
if (bufferErrors) if (bufferErrors)
DEBUG_WARN("PipeWire playback ran out of buffers %u time(s)", DEBUG_WARN("PipeWire playback ran out of buffers %u time(s)",
@@ -112,6 +122,11 @@ static void pipewire_reportPlaybackErrors(void)
if (timingErrors) if (timingErrors)
DEBUG_WARN("PipeWire playback timing query failed %u time(s)", DEBUG_WARN("PipeWire playback timing query failed %u time(s)",
timingErrors); timingErrors);
#if PW_CHECK_VERSION(1, 4, 0)
if (resampleError)
DEBUG_WARN("PipeWire resampler rate update failed: %s",
spa_strerror(resampleError));
#endif
} }
static void pipewire_reportRecordErrors(void) static void pipewire_reportRecordErrors(void)
@@ -175,6 +190,50 @@ static inline void pipewire_updatePlaybackLatency(void)
time.now + latencyNs, memory_order_release); time.now + latencyNs, memory_order_release);
} }
#if PW_CHECK_VERSION(1, 4, 0)
static bool pipewire_playbackSetRate(double ratio)
{
if (ratio <= 0.0 ||
!pw.playback.resamplerEnabled)
return false;
if (ratio == pw.playback.appliedResampleRatio)
return true;
const int result = pw_stream_set_rate(pw.playback.stream, ratio);
if (result < 0)
{
int expected = 0;
atomic_compare_exchange_strong_explicit(
&pw.playback.resampleError, &expected, result,
memory_order_relaxed, memory_order_relaxed);
return false;
}
pw.playback.appliedResampleRatio = ratio;
return true;
}
static bool pipewire_configurePlaybackResampler(bool enable)
{
pw.playback.resamplerEnabled = false;
pw.playback.appliedResampleRatio = 0.0;
if (!enable)
{
pw_stream_set_rate(pw.playback.stream, 0.0);
return false;
}
const int result = pw_stream_set_rate(pw.playback.stream, 1.0);
if (result < 0)
return false;
pw.playback.resamplerEnabled = true;
pw.playback.appliedResampleRatio = 1.0;
return true;
}
#endif
static void pipewire_onPlaybackIoChanged(void * userdata, uint32_t id, static void pipewire_onPlaybackIoChanged(void * userdata, uint32_t id,
void * data, uint32_t size) void * data, uint32_t size)
{ {
@@ -186,6 +245,17 @@ static void pipewire_onPlaybackIoChanged(void * userdata, uint32_t id,
} }
} }
static void pipewire_onPlaybackStateChanged(void * userdata,
enum pw_stream_state old, enum pw_stream_state state,
const char * error)
{
pw.playback.connectionState = state;
if (state == PW_STREAM_STATE_ERROR)
DEBUG_ERROR("PipeWire playback stream failed: %s",
error ? error : "unknown error");
pw_thread_loop_signal(pw.thread, false);
}
static void pipewire_onPlaybackProcess(void * userdata) static void pipewire_onPlaybackProcess(void * userdata)
{ {
struct pw_buffer * pbuf; struct pw_buffer * pbuf;
@@ -332,6 +402,7 @@ static void pipewire_playbackStopStream(void)
pw_stream_destroy(pw.playback.stream); pw_stream_destroy(pw.playback.stream);
pw.playback.stream = NULL; pw.playback.stream = NULL;
pw.playback.rateMatch = NULL; pw.playback.rateMatch = NULL;
pw.playback.resamplerEnabled = false;
atomic_store_explicit( atomic_store_explicit(
&pw.playback.presentationDeadline, 0, memory_order_release); &pw.playback.presentationDeadline, 0, memory_order_release);
atomic_store_explicit( atomic_store_explicit(
@@ -341,15 +412,19 @@ static void pipewire_playbackStopStream(void)
} }
static bool pipewire_playbackSetup(int channels, int sampleRate, static bool pipewire_playbackSetup(int channels, int sampleRate,
int requestedPeriodFrames, int * maxPeriodFrames, int * startFrames, int requestedPeriodFrames, bool requestResampler,
bool * resamplerEnabled, int * maxPeriodFrames, int * startFrames,
LG_AudioPullFn pullFn) LG_AudioPullFn pullFn)
{ {
*resamplerEnabled = false;
const struct spa_pod * params[1]; const struct spa_pod * params[1];
uint8_t buffer[1024]; uint8_t buffer[1024];
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer)); struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
static const struct pw_stream_events events = static const struct pw_stream_events events =
{ {
.version = PW_VERSION_STREAM_EVENTS, .version = PW_VERSION_STREAM_EVENTS,
.state_changed = pipewire_onPlaybackStateChanged,
.io_changed = pipewire_onPlaybackIoChanged, .io_changed = pipewire_onPlaybackIoChanged,
.process = pipewire_onPlaybackProcess, .process = pipewire_onPlaybackProcess,
.drained = pipewire_onPlaybackDrained .drained = pipewire_onPlaybackDrained
@@ -359,6 +434,12 @@ static bool pipewire_playbackSetup(int channels, int sampleRate,
pw.playback.channels == channels && pw.playback.channels == channels &&
pw.playback.sampleRate == sampleRate) pw.playback.sampleRate == sampleRate)
{ {
#if PW_CHECK_VERSION(1, 4, 0)
atomic_store_explicit(
&pw.playback.resampleError, 0, memory_order_relaxed);
*resamplerEnabled =
pipewire_configurePlaybackResampler(requestResampler);
#endif
*maxPeriodFrames = pw.playback.maxPeriodFrames; *maxPeriodFrames = pw.playback.maxPeriodFrames;
*startFrames = pw.playback.startFrames; *startFrames = pw.playback.startFrames;
return true; return true;
@@ -464,6 +545,7 @@ static bool pipewire_playbackSetup(int channels, int sampleRate,
.rate = sampleRate .rate = sampleRate
)); ));
pw.playback.connectionState = PW_STREAM_STATE_CONNECTING;
const int result = pw_stream_connect( const int result = pw_stream_connect(
pw.playback.stream, pw.playback.stream,
PW_DIRECTION_OUTPUT, PW_DIRECTION_OUTPUT,
@@ -484,6 +566,19 @@ static bool pipewire_playbackSetup(int channels, int sampleRate,
return false; return false;
} }
while (pw.playback.connectionState == PW_STREAM_STATE_CONNECTING)
pw_thread_loop_wait(pw.thread);
if (pw.playback.connectionState != PW_STREAM_STATE_PAUSED)
{
DEBUG_ERROR("PipeWire playback stream did not become ready");
pw_stream_destroy(pw.playback.stream);
pw.playback.stream = NULL;
pw.playback.rateMatch = NULL;
pw_thread_loop_unlock(pw.thread);
return false;
}
pw.playback.state = STREAM_STATE_INACTIVE; pw.playback.state = STREAM_STATE_INACTIVE;
atomic_store_explicit( atomic_store_explicit(
&pw.playback.presentationDeadline, 0, memory_order_release); &pw.playback.presentationDeadline, 0, memory_order_release);
@@ -493,6 +588,14 @@ static bool pipewire_playbackSetup(int channels, int sampleRate,
&pw.playback.bufferErrors, 0, memory_order_relaxed); &pw.playback.bufferErrors, 0, memory_order_relaxed);
atomic_store_explicit( atomic_store_explicit(
&pw.playback.timingErrors, 0, memory_order_relaxed); &pw.playback.timingErrors, 0, memory_order_relaxed);
#if PW_CHECK_VERSION(1, 4, 0)
atomic_store_explicit(
&pw.playback.resampleError, 0, memory_order_relaxed);
*resamplerEnabled =
pipewire_configurePlaybackResampler(requestResampler);
#else
(void)requestResampler;
#endif
pw_thread_loop_unlock(pw.thread); pw_thread_loop_unlock(pw.thread);
return true; return true;
} }
@@ -932,6 +1035,9 @@ struct LG_AudioDevOps LGAD_PipeWire =
.stop = pipewire_playbackStop, .stop = pipewire_playbackStop,
.volume = pipewire_playbackVolume, .volume = pipewire_playbackVolume,
.mute = pipewire_playbackMute, .mute = pipewire_playbackMute,
#if PW_CHECK_VERSION(1, 4, 0)
.setRate = pipewire_playbackSetRate,
#endif
.latency = pipewire_playbackLatency .latency = pipewire_playbackLatency
}, },
.record = .record =

View File

@@ -300,9 +300,12 @@ static void pulseaudio_overflow_cb(pa_stream * p, void * userdata)
} }
static bool pulseaudio_setup(int channels, int sampleRate, static bool pulseaudio_setup(int channels, int sampleRate,
int requestedPeriodFrames, int * maxPeriodFrames, int * startFrames, int requestedPeriodFrames, bool requestResampler,
bool * resamplerEnabled, int * maxPeriodFrames, int * startFrames,
LG_AudioPullFn pullFn) LG_AudioPullFn pullFn)
{ {
*resamplerEnabled = false;
if (pa.sink && pa.sinkChannels == channels && pa.sinkSampleRate == sampleRate) if (pa.sink && pa.sinkChannels == channels && pa.sinkSampleRate == sampleRate)
{ {
*maxPeriodFrames = pa.sinkMaxPeriodFrames; *maxPeriodFrames = pa.sinkMaxPeriodFrames;

View File

@@ -45,10 +45,13 @@ struct LG_AudioDevOps
struct struct
{ {
/* setup the stream for playback but don't start it yet, returning false /* setup the stream for playback but don't start it yet, returning false
* if the stream could not be configured * if the stream could not be configured. If backend resampling is
* requested, resamplerEnabled reports whether it was activated for this
* stream.
* Note: the pull function returns f32 samples * Note: the pull function returns f32 samples
*/ */
bool (*setup)(int channels, int sampleRate, int requestedPeriodFrames, bool (*setup)(int channels, int sampleRate, int requestedPeriodFrames,
bool requestResampler, bool * resamplerEnabled,
int * maxPeriodFrames, int * startFrames, LG_AudioPullFn pullFn); int * maxPeriodFrames, int * startFrames, LG_AudioPullFn pullFn);
/* called when there is data available to start playback */ /* called when there is data available to start playback */
@@ -63,6 +66,10 @@ struct LG_AudioDevOps
/* [optional] called to set muting of the output */ /* [optional] called to set muting of the output */
void (*mute)(bool mute); void (*mute)(bool mute);
/* [optional] update the active backend resampler's output/input ratio.
* Called from the backend's playback callback and must be realtime safe. */
bool (*setRate)(double ratio);
/* return the current total playback latency in microseconds */ /* return the current total playback latency in microseconds */
uint64_t (*latency)(void); uint64_t (*latency)(void);
} }

View File

@@ -37,6 +37,7 @@
#include <string.h> #include <string.h>
#define PLAYBACK_CLOCK_BANDWIDTH_HZ 0.05 #define PLAYBACK_CLOCK_BANDWIDTH_HZ 0.05
#define PLAYBACK_ACQUIRE_PHASE_BANDWIDTH_HZ 0.05
#define PLAYBACK_PHASE_BANDWIDTH_HZ 0.005 #define PLAYBACK_PHASE_BANDWIDTH_HZ 0.005
#define PLAYBACK_OFFSET_FILTER_BANDWIDTH_HZ \ #define PLAYBACK_OFFSET_FILTER_BANDWIDTH_HZ \
(20.0 * PLAYBACK_PHASE_BANDWIDTH_HZ) (20.0 * PLAYBACK_PHASE_BANDWIDTH_HZ)
@@ -44,6 +45,8 @@
#define PLAYBACK_MAX_RATE_CORRECTION 0.005 #define PLAYBACK_MAX_RATE_CORRECTION 0.005
#define PLAYBACK_MAX_RATE_SLEW_PER_SEC 0.005 #define PLAYBACK_MAX_RATE_SLEW_PER_SEC 0.005
#define PLAYBACK_MAX_JITTER_SEC 0.1 #define PLAYBACK_MAX_JITTER_SEC 0.1
#define PLAYBACK_PHASE_BASELINE_TIME_SEC 5.0
#define PLAYBACK_PHASE_RESERVE_DECAY_SEC 60.0
/* libsamplerate does not expose its buffered-frame delay. SRC_SINC_FASTEST /* libsamplerate does not expose its buffered-frame delay. SRC_SINC_FASTEST
* retains 20 frames at unity; the bounded ratio range changes this by less * retains 20 frames at unity; the bounded ratio range changes this by less
* than one frame. Keep it in the latency model, but not in the safety buffer. */ * than one frame. Keep it in the latency model, but not in the safety buffer. */
@@ -82,6 +85,8 @@ StreamState;
typedef struct typedef struct
{ {
int64_t nextPosition; int64_t nextPosition;
double outputPosition;
double appliedRatio;
int startupSilenceFrames; int startupSilenceFrames;
} }
PlaybackDeviceData; PlaybackDeviceData;
@@ -91,7 +96,7 @@ typedef struct
bool valid; bool valid;
unsigned int updates; unsigned int updates;
int64_t time; int64_t time;
int64_t position; double position;
double frameSec; double frameSec;
double phaseResidualSec; double phaseResidualSec;
} }
@@ -120,6 +125,10 @@ typedef struct
int64_t lastPacketTime; int64_t lastPacketTime;
int64_t lastArrivalTime; int64_t lastArrivalTime;
double arrivalJitterSec; double arrivalJitterSec;
double sourcePhaseBaselineSec;
double sourcePhaseReserveSec;
double sourcePacketDurationSec;
bool sourcePhaseBaselineValid;
bool mediaClockValid; bool mediaClockValid;
PlaybackRateSample rateSamples[PLAYBACK_RATE_MAX_SAMPLES]; PlaybackRateSample rateSamples[PLAYBACK_RATE_MAX_SAMPLES];
@@ -127,10 +136,8 @@ typedef struct
unsigned int rateSampleCount; unsigned int rateSampleCount;
int64_t rateLastSampleTimeMs; int64_t rateLastSampleTimeMs;
int64_t rateFilterTimeMs; int64_t rateFilterTimeMs;
double sourceRateRawFrameSec;
double sourceRateFrameSec; double sourceRateFrameSec;
bool sourceRateValid; bool sourceRateValid;
bool startupSyncPending;
bool bufferOverrunPending; bool bufferOverrunPending;
int devPeriodFrames; int devPeriodFrames;
@@ -140,6 +147,7 @@ typedef struct
int64_t deviceClockCheckTime; int64_t deviceClockCheckTime;
double deviceClockCheckFrameSec; double deviceClockCheckFrameSec;
double deviceClockStableSec; double deviceClockStableSec;
double devicePositionOffsetFrames;
bool deviceClockStable; bool deviceClockStable;
double offsetError; double offsetError;
@@ -149,18 +157,10 @@ typedef struct
double lastClockRatio; double lastClockRatio;
int64_t nextLogTime; int64_t nextLogTime;
unsigned int bufferOverruns; unsigned int bufferOverruns;
int maxAbsSlewFrames;
int64_t debugBufferFramesSum;
int debugBufferFramesMin;
int debugBufferFramesMax;
double debugSourcePhaseSumSec;
double debugSourcePhaseSquaredSec;
double debugSourcePhaseAbsMaxSec;
unsigned int debugSamples;
PlaybackClock sourceClock; PlaybackClock sourceClock;
PlaybackClock deviceClock; PlaybackClock deviceClock;
PlaybackClock outputClock;
SRC_STATE * src; SRC_STATE * src;
} }
PlaybackSpiceData; PlaybackSpiceData;
@@ -171,6 +171,7 @@ typedef struct
atomic_int periodFrames; atomic_int periodFrames;
_Atomic(int64_t) time; _Atomic(int64_t) time;
_Atomic(int64_t) position; _Atomic(int64_t) position;
_Atomic(double) outputPosition;
} }
PlaybackDeviceTiming; PlaybackDeviceTiming;
@@ -191,6 +192,12 @@ typedef struct
int deviceMaxPeriodFrames; int deviceMaxPeriodFrames;
int deviceStartFrames; int deviceStartFrames;
int targetStartFrames; int targetStartFrames;
int startupLowWaterFrames;
int64_t startupPacketDeadline;
int64_t startupPacketPeriod;
bool backendResampler;
_Atomic(double) backendResampleRatio;
atomic_bool backendResamplerFailed;
RingBuffer buffer; RingBuffer buffer;
PlaybackDeviceTiming deviceTiming; PlaybackDeviceTiming deviceTiming;
atomic_uint underruns; atomic_uint underruns;
@@ -238,11 +245,12 @@ typedef struct
int periodFrames; int periodFrames;
int64_t nextTime; int64_t nextTime;
int64_t nextPosition; int64_t nextPosition;
double outputPosition;
} }
PlaybackDeviceTick; PlaybackDeviceTick;
static void playbackClockReset(PlaybackClock * clock, int64_t time, static void playbackClockReset(PlaybackClock * clock, int64_t time,
int64_t position, double frameSec) double position, double frameSec)
{ {
clock->valid = true; clock->valid = true;
clock->updates = 1; clock->updates = 1;
@@ -253,7 +261,7 @@ static void playbackClockReset(PlaybackClock * clock, int64_t time,
} }
static bool playbackClockUpdate(PlaybackClock * clock, int64_t time, static bool playbackClockUpdate(PlaybackClock * clock, int64_t time,
int64_t position, double nominalFrameSec) double position, double nominalFrameSec)
{ {
if (!clock->valid) if (!clock->valid)
{ {
@@ -261,7 +269,7 @@ static bool playbackClockUpdate(PlaybackClock * clock, int64_t time,
return true; return true;
} }
const int64_t frames = position - clock->position; const double frames = position - clock->position;
if (frames <= 0) if (frames <= 0)
return frames == 0; return frames == 0;
@@ -292,7 +300,7 @@ static bool playbackClockUpdate(PlaybackClock * clock, int64_t time,
} }
static bool playbackSourceClockUpdate(PlaybackClock * clock, int64_t time, static bool playbackSourceClockUpdate(PlaybackClock * clock, int64_t time,
int64_t position, double nominalFrameSec) double position, double nominalFrameSec)
{ {
if (!clock->valid) if (!clock->valid)
{ {
@@ -300,7 +308,7 @@ static bool playbackSourceClockUpdate(PlaybackClock * clock, int64_t time,
return true; return true;
} }
const int64_t frames = position - clock->position; const double frames = position - clock->position;
if (frames <= 0) if (frames <= 0)
return frames == 0; return frames == 0;
@@ -329,7 +337,10 @@ static void playbackDeviceClockAcquireReset(PlaybackSpiceData * spiceData)
spiceData->deviceClockAcquireStart = INT64_MIN; spiceData->deviceClockAcquireStart = INT64_MIN;
spiceData->deviceClockCheckTime = INT64_MIN; spiceData->deviceClockCheckTime = INT64_MIN;
spiceData->deviceClockStableSec = 0.0; spiceData->deviceClockStableSec = 0.0;
spiceData->devicePositionOffsetFrames = 0.0;
spiceData->deviceClockStable = false; spiceData->deviceClockStable = false;
spiceData->ratioIntegral = 0.0;
spiceData->lastClockRatio = 1.0;
} }
static bool playbackDeviceClockAcquire( static bool playbackDeviceClockAcquire(
@@ -468,7 +479,6 @@ static void playbackSourceRateAdd(
(1.0 + PLAYBACK_MAX_RATE_CORRECTION)) (1.0 + PLAYBACK_MAX_RATE_CORRECTION))
return; return;
spiceData->sourceRateRawFrameSec = frameSec;
if (!spiceData->sourceRateValid) if (!spiceData->sourceRateValid)
{ {
spiceData->sourceRateFrameSec = frameSec; spiceData->sourceRateFrameSec = frameSec;
@@ -487,7 +497,8 @@ static void playbackSourceRateAdd(
} }
static void playbackPublishDeviceTiming( static void playbackPublishDeviceTiming(
int periodFrames, int64_t time, int64_t position) int periodFrames, int64_t time, int64_t position,
double outputPosition)
{ {
PlaybackDeviceTiming * timing = &audio.playback.deviceTiming; PlaybackDeviceTiming * timing = &audio.playback.deviceTiming;
atomic_fetch_add_explicit(&timing->sequence, 1, memory_order_relaxed); atomic_fetch_add_explicit(&timing->sequence, 1, memory_order_relaxed);
@@ -495,6 +506,8 @@ static void playbackPublishDeviceTiming(
&timing->periodFrames, periodFrames, memory_order_relaxed); &timing->periodFrames, periodFrames, memory_order_relaxed);
atomic_store_explicit(&timing->time, time, memory_order_relaxed); atomic_store_explicit(&timing->time, time, memory_order_relaxed);
atomic_store_explicit(&timing->position, position, memory_order_relaxed); atomic_store_explicit(&timing->position, position, memory_order_relaxed);
atomic_store_explicit(
&timing->outputPosition, outputPosition, memory_order_relaxed);
atomic_fetch_add_explicit(&timing->sequence, 1, memory_order_release); atomic_fetch_add_explicit(&timing->sequence, 1, memory_order_release);
} }
@@ -518,6 +531,9 @@ static bool playbackReadDeviceTiming(
atomic_load_explicit(&timing->time, memory_order_relaxed); atomic_load_explicit(&timing->time, memory_order_relaxed);
tick->nextPosition = tick->nextPosition =
atomic_load_explicit(&timing->position, memory_order_relaxed); atomic_load_explicit(&timing->position, memory_order_relaxed);
tick->outputPosition =
atomic_load_explicit(
&timing->outputPosition, memory_order_relaxed);
atomic_thread_fence(memory_order_acquire); atomic_thread_fence(memory_order_acquire);
after = atomic_load_explicit(&timing->sequence, memory_order_relaxed); after = atomic_load_explicit(&timing->sequence, memory_order_relaxed);
} }
@@ -714,6 +730,7 @@ static void playbackStop(void)
if (audio.playback.timings) if (audio.playback.timings)
{ {
if (audio.playback.graph)
app_unregisterGraph(audio.playback.graph); app_unregisterGraph(audio.playback.graph);
audio.playback.graph = NULL; audio.playback.graph = NULL;
ringbuffer_free(&audio.playback.timings); ringbuffer_free(&audio.playback.timings);
@@ -730,6 +747,20 @@ static int playbackPullFrames(uint8_t * dst, int frames)
return 0; return 0;
PlaybackDeviceData * data = &audio.playback.deviceData; PlaybackDeviceData * data = &audio.playback.deviceData;
double nextRatio = 1.0;
if (audio.playback.backendResampler)
{
nextRatio = atomic_load_explicit(
&audio.playback.backendResampleRatio, memory_order_acquire);
if (!audio.audioDev->playback.setRate(nextRatio))
{
atomic_store_explicit(
&audio.playback.backendResamplerFailed, true,
memory_order_release);
nextRatio = data->appliedRatio;
}
}
const int64_t now = nanotime(); const int64_t now = nanotime();
if (audio.playback.buffer) if (audio.playback.buffer)
@@ -737,12 +768,32 @@ static int playbackPullFrames(uint8_t * dst, int frames)
if (playbackGetState() == STREAM_STATE_SETUP_DEVICE) if (playbackGetState() == STREAM_STATE_SETUP_DEVICE)
{ {
/* The backend may begin pulling either immediately or long after it was /* The backend may begin pulling either immediately or long after it was
* activated. Align the buffer in both directions on its first callback: * activated. Only retain enough of the source packet to reach its next
* insert silence if it started early, or discard the oldest queued audio * expected delivery time; retaining the complete packet after part of
* if it started late. This always leaves the newest audio at the requested * its interval has already elapsed turns backend startup delay into
* startup latency. */ * persistent playback latency. Still cover the backend's immediate
* startup pull if it is larger than the remaining packet interval. */
const int64_t packetPeriod =
max(audio.playback.startupPacketPeriod, INT64_C(1));
const int64_t remainingNs =
audio.playback.startupPacketDeadline > now ?
audio.playback.startupPacketDeadline - now :
packetPeriod -
(now - audio.playback.startupPacketDeadline) % packetPeriod;
const int remainingFrames = clamp(
(remainingNs * audio.playback.sampleRate + INT64_C(999999999)) /
INT64_C(1000000000),
INT64_C(0), (int64_t)INT_MAX);
const int targetFrames = min(
(int64_t)audio.playback.startupLowWaterFrames +
max(audio.playback.deviceStartFrames, remainingFrames),
(int64_t)ringbuffer_getLength(audio.playback.buffer));
/* Align in both directions: insert silence if the backend started before
* the target was available, or discard the oldest queued audio if it
* started late. */
const int offset = ringbuffer_getCount(audio.playback.buffer) - const int offset = ringbuffer_getCount(audio.playback.buffer) -
audio.playback.targetStartFrames; targetFrames;
if (offset > 0) if (offset > 0)
{ {
data->nextPosition += offset; data->nextPosition += offset;
@@ -760,12 +811,17 @@ static int playbackPullFrames(uint8_t * dst, int frames)
playbackSetState(STREAM_STATE_RUN); playbackSetState(STREAM_STATE_RUN);
} }
/* Timestamp the dequeue boundary before the current pull. The position /* Timestamp the dequeue boundary before the current pull. The logical
* delta then describes the buffer requested by the previous callback, * position tracks source frames consumed from the ring for latency
* which is also what determines the elapsed device time. This remains * measurement. With backend resampling, outputPosition separately tracks
* correct when the backend changes quantum size. */ * the equivalent number of device-rate frames. PipeWire computes the
playbackPublishDeviceTiming(frames, now, data->nextPosition); * current request using the rate set by the previous callback, so apply
* that same ratio to this period before adopting nextRatio. */
playbackPublishDeviceTiming(
frames, now, data->nextPosition, data->outputPosition);
data->nextPosition += frames; data->nextPosition += frames;
data->outputPosition += frames * data->appliedRatio;
data->appliedRatio = nextRatio;
const int silenceFrames = const int silenceFrames =
min(frames, data->startupSilenceFrames); min(frames, data->startupSilenceFrames);
@@ -851,23 +907,11 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
if (state != STREAM_STATE_STOP) if (state != STREAM_STATE_STOP)
playbackStop(); playbackStop();
int srcError;
audio.playback.spiceData.src = src_new(SRC_SINC_FASTEST, channels, &srcError);
if (!audio.playback.spiceData.src)
{
DEBUG_ERROR("Failed to create resampler: %s", src_strerror(srcError));
return;
}
const int bufferFrames = sampleRate; const int bufferFrames = sampleRate;
audio.playback.buffer = ringbuffer_newUnbounded(bufferFrames, audio.playback.buffer = ringbuffer_newUnbounded(bufferFrames,
channels * sizeof(float)); channels * sizeof(float));
if (!audio.playback.buffer) if (!audio.playback.buffer)
{
audio.playback.spiceData.src =
src_delete(audio.playback.spiceData.src);
return; return;
}
lastChannels = channels; lastChannels = channels;
lastSampleRate = sampleRate; lastSampleRate = sampleRate;
@@ -879,6 +923,8 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
playbackSetState(STREAM_STATE_SETUP_SPICE); playbackSetState(STREAM_STATE_SETUP_SPICE);
audio.playback.deviceData.nextPosition = 0; audio.playback.deviceData.nextPosition = 0;
audio.playback.deviceData.outputPosition = 0.0;
audio.playback.deviceData.appliedRatio = 1.0;
audio.playback.deviceData.startupSilenceFrames = 0; audio.playback.deviceData.startupSilenceFrames = 0;
audio.playback.spiceData.inputPosition = 0; audio.playback.spiceData.inputPosition = 0;
@@ -892,21 +938,17 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
audio.playback.spiceData.ratioIntegral = 0.0; audio.playback.spiceData.ratioIntegral = 0.0;
audio.playback.spiceData.lastRatio = 1.0; audio.playback.spiceData.lastRatio = 1.0;
audio.playback.spiceData.lastClockRatio = 1.0; audio.playback.spiceData.lastClockRatio = 1.0;
audio.playback.spiceData.startupSyncPending = true;
audio.playback.spiceData.bufferOverrunPending = false; audio.playback.spiceData.bufferOverrunPending = false;
audio.playback.spiceData.bufferOverruns = 0; audio.playback.spiceData.bufferOverruns = 0;
audio.playback.spiceData.maxAbsSlewFrames = 0;
audio.playback.spiceData.nextLogTime = audio.playback.spiceData.nextLogTime =
nanotime() + INT64_C(5000000000); nanotime() + INT64_C(5000000000);
audio.playback.spiceData.debugBufferFramesSum = 0;
audio.playback.spiceData.debugBufferFramesMin = INT_MAX;
audio.playback.spiceData.debugBufferFramesMax = INT_MIN;
audio.playback.spiceData.debugSourcePhaseSumSec = 0.0;
audio.playback.spiceData.debugSourcePhaseSquaredSec = 0.0;
audio.playback.spiceData.debugSourcePhaseAbsMaxSec = 0.0;
audio.playback.spiceData.debugSamples = 0;
audio.playback.spiceData.arrivalJitterSec = 0.0; audio.playback.spiceData.arrivalJitterSec = 0.0;
audio.playback.spiceData.sourcePhaseBaselineSec = 0.0;
audio.playback.spiceData.sourcePhaseReserveSec = 0.0;
audio.playback.spiceData.sourcePacketDurationSec = 0.0;
audio.playback.spiceData.sourcePhaseBaselineValid = false;
audio.playback.spiceData.deviceClock.valid = false; audio.playback.spiceData.deviceClock.valid = false;
audio.playback.spiceData.outputClock.valid = false;
playbackPrepareMediaClock(&audio.playback.spiceData, time); playbackPrepareMediaClock(&audio.playback.spiceData, time);
atomic_store_explicit( atomic_store_explicit(
@@ -917,16 +959,32 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
&audio.playback.deviceTiming.time, 0, memory_order_relaxed); &audio.playback.deviceTiming.time, 0, memory_order_relaxed);
atomic_store_explicit( atomic_store_explicit(
&audio.playback.deviceTiming.position, 0, memory_order_relaxed); &audio.playback.deviceTiming.position, 0, memory_order_relaxed);
atomic_store_explicit(
&audio.playback.deviceTiming.outputPosition, 0.0,
memory_order_relaxed);
atomic_store_explicit( atomic_store_explicit(
&audio.playback.underruns, 0, memory_order_relaxed); &audio.playback.underruns, 0, memory_order_relaxed);
atomic_store_explicit(
&audio.playback.backendResampleRatio, 1.0, memory_order_relaxed);
atomic_store_explicit(
&audio.playback.backendResamplerFailed, false,
memory_order_relaxed);
const int requestedPeriodFrames = g_params.audioPeriodSize > 0 ? const int requestedPeriodFrames = g_params.audioPeriodSize > 0 ?
clamp(g_params.audioPeriodSize, 1, sampleRate) : clamp(g_params.audioPeriodSize, 1, sampleRate) :
max(sampleRate / 100, 1); max(sampleRate / 100, 1);
audio.playback.deviceMaxPeriodFrames = 0; audio.playback.deviceMaxPeriodFrames = 0;
audio.playback.deviceStartFrames = 0; audio.playback.deviceStartFrames = 0;
audio.playback.targetStartFrames = 0;
audio.playback.startupLowWaterFrames = 0;
audio.playback.startupPacketDeadline = 0;
audio.playback.startupPacketPeriod = 0;
const bool requestBackendResampler =
g_params.audioResampler != AUDIO_RESAMPLER_LIBSAMPLERATE;
if (!audio.audioDev->playback.setup(channels, sampleRate, if (!audio.audioDev->playback.setup(channels, sampleRate,
requestedPeriodFrames, &audio.playback.deviceMaxPeriodFrames, requestedPeriodFrames, requestBackendResampler,
&audio.playback.backendResampler,
&audio.playback.deviceMaxPeriodFrames,
&audio.playback.deviceStartFrames, playbackPullFrames) || &audio.playback.deviceStartFrames, playbackPullFrames) ||
audio.playback.deviceMaxPeriodFrames <= 0 || audio.playback.deviceMaxPeriodFrames <= 0 ||
audio.playback.deviceStartFrames < 0) audio.playback.deviceStartFrames < 0)
@@ -936,6 +994,30 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
return; return;
} }
if (g_params.audioResampler == AUDIO_RESAMPLER_BACKEND &&
!audio.playback.backendResampler)
DEBUG_WARN("%s could not activate backend resampling; "
"using libsamplerate", audio.audioDev->name);
if (!audio.playback.backendResampler)
{
int srcError;
audio.playback.spiceData.src =
src_new(SRC_SINC_FASTEST, channels, &srcError);
if (!audio.playback.spiceData.src)
{
DEBUG_ERROR("Failed to create resampler: %s", src_strerror(srcError));
playbackStop();
return;
}
}
else
audio.playback.spiceData.src = NULL;
DEBUG_INFO("Using audio resampler: %s",
audio.playback.backendResampler ?
audio.audioDev->name : "libsamplerate");
// if a volume level was stored, set it before we return // if a volume level was stored, set it before we return
if (audio.playback.volumeChannels) if (audio.playback.volumeChannels)
audio.audioDev->playback.volume( audio.audioDev->playback.volume(
@@ -948,16 +1030,7 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
// Set up synchronization instrumentation only when explicitly requested. // Set up synchronization instrumentation only when explicitly requested.
if (g_params.audioDebug) if (g_params.audioDebug)
{
audio.playback.timings = ringbuffer_new(1200, sizeof(float)); audio.playback.timings = ringbuffer_new(1200, sizeof(float));
if (audio.playback.timings)
{
audio.playback.graph = app_registerGraph("PLAYBACK",
audio.playback.timings, 0.0f, 200.0f, audioGraphFormatFn);
if (!audio.playback.graph)
ringbuffer_free(&audio.playback.timings);
}
}
atomic_store_explicit( atomic_store_explicit(
&audio.playback.callbackState, 0, memory_order_release); &audio.playback.callbackState, 0, memory_order_release);
@@ -977,13 +1050,16 @@ void audio_playbackStop(void)
// playback starts again // playback starts again
playbackSetState(STREAM_STATE_KEEP_ALIVE); playbackSetState(STREAM_STATE_KEEP_ALIVE);
// Reset the resampler so it is safe to use for the next playback // Reset the software resampler so it is safe for the next playback
if (audio.playback.spiceData.src)
{
int error = src_reset(audio.playback.spiceData.src); int error = src_reset(audio.playback.spiceData.src);
if (error) if (error)
{ {
DEBUG_ERROR("Failed to reset resampler: %s", src_strerror(error)); DEBUG_ERROR("Failed to reset resampler: %s", src_strerror(error));
playbackStop(); playbackStop();
} }
}
break; break;
} }
@@ -1034,8 +1110,11 @@ void audio_playbackMute(bool mute)
static double computeDevicePosition(int64_t curTime) static double computeDevicePosition(int64_t curTime)
{ {
const PlaybackSpiceData * spiceData =
&audio.playback.spiceData;
return playbackClockPosition( return playbackClockPosition(
&audio.playback.spiceData.deviceClock, curTime); &spiceData->deviceClock, curTime) +
spiceData->devicePositionOffsetFrames;
} }
static bool playbackEnsureConversionBuffers( static bool playbackEnsureConversionBuffers(
@@ -1055,6 +1134,8 @@ static bool playbackEnsureConversionBuffers(
spiceData->framesInSize = frames; spiceData->framesInSize = frames;
} }
if (!audio.playback.backendResampler)
{
const int framesOut = const int framesOut =
(int)ceil(frames * (1.0 + PLAYBACK_MAX_RATE_CORRECTION)) + 64; (int)ceil(frames * (1.0 + PLAYBACK_MAX_RATE_CORRECTION)) + 64;
if (framesOut > spiceData->framesOutSize) if (framesOut > spiceData->framesOutSize)
@@ -1070,6 +1151,7 @@ static bool playbackEnsureConversionBuffers(
spiceData->framesOut = output; spiceData->framesOut = output;
spiceData->framesOutSize = framesOut; spiceData->framesOutSize = framesOut;
} }
}
return true; return true;
} }
@@ -1113,9 +1195,6 @@ static int playbackSlewBuffer(
ringbuffer_append(audio.playback.buffer, NULL, slew); ringbuffer_append(audio.playback.buffer, NULL, slew);
DEBUG_ASSERT(advanced == slew); DEBUG_ASSERT(advanced == slew);
spiceData->maxAbsSlewFrames =
max(spiceData->maxAbsSlewFrames,
slew == INT_MIN ? INT_MAX : abs(slew));
if (slew != requested) if (slew != requested)
spiceData->bufferOverrunPending = true; spiceData->bufferOverrunPending = true;
@@ -1134,7 +1213,22 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
if (state == STREAM_STATE_STOP || !audio.audioDev || size == 0) if (state == STREAM_STATE_STOP || !audio.audioDev || size == 0)
return; return;
if (audio.playback.backendResampler &&
atomic_exchange_explicit(
&audio.playback.backendResamplerFailed, false,
memory_order_acq_rel))
{
DEBUG_ERROR("Audio backend resampler failed");
playbackStop();
return;
}
PlaybackSpiceData * spiceData = &audio.playback.spiceData; PlaybackSpiceData * spiceData = &audio.playback.spiceData;
/* Backend resampling changes how many source frames PipeWire requests per
* device period. Use the command-normalized output clock for rate matching,
* while deviceClock remains in the ring's source-frame domain for latency. */
const PlaybackClock * rateClock = audio.playback.backendResampler ?
&spiceData->outputClock : &spiceData->deviceClock;
const int64_t now = nanotime(); const int64_t now = nanotime();
const double nominalFrameSec = 1.0 / audio.playback.sampleRate; const double nominalFrameSec = 1.0 / audio.playback.sampleRate;
@@ -1191,14 +1285,61 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
spiceData->lastPacketTime = packetTime; spiceData->lastPacketTime = packetTime;
spiceData->lastArrivalTime = now; spiceData->lastArrivalTime = now;
const bool sourceRateWasValid =
spiceData->sourceRateValid;
playbackSourceRateAdd(spiceData, nominalFrameSec); playbackSourceRateAdd(spiceData, nominalFrameSec);
const bool sourceRateBecameValid =
!sourceRateWasValid && spiceData->sourceRateValid;
if (!playbackSourceClockUpdate(&spiceData->sourceClock, if (!playbackSourceClockUpdate(&spiceData->sourceClock,
packetTime, spiceData->inputPosition, nominalFrameSec)) packetTime, spiceData->inputPosition, nominalFrameSec))
discontinuity = true; discontinuity = true;
if (spiceData->sourceRateValid) if (spiceData->sourceRateValid)
spiceData->sourceClock.frameSec = spiceData->sourceRateFrameSec; spiceData->sourceClock.frameSec = spiceData->sourceRateFrameSec;
/* Track phase variation around its local baseline, not its absolute value.
* The absolute phase depends on the arbitrary local origin assigned to the
* SPICE multimedia clock and must not become buffer reserve. Positive
* deviation means the latency model temporarily overstates how much audio
* remains in the ring. */
const double sourcePhaseSec =
spiceData->sourceClock.phaseResidualSec;
const double packetSec =
frames * nominalFrameSec;
spiceData->sourcePacketDurationSec =
max(packetSec, spiceData->sourcePacketDurationSec *
exp(-packetSec / PLAYBACK_PHASE_RESERVE_DECAY_SEC));
if (!spiceData->sourcePhaseBaselineValid ||
spiceData->sourceClock.updates == 1)
{
spiceData->sourcePhaseBaselineSec = sourcePhaseSec;
spiceData->sourcePhaseBaselineValid = true;
}
else
{
const double alpha =
-expm1(-packetSec / PLAYBACK_PHASE_BASELINE_TIME_SEC);
spiceData->sourcePhaseBaselineSec +=
alpha * (sourcePhaseSec -
spiceData->sourcePhaseBaselineSec);
}
const double sourcePhaseDeviationSec =
max(0.0, sourcePhaseSec -
spiceData->sourcePhaseBaselineSec);
spiceData->sourcePhaseReserveSec =
min(PLAYBACK_MAX_JITTER_SEC,
max(sourcePhaseDeviationSec,
spiceData->sourcePhaseReserveSec *
exp(-packetSec /
PLAYBACK_PHASE_RESERVE_DECAY_SEC)));
int64_t curTime = spiceData->sourceClock.time; int64_t curTime = spiceData->sourceClock.time;
int64_t curPosition = spiceData->outputPosition; int64_t curPosition = spiceData->outputPosition;
const double sourceReserveFrames =
max(spiceData->sourcePacketDurationSec * 0.5,
spiceData->sourcePhaseReserveSec) *
audio.playback.sampleRate;
// Receive the newest timing information from the audio device thread. // Receive the newest timing information from the audio device thread.
PlaybackDeviceTick deviceTick; PlaybackDeviceTick deviceTick;
@@ -1211,8 +1352,15 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
spiceData->devPeriodFrames = deviceTick.periodFrames; spiceData->devPeriodFrames = deviceTick.periodFrames;
spiceData->devReadPosition = spiceData->devReadPosition =
deviceTick.nextPosition + deviceTick.periodFrames; deviceTick.nextPosition + deviceTick.periodFrames;
if (!playbackClockUpdate(&spiceData->deviceClock, const bool deviceClockUpdated =
deviceTick.nextTime, deviceTick.nextPosition, nominalFrameSec)) playbackClockUpdate(&spiceData->deviceClock,
deviceTick.nextTime, deviceTick.nextPosition, nominalFrameSec);
const bool outputClockUpdated =
!audio.playback.backendResampler ||
playbackClockUpdate(&spiceData->outputClock,
deviceTick.nextTime, deviceTick.outputPosition,
nominalFrameSec);
if (!deviceClockUpdated || !outputClockUpdated)
{ {
playbackDeviceClockAcquireReset(spiceData); playbackDeviceClockAcquireReset(spiceData);
discontinuity = true; discontinuity = true;
@@ -1224,48 +1372,46 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
if (deviceClockBecameStable) if (deviceClockBecameStable)
{ {
/* The first device callback already aligned the logical ring. Anchor the /* Give the fitted device timeline the same latency reported by the
* now-stable clock model to that exact read position without changing * acquisition model. Their position origins are otherwise unrelated, so
* buffered audio. */ * switching models would create a false phase step and drive the resampler
curTime = llrint( * despite an already-correct ring level. Keep the source clock untouched:
spiceData->deviceClock.time + * changing it would also disturb SPICE phase and jitter tracking. */
(spiceData->devReadPosition - const double rawDevicePosition =
spiceData->deviceClock.position) * playbackClockPosition(&spiceData->deviceClock, curTime);
spiceData->deviceClock.frameSec * 1.0e9); spiceData->devicePositionOffsetFrames =
spiceData->sourceClock.time = curTime; spiceData->devReadPosition - sourceReserveFrames -
spiceData->offsetError = 0.0; rawDevicePosition;
spiceData->offsetErrorIntegral = 0.0;
spiceData->ratioIntegral = 0.0;
} }
const int configLatencyMs = max(g_params.audioBufferLatency, 0);
const int maxPeriodFrames = const int maxPeriodFrames =
max(audio.playback.deviceMaxPeriodFrames, spiceData->devPeriodFrames); max(audio.playback.deviceMaxPeriodFrames, spiceData->devPeriodFrames);
const double configuredLatencyFrames = /* The device period, delivery jitter, packet phase, and resampler delay
configLatencyMs * audio.playback.sampleRate / 1000.0; * define the minimum viable latency. latencyOffset is strictly an additive
const double measuredJitterFrames = * user offset over that same minimum for both startup and steady state. */
(spiceData->arrivalJitterSec + 0.001) * audio.playback.sampleRate; const double latencyOffsetFrames =
max(g_params.audioLatencyOffset, 0) *
audio.playback.sampleRate / 1000.0;
const double arrivalReserveFrames =
(spiceData->arrivalJitterSec + 0.001) *
audio.playback.sampleRate;
const double minimumLowWaterReserveFrames =
maxPeriodFrames * 0.1 + arrivalReserveFrames;
const double minimumLowWaterFrames =
maxPeriodFrames + minimumLowWaterReserveFrames;
const double targetLowWaterFrames =
minimumLowWaterFrames + latencyOffsetFrames;
const double minimumBufferFrames =
minimumLowWaterFrames + sourceReserveFrames;
const double targetBufferFrames = const double targetBufferFrames =
maxPeriodFrames * 1.1 + minimumBufferFrames + latencyOffsetFrames;
configuredLatencyFrames + measuredJitterFrames; const double resamplerDelayFrames =
audio.playback.backendResampler ?
0.0 : PLAYBACK_RESAMPLER_DELAY_FRAMES;
const double minimumLatencyFrames =
minimumBufferFrames + resamplerDelayFrames;
const double targetLatencyFrames = const double targetLatencyFrames =
targetBufferFrames + PLAYBACK_RESAMPLER_DELAY_FRAMES; minimumLatencyFrames + latencyOffsetFrames;
if (spiceData->startupSyncPending &&
spiceData->deviceClock.valid)
{
/* Remove any unused backend startup reserve using only logical producer
* and consumer positions. The device clock is still acquiring here and
* must not participate in this one-time alignment. */
const double slew =
spiceData->devReadPosition + targetBufferFrames - curPosition;
const int slewFrames = clamp(llrint(slew), (int64_t)INT_MIN,
(int64_t)INT_MAX);
const int actualSlew = playbackSlewBuffer(spiceData, slewFrames);
spiceData->outputPosition += actualSlew;
curPosition += actualSlew;
spiceData->startupSyncPending = false;
}
double devPosition = DBL_MIN; double devPosition = DBL_MIN;
state = playbackGetState(); state = playbackGetState();
@@ -1290,6 +1436,7 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
} }
double actualLatencyFrames = 0.0; double actualLatencyFrames = 0.0;
double actualOffsetError = 0.0;
if (spiceData->deviceClock.valid) if (spiceData->deviceClock.valid)
{ {
if (spiceData->deviceClockStable) if (spiceData->deviceClockStable)
@@ -1298,11 +1445,21 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
devPosition = computeDevicePosition(curTime); devPosition = computeDevicePosition(curTime);
actualLatencyFrames = actualLatencyFrames =
curPosition - devPosition + PLAYBACK_RESAMPLER_DELAY_FRAMES; curPosition - devPosition + resamplerDelayFrames;
const double actualOffsetError = actualOffsetError =
targetLatencyFrames - actualLatencyFrames; targetLatencyFrames - actualLatencyFrames;
const double error = actualOffsetError - spiceData->offsetError; }
else
{
actualLatencyFrames =
curPosition - spiceData->devReadPosition +
sourceReserveFrames + resamplerDelayFrames;
actualOffsetError =
targetLatencyFrames - actualLatencyFrames;
}
const double error =
actualOffsetError - spiceData->offsetError;
const double periodSec = frames * nominalFrameSec; const double periodSec = frames * nominalFrameSec;
const double omega = const double omega =
2.0 * M_PI * PLAYBACK_OFFSET_FILTER_BANDWIDTH_HZ * periodSec; 2.0 * M_PI * PLAYBACK_OFFSET_FILTER_BANDWIDTH_HZ * periodSec;
@@ -1313,42 +1470,39 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
spiceData->offsetErrorIntegral; spiceData->offsetErrorIntegral;
spiceData->offsetErrorIntegral += c * error; spiceData->offsetErrorIntegral += c * error;
} }
else
{
actualLatencyFrames =
curPosition - spiceData->devReadPosition +
PLAYBACK_RESAMPLER_DELAY_FRAMES;
spiceData->offsetError = 0.0;
spiceData->offsetErrorIntegral = 0.0;
spiceData->ratioIntegral = 0.0;
}
}
/* Feed forward the measured source/device rate ratio, then use a slow, /* Feed forward the measured source/device rate ratio, then use a slow,
* bounded phase controller to keep the ring at its target. The gains are * bounded phase controller to keep the ring at its target. While the device
* derived from the requested loop bandwidth rather than arbitrary constants. * clock is acquiring, its rate estimate is not trustworthy, but the logical
* producer/consumer latency above is. Use that with a faster, one-sided
* controller which can restore missing reserve without draining an initial
* surplus. The stable controller is critically damped so latency approaches
* the target without a designed-in overshoot.
* *
* Transfer changes in the clock estimate into the phase-controller * Before the long-term source estimate is available, the phase integral
* integral. This makes feed-forward updates bumpless: learning a better * necessarily contains the clock-rate error. Discard that provisional
* clock ratio changes how the current correction is represented without * integral when measured feed-forward first takes over, then allow later
* immediately changing the resampler ratio and moving the buffer. */ * filtered clock updates to change the requested ratio directly. Hiding
* those updates in the integral preserves a stale correction and steadily
* moves an already-correct buffer away from its target. */
const double naturalFrequency = const double naturalFrequency =
2.0 * M_PI * PLAYBACK_PHASE_BANDWIDTH_HZ; 2.0 * M_PI * PLAYBACK_PHASE_BANDWIDTH_HZ;
const double kp = const double kp =
M_SQRT2 * naturalFrequency / audio.playback.sampleRate; 2.0 * naturalFrequency / audio.playback.sampleRate;
const double ki = const double ki =
naturalFrequency * naturalFrequency / audio.playback.sampleRate; naturalFrequency * naturalFrequency / audio.playback.sampleRate;
if (sourceRateBecameValid)
spiceData->ratioIntegral = 0.0;
if (spiceData->deviceClockStable && if (spiceData->deviceClockStable &&
spiceData->sourceRateValid && spiceData->sourceRateValid &&
spiceData->deviceClock.updates >= 2) rateClock->updates >= 2)
{ {
const double clockRatio = clamp( const double clockRatio = clamp(
spiceData->sourceRateFrameSec / spiceData->sourceRateFrameSec /
spiceData->deviceClock.frameSec, rateClock->frameSec,
1.0 - PLAYBACK_MAX_RATE_CORRECTION, 1.0 - PLAYBACK_MAX_RATE_CORRECTION,
1.0 + PLAYBACK_MAX_RATE_CORRECTION); 1.0 + PLAYBACK_MAX_RATE_CORRECTION);
spiceData->ratioIntegral -=
(clockRatio - spiceData->lastClockRatio) / ki;
spiceData->lastClockRatio = clockRatio; spiceData->lastClockRatio = clockRatio;
} }
@@ -1358,25 +1512,65 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
* subtracting the deadband outside it keeps the response continuous. */ * subtracting the deadband outside it keeps the response continuous. */
const double phaseDeadbandFrames = const double phaseDeadbandFrames =
PLAYBACK_PHASE_DEADBAND_SEC * audio.playback.sampleRate; PLAYBACK_PHASE_DEADBAND_SEC * audio.playback.sampleRate;
double phaseError = spiceData->offsetError; const double rawPhaseError = spiceData->offsetError;
double phaseError = rawPhaseError;
if (fabs(phaseError) <= phaseDeadbandFrames) if (fabs(phaseError) <= phaseDeadbandFrames)
phaseError = 0.0; phaseError = 0.0;
else else
phaseError -= copysign(phaseDeadbandFrames, phaseError); phaseError -= copysign(phaseDeadbandFrames, phaseError);
const double candidateIntegral = const bool acquiringDeviceClock =
spiceData->ratioIntegral + phaseError * periodSec; spiceData->deviceClock.valid && !spiceData->deviceClockStable;
double controllerKp = kp;
double controllerKi = ki;
double controllerError = phaseError;
double controllerBase = spiceData->lastClockRatio;
if (acquiringDeviceClock)
{
const double acquireFrequency =
2.0 * M_PI * PLAYBACK_ACQUIRE_PHASE_BANDWIDTH_HZ;
controllerKp =
2.0 * acquireFrequency / audio.playback.sampleRate;
controllerBase = 1.0;
spiceData->ratioIntegral = 0.0;
if (actualOffsetError <= 0.0)
controllerError = 0.0;
else
controllerError = max(phaseError, 0.0);
}
else if (deviceClockBecameStable)
{
/* Acquisition correction is transient, not a clock-rate estimate. Start
* the stable integral clean; the output-rate slew keeps the applied ratio
* continuous across this transition. */
spiceData->ratioIntegral = 0.0;
}
/* Use the unfiltered latency error here so filter lag cannot retain a phase
* correction after the target has already been crossed. */
else if (spiceData->ratioIntegral * actualOffsetError <= 0.0)
spiceData->ratioIntegral = 0.0;
const double candidateIntegral = acquiringDeviceClock ?
0.0 :
spiceData->ratioIntegral +
(deviceClockBecameStable ? 0.0 : controllerError * periodSec);
const double phaseCorrection = const double phaseCorrection =
kp * phaseError + ki * candidateIntegral; controllerKp * controllerError +
(acquiringDeviceClock ? 0.0 :
controllerKi * candidateIntegral);
const double desiredRatio = const double desiredRatio =
spiceData->lastClockRatio + phaseCorrection; controllerBase + phaseCorrection;
const double boundedRatio = clamp(desiredRatio, const double boundedRatio = clamp(desiredRatio,
acquiringDeviceClock ? 1.0 :
1.0 - PLAYBACK_MAX_RATE_CORRECTION, 1.0 - PLAYBACK_MAX_RATE_CORRECTION,
1.0 + PLAYBACK_MAX_RATE_CORRECTION); 1.0 + PLAYBACK_MAX_RATE_CORRECTION);
if (desiredRatio == boundedRatio || if (!acquiringDeviceClock && spiceData->deviceClockStable &&
(desiredRatio > boundedRatio && phaseError < 0.0) || (desiredRatio == boundedRatio ||
(desiredRatio < boundedRatio && phaseError > 0.0)) (desiredRatio > boundedRatio && controllerError < 0.0) ||
(desiredRatio < boundedRatio && controllerError > 0.0)))
spiceData->ratioIntegral = candidateIntegral; spiceData->ratioIntegral = candidateIntegral;
const double maxRatioStep = const double maxRatioStep =
@@ -1386,6 +1580,17 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
spiceData->lastRatio + maxRatioStep); spiceData->lastRatio + maxRatioStep);
spiceData->lastRatio = ratio; spiceData->lastRatio = ratio;
if (audio.playback.backendResampler)
{
atomic_store_explicit(
&audio.playback.backendResampleRatio, ratio,
memory_order_release);
const int outputFrames =
playbackAppendFrames(spiceData, spiceData->framesIn, frames);
spiceData->outputPosition += outputFrames;
}
else
{
int consumed = 0; int consumed = 0;
while (consumed < frames) while (consumed < frames)
{ {
@@ -1423,21 +1628,56 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
consumed += srcData.input_frames_used; consumed += srcData.input_frames_used;
spiceData->outputPosition += outputFrames; spiceData->outputPosition += outputFrames;
} }
}
spiceData->inputPosition += frames; spiceData->inputPosition += frames;
if (playbackGetState() == STREAM_STATE_SETUP_SPICE) if (playbackGetState() == STREAM_STATE_SETUP_SPICE)
{ {
/* Reserve enough data for the backend's immediate startup pulls while /* At a packet boundary, targetLowWaterFrames is the physical ring target;
* leaving the requested steady-state target afterwards. Do not add a * sourceReserveFrames accounts for the packet's average delivery phase
* complete source packet to the reserve: it can be much larger than the * and must not be prefetched a second time. Cover whichever is larger:
* device period and unnecessarily delays activation by another packet. */ * the backend's immediate startup pull or the interval until the next
* source packet. This starts at the requested average latency without
* risking an underrun before that packet arrives. */
const int bufferLength =
ringbuffer_getLength(audio.playback.buffer);
const int startupLowWaterFrames = clamp(
llrint(ceil(targetLowWaterFrames)),
INT64_C(0), (int64_t)bufferLength);
audio.playback.targetStartFrames = min( audio.playback.targetStartFrames = min(
ceil(targetBufferFrames) + (int64_t)startupLowWaterFrames +
audio.playback.deviceStartFrames, max(audio.playback.deviceStartFrames, frames),
ringbuffer_getLength(audio.playback.buffer)); (int64_t)bufferLength);
if (ringbuffer_getCount(audio.playback.buffer) >= if (ringbuffer_getCount(audio.playback.buffer) >=
audio.playback.targetStartFrames) audio.playback.targetStartFrames)
{ {
if (audio.playback.timings && !audio.playback.graph)
{
const float graphMax =
targetLatencyFrames * 1000.0 /
audio.playback.sampleRate * 2;
audio.playback.graph = app_registerGraph("PLAYBACK",
audio.playback.timings, 0.0f, graphMax,
audioGraphFormatFn);
if (!audio.playback.graph)
ringbuffer_free(&audio.playback.timings);
}
audio.playback.startupLowWaterFrames =
startupLowWaterFrames;
audio.playback.startupPacketPeriod =
max(llrint(packetSec * 1.0e9), INT64_C(1));
audio.playback.startupPacketDeadline =
now + audio.playback.startupPacketPeriod;
if (g_params.audioDebug)
DEBUG_INFO(
"Audio start: %.2f/%.2f ms target/queued",
targetLatencyFrames * 1000.0 /
audio.playback.sampleRate,
audio.playback.targetStartFrames * 1000.0 /
audio.playback.sampleRate);
playbackSetState(STREAM_STATE_SETUP_DEVICE); playbackSetState(STREAM_STATE_SETUP_DEVICE);
audio.audioDev->playback.start(); audio.audioDev->playback.start();
} }
@@ -1446,39 +1686,12 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
if (!g_params.audioDebug) if (!g_params.audioDebug)
return; return;
if (spiceData->deviceClock.valid &&
spiceData->sourceClock.updates >= 2)
{
const int64_t bufferFrames64 =
curPosition - spiceData->devReadPosition;
const int bufferFrames = clamp(
bufferFrames64, (int64_t)INT_MIN, (int64_t)INT_MAX);
const double sourcePhaseSec =
spiceData->sourceClock.phaseResidualSec;
spiceData->debugBufferFramesSum += bufferFrames;
spiceData->debugBufferFramesMin =
min(spiceData->debugBufferFramesMin, bufferFrames);
spiceData->debugBufferFramesMax =
max(spiceData->debugBufferFramesMax, bufferFrames);
spiceData->debugSourcePhaseSumSec += sourcePhaseSec;
spiceData->debugSourcePhaseSquaredSec +=
sourcePhaseSec * sourcePhaseSec;
spiceData->debugSourcePhaseAbsMaxSec =
max(spiceData->debugSourcePhaseAbsMaxSec, fabs(sourcePhaseSec));
++spiceData->debugSamples;
}
const double softwareLatencyMs = const double softwareLatencyMs =
actualLatencyFrames * 1000.0 / audio.playback.sampleRate; actualLatencyFrames * 1000.0 / audio.playback.sampleRate;
double backendLatencyMs = 0.0;
if (audio.audioDev->playback.latency)
backendLatencyMs = audio.audioDev->playback.latency() / 1000.0;
const double latencyMs = softwareLatencyMs + backendLatencyMs;
if (audio.playback.timings) if (audio.playback.graph)
{ {
const float latency = latencyMs; const float latency = softwareLatencyMs;
ringbuffer_push(audio.playback.timings, &latency); ringbuffer_push(audio.playback.timings, &latency);
app_invalidateGraph(audio.playback.graph); app_invalidateGraph(audio.playback.graph);
} }
@@ -1488,60 +1701,22 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
const double sourcePpm = spiceData->sourceRateValid ? const double sourcePpm = spiceData->sourceRateValid ?
(spiceData->sourceRateFrameSec / nominalFrameSec - 1.0) * 1.0e6 : (spiceData->sourceRateFrameSec / nominalFrameSec - 1.0) * 1.0e6 :
0.0; 0.0;
const double sourceRawPpm = spiceData->sourceRateValid ? const double devicePpm = rateClock->valid ?
(spiceData->sourceRateRawFrameSec / nominalFrameSec - 1.0) * 1.0e6 : (rateClock->frameSec / nominalFrameSec - 1.0) * 1.0e6 :
0.0;
const double devicePpm = spiceData->deviceClock.valid ?
(spiceData->deviceClock.frameSec / nominalFrameSec - 1.0) * 1.0e6 :
0.0; 0.0;
const unsigned int underruns = atomic_exchange_explicit( const unsigned int underruns = atomic_exchange_explicit(
&audio.playback.underruns, 0, memory_order_relaxed); &audio.playback.underruns, 0, memory_order_relaxed);
DEBUG_INFO( DEBUG_INFO(
"Audio sync: software latency %.2f/%.2f ms, backend %.2f ms, " "Audio sync: %.2f/%.2f ms, ratio %+.1f ppm, "
"ratio %+.1f ppm, clocks " "clocks %+.1f/%+.1f ppm, jitter %.2f ms, xruns %u/%u",
"source %+.1f (raw %+.1f)/device %+.1f ppm (%s), "
"phase error %+.2f ms, arrival jitter %.2f ms, "
"underruns %u, overruns %u, max slew %.2f ms",
softwareLatencyMs, softwareLatencyMs,
targetLatencyFrames * 1000.0 / audio.playback.sampleRate, targetLatencyFrames * 1000.0 / audio.playback.sampleRate,
backendLatencyMs, (ratio - 1.0) * 1.0e6, sourcePpm, sourceRawPpm, (ratio - 1.0) * 1.0e6, sourcePpm, devicePpm,
devicePpm, spiceData->arrivalJitterSec * 1000.0,
spiceData->deviceClockStable ? "stable" : "acquiring", underruns, spiceData->bufferOverruns);
spiceData->offsetError * 1000.0 / audio.playback.sampleRate,
spiceData->arrivalJitterSec * 1000.0, underruns,
spiceData->bufferOverruns,
spiceData->maxAbsSlewFrames * 1000.0 / audio.playback.sampleRate);
if (spiceData->debugSamples > 0)
{
const double samples = spiceData->debugSamples;
const double frameMs = 1000.0 / audio.playback.sampleRate;
const double sourcePhaseMeanMs =
spiceData->debugSourcePhaseSumSec * 1000.0 / samples;
const double sourcePhaseRmsMs =
sqrt(spiceData->debugSourcePhaseSquaredSec / samples) * 1000.0;
DEBUG_INFO(
"Audio sync detail: ring at packet start "
"%.2f/%.2f/%.2f ms avg/min/max, "
"SPICE phase residual %+.3f/%.3f/%.3f ms mean/rms/max",
spiceData->debugBufferFramesSum * frameMs / samples,
spiceData->debugBufferFramesMin * frameMs,
spiceData->debugBufferFramesMax * frameMs,
sourcePhaseMeanMs, sourcePhaseRmsMs,
spiceData->debugSourcePhaseAbsMaxSec * 1000.0);
}
spiceData->debugBufferFramesSum = 0;
spiceData->debugBufferFramesMin = INT_MAX;
spiceData->debugBufferFramesMax = INT_MIN;
spiceData->debugSourcePhaseSumSec = 0.0;
spiceData->debugSourcePhaseSquaredSec = 0.0;
spiceData->debugSourcePhaseAbsMaxSec = 0.0;
spiceData->debugSamples = 0;
spiceData->bufferOverruns = 0; spiceData->bufferOverruns = 0;
spiceData->maxAbsSlewFrames = 0;
spiceData->nextLogTime = now + INT64_C(5000000000); spiceData->nextLogTime = now + INT64_C(5000000000);
} }
} }

View File

@@ -50,6 +50,10 @@ static bool optRotateValidate (struct Option * opt, const char ** error
static bool optMicDefaultParse (struct Option * opt, const char * str); static bool optMicDefaultParse (struct Option * opt, const char * str);
static StringList optMicDefaultValues (struct Option * opt); static StringList optMicDefaultValues (struct Option * opt);
static char * optMicDefaultToString(struct Option * opt); static char * optMicDefaultToString(struct Option * opt);
static bool optAudioResamplerParse (struct Option * opt,
const char * str);
static StringList optAudioResamplerValues (struct Option * opt);
static char * optAudioResamplerToString(struct Option * opt);
static void doLicense(void); static void doLicense(void);
@@ -542,14 +546,23 @@ static struct Option options[] =
.name = "periodSize", .name = "periodSize",
.description = "Requested audio device period size in samples (0 = 10 ms)", .description = "Requested audio device period size in samples (0 = 10 ms)",
.type = OPTION_TYPE_INT, .type = OPTION_TYPE_INT,
.value.x_int = 0 .value.x_int = 512
}, },
{ {
.module = "audio", .module = "audio",
.name = "bufferLatency", .name = "latencyOffset",
.description = "Additional buffer latency in milliseconds", .description = "Latency offset added to the calculated minimum in milliseconds",
.type = OPTION_TYPE_INT, .type = OPTION_TYPE_INT,
.value.x_int = 4 .value.x_int = 6
},
{
.module = "audio",
.name = "resampler",
.description = "Audio resampler to use (auto, libsamplerate, backend)",
.type = OPTION_TYPE_CUSTOM,
.parser = optAudioResamplerParse,
.getValues = optAudioResamplerValues,
.toString = optAudioResamplerToString
}, },
{ {
.module = "audio", .module = "audio",
@@ -772,7 +785,7 @@ bool config_load(int argc, char * argv[])
g_params.audioDebug = option_get_bool("audio", "debug"); g_params.audioDebug = option_get_bool("audio", "debug");
g_params.audioPeriodSize = option_get_int("audio", "periodSize"); g_params.audioPeriodSize = option_get_int("audio", "periodSize");
g_params.audioBufferLatency = option_get_int("audio", "bufferLatency"); g_params.audioLatencyOffset = option_get_int("audio", "latencyOffset");
g_params.micShowIndicator = option_get_bool("audio", "micShowIndicator"); g_params.micShowIndicator = option_get_bool("audio", "micShowIndicator");
g_params.audioSyncVolume = option_get_bool("audio", "syncVolume"); g_params.audioSyncVolume = option_get_bool("audio", "syncVolume");
@@ -1055,3 +1068,48 @@ static char * optMicDefaultToString(struct Option * opt)
return NULL; return NULL;
} }
static bool optAudioResamplerParse(
struct Option * opt, const char * str)
{
if (!str)
return false;
if (strcasecmp(str, "auto") == 0)
g_params.audioResampler = AUDIO_RESAMPLER_AUTO;
else if (strcasecmp(str, "libsamplerate") == 0)
g_params.audioResampler = AUDIO_RESAMPLER_LIBSAMPLERATE;
else if (strcasecmp(str, "backend") == 0)
g_params.audioResampler = AUDIO_RESAMPLER_BACKEND;
else
return false;
return true;
}
static StringList optAudioResamplerValues(struct Option * opt)
{
StringList sl = stringlist_new(false);
if (!sl)
return NULL;
stringlist_push(sl, (char *)"auto");
stringlist_push(sl, (char *)"libsamplerate");
stringlist_push(sl, (char *)"backend");
return sl;
}
static char * optAudioResamplerToString(struct Option * opt)
{
switch (g_params.audioResampler)
{
case AUDIO_RESAMPLER_AUTO:
return strdup("auto");
case AUDIO_RESAMPLER_LIBSAMPLERATE:
return strdup("libsamplerate");
case AUDIO_RESAMPLER_BACKEND:
return strdup("backend");
}
return NULL;
}

View File

@@ -53,6 +53,12 @@ enum MicDefaultState {
}; };
#define MIC_DEFAULT_MAX (MIC_DEFAULT_DENY + 1) #define MIC_DEFAULT_MAX (MIC_DEFAULT_DENY + 1)
enum AudioResampler {
AUDIO_RESAMPLER_AUTO,
AUDIO_RESAMPLER_LIBSAMPLERATE,
AUDIO_RESAMPLER_BACKEND
};
struct AppState struct AppState
{ {
_Atomic(enum RunState) state; _Atomic(enum RunState) state;
@@ -239,7 +245,8 @@ struct AppParams
bool audioDebug; bool audioDebug;
int audioPeriodSize; int audioPeriodSize;
int audioBufferLatency; int audioLatencyOffset;
enum AudioResampler audioResampler;
bool micShowIndicator; bool micShowIndicator;
enum MicDefaultState micDefaultState; enum MicDefaultState micDefaultState;
bool audioSyncVolume; bool audioSyncVolume;

View File

@@ -370,9 +370,9 @@ All command line options
+------------------------+-------+--------+-------------------------------------------------------------------------------+ +------------------------+-------+--------+-------------------------------------------------------------------------------+
| Long | Short | Value | Description | | Long | Short | Value | Description |
+========================+=======+========+===============================================================================+ +========================+=======+========+===============================================================================+
| audio:periodSize | | 2048 | Requested audio device period size in samples | | audio:periodSize | | 512 | Requested audio device period size in samples |
+------------------------+-------+--------+-------------------------------------------------------------------------------+ +------------------------+-------+--------+-------------------------------------------------------------------------------+
| audio:bufferLatency | | 13 | Additional buffer latency in milliseconds | | audio:latencyOffset | | 4 | Latency offset added to the calculated minimum in milliseconds |
+------------------------+-------+--------+-------------------------------------------------------------------------------+ +------------------------+-------+--------+-------------------------------------------------------------------------------+
| audio:micDefault | | prompt | Default action when an application opens the microphone (prompt, allow, deny) | | audio:micDefault | | prompt | Default action when an application opens the microphone (prompt, allow, deny) |
+------------------------+-------+--------+-------------------------------------------------------------------------------+ +------------------------+-------+--------+-------------------------------------------------------------------------------+