[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_uint bufferErrors;
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 sampleRate;
@@ -105,6 +111,10 @@ static void pipewire_reportPlaybackErrors(void)
&pw.playback.bufferErrors, 0, memory_order_relaxed);
const unsigned int timingErrors = atomic_exchange_explicit(
&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)
DEBUG_WARN("PipeWire playback ran out of buffers %u time(s)",
@@ -112,6 +122,11 @@ static void pipewire_reportPlaybackErrors(void)
if (timingErrors)
DEBUG_WARN("PipeWire playback timing query failed %u time(s)",
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)
@@ -175,6 +190,50 @@ static inline void pipewire_updatePlaybackLatency(void)
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,
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)
{
struct pw_buffer * pbuf;
@@ -332,6 +402,7 @@ static void pipewire_playbackStopStream(void)
pw_stream_destroy(pw.playback.stream);
pw.playback.stream = NULL;
pw.playback.rateMatch = NULL;
pw.playback.resamplerEnabled = false;
atomic_store_explicit(
&pw.playback.presentationDeadline, 0, memory_order_release);
atomic_store_explicit(
@@ -341,15 +412,19 @@ static void pipewire_playbackStopStream(void)
}
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)
{
*resamplerEnabled = false;
const struct spa_pod * params[1];
uint8_t buffer[1024];
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
static const struct pw_stream_events events =
{
.version = PW_VERSION_STREAM_EVENTS,
.state_changed = pipewire_onPlaybackStateChanged,
.io_changed = pipewire_onPlaybackIoChanged,
.process = pipewire_onPlaybackProcess,
.drained = pipewire_onPlaybackDrained
@@ -359,6 +434,12 @@ static bool pipewire_playbackSetup(int channels, int sampleRate,
pw.playback.channels == channels &&
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;
*startFrames = pw.playback.startFrames;
return true;
@@ -464,6 +545,7 @@ static bool pipewire_playbackSetup(int channels, int sampleRate,
.rate = sampleRate
));
pw.playback.connectionState = PW_STREAM_STATE_CONNECTING;
const int result = pw_stream_connect(
pw.playback.stream,
PW_DIRECTION_OUTPUT,
@@ -484,6 +566,19 @@ static bool pipewire_playbackSetup(int channels, int sampleRate,
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;
atomic_store_explicit(
&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);
atomic_store_explicit(
&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);
return true;
}
@@ -932,6 +1035,9 @@ struct LG_AudioDevOps LGAD_PipeWire =
.stop = pipewire_playbackStop,
.volume = pipewire_playbackVolume,
.mute = pipewire_playbackMute,
#if PW_CHECK_VERSION(1, 4, 0)
.setRate = pipewire_playbackSetRate,
#endif
.latency = pipewire_playbackLatency
},
.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,
int requestedPeriodFrames, int * maxPeriodFrames, int * startFrames,
int requestedPeriodFrames, bool requestResampler,
bool * resamplerEnabled, int * maxPeriodFrames, int * startFrames,
LG_AudioPullFn pullFn)
{
*resamplerEnabled = false;
if (pa.sink && pa.sinkChannels == channels && pa.sinkSampleRate == sampleRate)
{
*maxPeriodFrames = pa.sinkMaxPeriodFrames;

View File

@@ -45,10 +45,13 @@ struct LG_AudioDevOps
struct
{
/* 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
*/
bool (*setup)(int channels, int sampleRate, int requestedPeriodFrames,
bool requestResampler, bool * resamplerEnabled,
int * maxPeriodFrames, int * startFrames, LG_AudioPullFn pullFn);
/* called when there is data available to start playback */
@@ -63,6 +66,10 @@ struct LG_AudioDevOps
/* [optional] called to set muting of the output */
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 */
uint64_t (*latency)(void);
}

View File

@@ -37,6 +37,7 @@
#include <string.h>
#define PLAYBACK_CLOCK_BANDWIDTH_HZ 0.05
#define PLAYBACK_ACQUIRE_PHASE_BANDWIDTH_HZ 0.05
#define PLAYBACK_PHASE_BANDWIDTH_HZ 0.005
#define PLAYBACK_OFFSET_FILTER_BANDWIDTH_HZ \
(20.0 * PLAYBACK_PHASE_BANDWIDTH_HZ)
@@ -44,6 +45,8 @@
#define PLAYBACK_MAX_RATE_CORRECTION 0.005
#define PLAYBACK_MAX_RATE_SLEW_PER_SEC 0.005
#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
* 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. */
@@ -82,6 +85,8 @@ StreamState;
typedef struct
{
int64_t nextPosition;
double outputPosition;
double appliedRatio;
int startupSilenceFrames;
}
PlaybackDeviceData;
@@ -91,7 +96,7 @@ typedef struct
bool valid;
unsigned int updates;
int64_t time;
int64_t position;
double position;
double frameSec;
double phaseResidualSec;
}
@@ -120,6 +125,10 @@ typedef struct
int64_t lastPacketTime;
int64_t lastArrivalTime;
double arrivalJitterSec;
double sourcePhaseBaselineSec;
double sourcePhaseReserveSec;
double sourcePacketDurationSec;
bool sourcePhaseBaselineValid;
bool mediaClockValid;
PlaybackRateSample rateSamples[PLAYBACK_RATE_MAX_SAMPLES];
@@ -127,10 +136,8 @@ typedef struct
unsigned int rateSampleCount;
int64_t rateLastSampleTimeMs;
int64_t rateFilterTimeMs;
double sourceRateRawFrameSec;
double sourceRateFrameSec;
bool sourceRateValid;
bool startupSyncPending;
bool bufferOverrunPending;
int devPeriodFrames;
@@ -140,6 +147,7 @@ typedef struct
int64_t deviceClockCheckTime;
double deviceClockCheckFrameSec;
double deviceClockStableSec;
double devicePositionOffsetFrames;
bool deviceClockStable;
double offsetError;
@@ -149,18 +157,10 @@ typedef struct
double lastClockRatio;
int64_t nextLogTime;
unsigned int bufferOverruns;
int maxAbsSlewFrames;
int64_t debugBufferFramesSum;
int debugBufferFramesMin;
int debugBufferFramesMax;
double debugSourcePhaseSumSec;
double debugSourcePhaseSquaredSec;
double debugSourcePhaseAbsMaxSec;
unsigned int debugSamples;
PlaybackClock sourceClock;
PlaybackClock deviceClock;
PlaybackClock outputClock;
SRC_STATE * src;
}
PlaybackSpiceData;
@@ -171,6 +171,7 @@ typedef struct
atomic_int periodFrames;
_Atomic(int64_t) time;
_Atomic(int64_t) position;
_Atomic(double) outputPosition;
}
PlaybackDeviceTiming;
@@ -191,6 +192,12 @@ typedef struct
int deviceMaxPeriodFrames;
int deviceStartFrames;
int targetStartFrames;
int startupLowWaterFrames;
int64_t startupPacketDeadline;
int64_t startupPacketPeriod;
bool backendResampler;
_Atomic(double) backendResampleRatio;
atomic_bool backendResamplerFailed;
RingBuffer buffer;
PlaybackDeviceTiming deviceTiming;
atomic_uint underruns;
@@ -238,11 +245,12 @@ typedef struct
int periodFrames;
int64_t nextTime;
int64_t nextPosition;
double outputPosition;
}
PlaybackDeviceTick;
static void playbackClockReset(PlaybackClock * clock, int64_t time,
int64_t position, double frameSec)
double position, double frameSec)
{
clock->valid = true;
clock->updates = 1;
@@ -253,7 +261,7 @@ static void playbackClockReset(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)
{
@@ -261,7 +269,7 @@ static bool playbackClockUpdate(PlaybackClock * clock, int64_t time,
return true;
}
const int64_t frames = position - clock->position;
const double frames = position - clock->position;
if (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,
int64_t position, double nominalFrameSec)
double position, double nominalFrameSec)
{
if (!clock->valid)
{
@@ -300,7 +308,7 @@ static bool playbackSourceClockUpdate(PlaybackClock * clock, int64_t time,
return true;
}
const int64_t frames = position - clock->position;
const double frames = position - clock->position;
if (frames <= 0)
return frames == 0;
@@ -329,7 +337,10 @@ static void playbackDeviceClockAcquireReset(PlaybackSpiceData * spiceData)
spiceData->deviceClockAcquireStart = INT64_MIN;
spiceData->deviceClockCheckTime = INT64_MIN;
spiceData->deviceClockStableSec = 0.0;
spiceData->devicePositionOffsetFrames = 0.0;
spiceData->deviceClockStable = false;
spiceData->ratioIntegral = 0.0;
spiceData->lastClockRatio = 1.0;
}
static bool playbackDeviceClockAcquire(
@@ -468,7 +479,6 @@ static void playbackSourceRateAdd(
(1.0 + PLAYBACK_MAX_RATE_CORRECTION))
return;
spiceData->sourceRateRawFrameSec = frameSec;
if (!spiceData->sourceRateValid)
{
spiceData->sourceRateFrameSec = frameSec;
@@ -487,7 +497,8 @@ static void playbackSourceRateAdd(
}
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;
atomic_fetch_add_explicit(&timing->sequence, 1, memory_order_relaxed);
@@ -495,6 +506,8 @@ static void playbackPublishDeviceTiming(
&timing->periodFrames, periodFrames, 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->outputPosition, outputPosition, memory_order_relaxed);
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);
tick->nextPosition =
atomic_load_explicit(&timing->position, memory_order_relaxed);
tick->outputPosition =
atomic_load_explicit(
&timing->outputPosition, memory_order_relaxed);
atomic_thread_fence(memory_order_acquire);
after = atomic_load_explicit(&timing->sequence, memory_order_relaxed);
}
@@ -714,6 +730,7 @@ static void playbackStop(void)
if (audio.playback.timings)
{
if (audio.playback.graph)
app_unregisterGraph(audio.playback.graph);
audio.playback.graph = NULL;
ringbuffer_free(&audio.playback.timings);
@@ -730,6 +747,20 @@ static int playbackPullFrames(uint8_t * dst, int frames)
return 0;
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();
if (audio.playback.buffer)
@@ -737,12 +768,32 @@ static int playbackPullFrames(uint8_t * dst, int frames)
if (playbackGetState() == STREAM_STATE_SETUP_DEVICE)
{
/* The backend may begin pulling either immediately or long after it was
* activated. Align the buffer in both directions on its first callback:
* insert silence if it started early, or discard the oldest queued audio
* if it started late. This always leaves the newest audio at the requested
* startup latency. */
* activated. Only retain enough of the source packet to reach its next
* expected delivery time; retaining the complete packet after part of
* its interval has already elapsed turns backend startup delay into
* 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) -
audio.playback.targetStartFrames;
targetFrames;
if (offset > 0)
{
data->nextPosition += offset;
@@ -760,12 +811,17 @@ static int playbackPullFrames(uint8_t * dst, int frames)
playbackSetState(STREAM_STATE_RUN);
}
/* Timestamp the dequeue boundary before the current pull. The position
* delta then describes the buffer requested by the previous callback,
* which is also what determines the elapsed device time. This remains
* correct when the backend changes quantum size. */
playbackPublishDeviceTiming(frames, now, data->nextPosition);
/* Timestamp the dequeue boundary before the current pull. The logical
* position tracks source frames consumed from the ring for latency
* measurement. With backend resampling, outputPosition separately tracks
* the equivalent number of device-rate frames. PipeWire computes the
* 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->outputPosition += frames * data->appliedRatio;
data->appliedRatio = nextRatio;
const int silenceFrames =
min(frames, data->startupSilenceFrames);
@@ -851,23 +907,11 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
if (state != STREAM_STATE_STOP)
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;
audio.playback.buffer = ringbuffer_newUnbounded(bufferFrames,
channels * sizeof(float));
if (!audio.playback.buffer)
{
audio.playback.spiceData.src =
src_delete(audio.playback.spiceData.src);
return;
}
lastChannels = channels;
lastSampleRate = sampleRate;
@@ -879,6 +923,8 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
playbackSetState(STREAM_STATE_SETUP_SPICE);
audio.playback.deviceData.nextPosition = 0;
audio.playback.deviceData.outputPosition = 0.0;
audio.playback.deviceData.appliedRatio = 1.0;
audio.playback.deviceData.startupSilenceFrames = 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.lastRatio = 1.0;
audio.playback.spiceData.lastClockRatio = 1.0;
audio.playback.spiceData.startupSyncPending = true;
audio.playback.spiceData.bufferOverrunPending = false;
audio.playback.spiceData.bufferOverruns = 0;
audio.playback.spiceData.maxAbsSlewFrames = 0;
audio.playback.spiceData.nextLogTime =
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.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.outputClock.valid = false;
playbackPrepareMediaClock(&audio.playback.spiceData, time);
atomic_store_explicit(
@@ -917,16 +959,32 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
&audio.playback.deviceTiming.time, 0, memory_order_relaxed);
atomic_store_explicit(
&audio.playback.deviceTiming.position, 0, memory_order_relaxed);
atomic_store_explicit(
&audio.playback.deviceTiming.outputPosition, 0.0,
memory_order_relaxed);
atomic_store_explicit(
&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 ?
clamp(g_params.audioPeriodSize, 1, sampleRate) :
max(sampleRate / 100, 1);
audio.playback.deviceMaxPeriodFrames = 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,
requestedPeriodFrames, &audio.playback.deviceMaxPeriodFrames,
requestedPeriodFrames, requestBackendResampler,
&audio.playback.backendResampler,
&audio.playback.deviceMaxPeriodFrames,
&audio.playback.deviceStartFrames, playbackPullFrames) ||
audio.playback.deviceMaxPeriodFrames <= 0 ||
audio.playback.deviceStartFrames < 0)
@@ -936,6 +994,30 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
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 (audio.playback.volumeChannels)
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.
if (g_params.audioDebug)
{
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(
&audio.playback.callbackState, 0, memory_order_release);
@@ -977,13 +1050,16 @@ void audio_playbackStop(void)
// playback starts again
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);
if (error)
{
DEBUG_ERROR("Failed to reset resampler: %s", src_strerror(error));
playbackStop();
}
}
break;
}
@@ -1034,8 +1110,11 @@ void audio_playbackMute(bool mute)
static double computeDevicePosition(int64_t curTime)
{
const PlaybackSpiceData * spiceData =
&audio.playback.spiceData;
return playbackClockPosition(
&audio.playback.spiceData.deviceClock, curTime);
&spiceData->deviceClock, curTime) +
spiceData->devicePositionOffsetFrames;
}
static bool playbackEnsureConversionBuffers(
@@ -1055,6 +1134,8 @@ static bool playbackEnsureConversionBuffers(
spiceData->framesInSize = frames;
}
if (!audio.playback.backendResampler)
{
const int framesOut =
(int)ceil(frames * (1.0 + PLAYBACK_MAX_RATE_CORRECTION)) + 64;
if (framesOut > spiceData->framesOutSize)
@@ -1070,6 +1151,7 @@ static bool playbackEnsureConversionBuffers(
spiceData->framesOut = output;
spiceData->framesOutSize = framesOut;
}
}
return true;
}
@@ -1113,9 +1195,6 @@ static int playbackSlewBuffer(
ringbuffer_append(audio.playback.buffer, NULL, slew);
DEBUG_ASSERT(advanced == slew);
spiceData->maxAbsSlewFrames =
max(spiceData->maxAbsSlewFrames,
slew == INT_MIN ? INT_MAX : abs(slew));
if (slew != requested)
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)
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;
/* 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 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->lastArrivalTime = now;
const bool sourceRateWasValid =
spiceData->sourceRateValid;
playbackSourceRateAdd(spiceData, nominalFrameSec);
const bool sourceRateBecameValid =
!sourceRateWasValid && spiceData->sourceRateValid;
if (!playbackSourceClockUpdate(&spiceData->sourceClock,
packetTime, spiceData->inputPosition, nominalFrameSec))
discontinuity = true;
if (spiceData->sourceRateValid)
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 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.
PlaybackDeviceTick deviceTick;
@@ -1211,8 +1352,15 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
spiceData->devPeriodFrames = deviceTick.periodFrames;
spiceData->devReadPosition =
deviceTick.nextPosition + deviceTick.periodFrames;
if (!playbackClockUpdate(&spiceData->deviceClock,
deviceTick.nextTime, deviceTick.nextPosition, nominalFrameSec))
const bool deviceClockUpdated =
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);
discontinuity = true;
@@ -1224,48 +1372,46 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
if (deviceClockBecameStable)
{
/* The first device callback already aligned the logical ring. Anchor the
* now-stable clock model to that exact read position without changing
* buffered audio. */
curTime = llrint(
spiceData->deviceClock.time +
(spiceData->devReadPosition -
spiceData->deviceClock.position) *
spiceData->deviceClock.frameSec * 1.0e9);
spiceData->sourceClock.time = curTime;
spiceData->offsetError = 0.0;
spiceData->offsetErrorIntegral = 0.0;
spiceData->ratioIntegral = 0.0;
/* Give the fitted device timeline the same latency reported by the
* acquisition model. Their position origins are otherwise unrelated, so
* switching models would create a false phase step and drive the resampler
* despite an already-correct ring level. Keep the source clock untouched:
* changing it would also disturb SPICE phase and jitter tracking. */
const double rawDevicePosition =
playbackClockPosition(&spiceData->deviceClock, curTime);
spiceData->devicePositionOffsetFrames =
spiceData->devReadPosition - sourceReserveFrames -
rawDevicePosition;
}
const int configLatencyMs = max(g_params.audioBufferLatency, 0);
const int maxPeriodFrames =
max(audio.playback.deviceMaxPeriodFrames, spiceData->devPeriodFrames);
const double configuredLatencyFrames =
configLatencyMs * audio.playback.sampleRate / 1000.0;
const double measuredJitterFrames =
(spiceData->arrivalJitterSec + 0.001) * audio.playback.sampleRate;
/* The device period, delivery jitter, packet phase, and resampler delay
* define the minimum viable latency. latencyOffset is strictly an additive
* user offset over that same minimum for both startup and steady state. */
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 =
maxPeriodFrames * 1.1 +
configuredLatencyFrames + measuredJitterFrames;
minimumBufferFrames + latencyOffsetFrames;
const double resamplerDelayFrames =
audio.playback.backendResampler ?
0.0 : PLAYBACK_RESAMPLER_DELAY_FRAMES;
const double minimumLatencyFrames =
minimumBufferFrames + resamplerDelayFrames;
const double targetLatencyFrames =
targetBufferFrames + PLAYBACK_RESAMPLER_DELAY_FRAMES;
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;
}
minimumLatencyFrames + latencyOffsetFrames;
double devPosition = DBL_MIN;
state = playbackGetState();
@@ -1290,6 +1436,7 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
}
double actualLatencyFrames = 0.0;
double actualOffsetError = 0.0;
if (spiceData->deviceClock.valid)
{
if (spiceData->deviceClockStable)
@@ -1298,11 +1445,21 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
devPosition = computeDevicePosition(curTime);
actualLatencyFrames =
curPosition - devPosition + PLAYBACK_RESAMPLER_DELAY_FRAMES;
const double actualOffsetError =
curPosition - devPosition + resamplerDelayFrames;
actualOffsetError =
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 omega =
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 += 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,
* bounded phase controller to keep the ring at its target. The gains are
* derived from the requested loop bandwidth rather than arbitrary constants.
* bounded phase controller to keep the ring at its target. While the device
* 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
* integral. This makes feed-forward updates bumpless: learning a better
* clock ratio changes how the current correction is represented without
* immediately changing the resampler ratio and moving the buffer. */
* Before the long-term source estimate is available, the phase integral
* necessarily contains the clock-rate error. Discard that provisional
* integral when measured feed-forward first takes over, then allow later
* 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 =
2.0 * M_PI * PLAYBACK_PHASE_BANDWIDTH_HZ;
const double kp =
M_SQRT2 * naturalFrequency / audio.playback.sampleRate;
2.0 * naturalFrequency / audio.playback.sampleRate;
const double ki =
naturalFrequency * naturalFrequency / audio.playback.sampleRate;
if (sourceRateBecameValid)
spiceData->ratioIntegral = 0.0;
if (spiceData->deviceClockStable &&
spiceData->sourceRateValid &&
spiceData->deviceClock.updates >= 2)
rateClock->updates >= 2)
{
const double clockRatio = clamp(
spiceData->sourceRateFrameSec /
spiceData->deviceClock.frameSec,
rateClock->frameSec,
1.0 - PLAYBACK_MAX_RATE_CORRECTION,
1.0 + PLAYBACK_MAX_RATE_CORRECTION);
spiceData->ratioIntegral -=
(clockRatio - spiceData->lastClockRatio) / ki;
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. */
const double phaseDeadbandFrames =
PLAYBACK_PHASE_DEADBAND_SEC * audio.playback.sampleRate;
double phaseError = spiceData->offsetError;
const double rawPhaseError = spiceData->offsetError;
double phaseError = rawPhaseError;
if (fabs(phaseError) <= phaseDeadbandFrames)
phaseError = 0.0;
else
phaseError -= copysign(phaseDeadbandFrames, phaseError);
const double candidateIntegral =
spiceData->ratioIntegral + phaseError * periodSec;
const bool acquiringDeviceClock =
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 =
kp * phaseError + ki * candidateIntegral;
controllerKp * controllerError +
(acquiringDeviceClock ? 0.0 :
controllerKi * candidateIntegral);
const double desiredRatio =
spiceData->lastClockRatio + phaseCorrection;
controllerBase + phaseCorrection;
const double boundedRatio = clamp(desiredRatio,
acquiringDeviceClock ? 1.0 :
1.0 - PLAYBACK_MAX_RATE_CORRECTION,
1.0 + PLAYBACK_MAX_RATE_CORRECTION);
if (desiredRatio == boundedRatio ||
(desiredRatio > boundedRatio && phaseError < 0.0) ||
(desiredRatio < boundedRatio && phaseError > 0.0))
if (!acquiringDeviceClock && spiceData->deviceClockStable &&
(desiredRatio == boundedRatio ||
(desiredRatio > boundedRatio && controllerError < 0.0) ||
(desiredRatio < boundedRatio && controllerError > 0.0)))
spiceData->ratioIntegral = candidateIntegral;
const double maxRatioStep =
@@ -1386,6 +1580,17 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
spiceData->lastRatio + maxRatioStep);
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;
while (consumed < frames)
{
@@ -1423,21 +1628,56 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
consumed += srcData.input_frames_used;
spiceData->outputPosition += outputFrames;
}
}
spiceData->inputPosition += frames;
if (playbackGetState() == STREAM_STATE_SETUP_SPICE)
{
/* Reserve enough data for the backend's immediate startup pulls while
* leaving the requested steady-state target afterwards. Do not add a
* complete source packet to the reserve: it can be much larger than the
* device period and unnecessarily delays activation by another packet. */
/* At a packet boundary, targetLowWaterFrames is the physical ring target;
* sourceReserveFrames accounts for the packet's average delivery phase
* and must not be prefetched a second time. Cover whichever is larger:
* 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(
ceil(targetBufferFrames) +
audio.playback.deviceStartFrames,
ringbuffer_getLength(audio.playback.buffer));
(int64_t)startupLowWaterFrames +
max(audio.playback.deviceStartFrames, frames),
(int64_t)bufferLength);
if (ringbuffer_getCount(audio.playback.buffer) >=
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);
audio.audioDev->playback.start();
}
@@ -1446,39 +1686,12 @@ void audio_playbackData(uint8_t * data, size_t size, uint32_t time)
if (!g_params.audioDebug)
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 =
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);
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 ?
(spiceData->sourceRateFrameSec / nominalFrameSec - 1.0) * 1.0e6 :
0.0;
const double sourceRawPpm = spiceData->sourceRateValid ?
(spiceData->sourceRateRawFrameSec / nominalFrameSec - 1.0) * 1.0e6 :
0.0;
const double devicePpm = spiceData->deviceClock.valid ?
(spiceData->deviceClock.frameSec / nominalFrameSec - 1.0) * 1.0e6 :
const double devicePpm = rateClock->valid ?
(rateClock->frameSec / nominalFrameSec - 1.0) * 1.0e6 :
0.0;
const unsigned int underruns = atomic_exchange_explicit(
&audio.playback.underruns, 0, memory_order_relaxed);
DEBUG_INFO(
"Audio sync: software latency %.2f/%.2f ms, backend %.2f ms, "
"ratio %+.1f ppm, clocks "
"source %+.1f (raw %+.1f)/device %+.1f ppm (%s), "
"phase error %+.2f ms, arrival jitter %.2f ms, "
"underruns %u, overruns %u, max slew %.2f ms",
"Audio sync: %.2f/%.2f ms, ratio %+.1f ppm, "
"clocks %+.1f/%+.1f ppm, jitter %.2f ms, xruns %u/%u",
softwareLatencyMs,
targetLatencyFrames * 1000.0 / audio.playback.sampleRate,
backendLatencyMs, (ratio - 1.0) * 1.0e6, sourcePpm, sourceRawPpm,
devicePpm,
spiceData->deviceClockStable ? "stable" : "acquiring",
spiceData->offsetError * 1000.0 / audio.playback.sampleRate,
spiceData->arrivalJitterSec * 1000.0, underruns,
spiceData->bufferOverruns,
spiceData->maxAbsSlewFrames * 1000.0 / audio.playback.sampleRate);
(ratio - 1.0) * 1.0e6, sourcePpm, devicePpm,
spiceData->arrivalJitterSec * 1000.0,
underruns, spiceData->bufferOverruns);
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->maxAbsSlewFrames = 0;
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 StringList optMicDefaultValues (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);
@@ -542,14 +546,23 @@ static struct Option options[] =
.name = "periodSize",
.description = "Requested audio device period size in samples (0 = 10 ms)",
.type = OPTION_TYPE_INT,
.value.x_int = 0
.value.x_int = 512
},
{
.module = "audio",
.name = "bufferLatency",
.description = "Additional buffer latency in milliseconds",
.name = "latencyOffset",
.description = "Latency offset added to the calculated minimum in milliseconds",
.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",
@@ -772,7 +785,7 @@ bool config_load(int argc, char * argv[])
g_params.audioDebug = option_get_bool("audio", "debug");
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.audioSyncVolume = option_get_bool("audio", "syncVolume");
@@ -1055,3 +1068,48 @@ static char * optMicDefaultToString(struct Option * opt)
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)
enum AudioResampler {
AUDIO_RESAMPLER_AUTO,
AUDIO_RESAMPLER_LIBSAMPLERATE,
AUDIO_RESAMPLER_BACKEND
};
struct AppState
{
_Atomic(enum RunState) state;
@@ -239,7 +245,8 @@ struct AppParams
bool audioDebug;
int audioPeriodSize;
int audioBufferLatency;
int audioLatencyOffset;
enum AudioResampler audioResampler;
bool micShowIndicator;
enum MicDefaultState micDefaultState;
bool audioSyncVolume;

View File

@@ -370,9 +370,9 @@ All command line options
+------------------------+-------+--------+-------------------------------------------------------------------------------+
| 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) |
+------------------------+-------+--------+-------------------------------------------------------------------------------+