From d956dd8fd30f844f26797c537349ad3a4dbfde4c Mon Sep 17 00:00:00 2001 From: Geoffrey McRae Date: Tue, 28 Jul 2026 21:07:02 +1000 Subject: [PATCH] [client] audio: rework synchronization for low latency Drive playback timing from audio sample positions instead of packet arrival cadence. Use SPICE multimedia timestamps to anchor streams, detect discontinuities, and estimate the long-term source rate. Ignore their coarse phase corrections when steering the playback buffer. Measure the device clock independently and wait for it to stabilize before enabling a bounded, slew-limited phase controller. Align startup buffering from logical producer and consumer positions so backend startup reserves do not become persistent latency. Size the buffer from the backend period, measured delivery jitter, and resampler delay. Update PipeWire and PulseAudio to report their actual startup requirements and presentation latency. Default to a 10 ms device period with 4 ms of additional buffering, and make detailed synchronization diagnostics opt-in through audio:debug. This reduces startup and steady-state latency while compensating for physical and virtual device clock drift without reacting to transport jitter or SPICE timestamp quantization. --- client/audiodevs/PipeWire/pipewire.c | 135 ++- client/audiodevs/PulseAudio/pulseaudio.c | 149 ++- client/include/interface/audiodev.h | 5 +- client/src/audio.c | 1198 ++++++++++++++++------ client/src/audio.h | 2 +- client/src/config.c | 14 +- client/src/main.h | 1 + repos/PureSpice | 2 +- 8 files changed, 1123 insertions(+), 383 deletions(-) diff --git a/client/audiodevs/PipeWire/pipewire.c b/client/audiodevs/PipeWire/pipewire.c index fcc8a17a..af0a86d6 100644 --- a/client/audiodevs/PipeWire/pipewire.c +++ b/client/audiodevs/PipeWire/pipewire.c @@ -22,8 +22,11 @@ #include #include +#include #include +#include #include +#include #include "common/debug.h" #include "common/stringutils.h" @@ -48,7 +51,7 @@ struct PipeWire { struct pw_stream * stream; struct spa_io_rate_match * rateMatch; - struct pw_time time; + _Atomic(int64_t) presentationDeadline; int channels; int sampleRate; @@ -77,6 +80,42 @@ struct PipeWire static struct PipeWire pw = {0}; +static int64_t pipewire_monotonicTime(void) +{ + struct timespec time; + clock_gettime(CLOCK_MONOTONIC, &time); + return SPA_TIMESPEC_TO_NSEC(&time); +} + +static void pipewire_updatePlaybackLatency(void) +{ + struct pw_time time; +#if PW_CHECK_VERSION(0, 3, 50) + if (pw_stream_get_time_n(pw.playback.stream, &time, sizeof(time)) < 0) +#else + if (pw_stream_get_time(pw.playback.stream, &time) < 0) +#endif + { + DEBUG_ERROR("pw_stream_get_time failed"); + return; + } + + if (time.rate.num == 0 || time.rate.denom == 0) + return; + +#if PW_CHECK_VERSION(0, 3, 50) + const double latencyTicks = + time.delay + (double)time.queued + time.buffered; +#else + const double latencyTicks = + time.delay + (double)time.queued / pw.playback.stride; +#endif + const int64_t latencyNs = llrint(max(0.0, latencyTicks) * + time.rate.num * SPA_NSEC_PER_SEC / time.rate.denom); + atomic_store_explicit(&pw.playback.presentationDeadline, + time.now + latencyNs, memory_order_release); +} + static void pipewire_onPlaybackIoChanged(void * userdata, uint32_t id, void * data, uint32_t size) { @@ -92,14 +131,6 @@ static void pipewire_onPlaybackProcess(void * userdata) { struct pw_buffer * pbuf; -#if PW_CHECK_VERSION(0, 3, 50) - if (pw_stream_get_time_n(pw.playback.stream, &pw.playback.time, - sizeof(pw.playback.time)) < 0) -#else - if (pw_stream_get_time(pw.playback.stream, &pw.playback.time) < 0) -#endif - DEBUG_ERROR("pw_stream_get_time failed"); - if (!(pbuf = pw_stream_dequeue_buffer(pw.playback.stream))) { DEBUG_WARN("out of buffers"); @@ -110,7 +141,10 @@ static void pipewire_onPlaybackProcess(void * userdata) uint8_t * dst; if (!(dst = sbuf->datas[0].data)) + { + pw_stream_return_buffer(pw.playback.stream, pbuf); return; + } int frames = sbuf->datas[0].maxsize / pw.playback.stride; if (pw.playback.rateMatch && pw.playback.rateMatch->size > 0) @@ -121,6 +155,7 @@ static void pipewire_onPlaybackProcess(void * userdata) { sbuf->datas[0].chunk->size = 0; pw_stream_queue_buffer(pw.playback.stream, pbuf); + pipewire_updatePlaybackLatency(); return; } @@ -130,6 +165,7 @@ static void pipewire_onPlaybackProcess(void * userdata) sbuf->datas[0].chunk->size = frames * pw.playback.stride; pw_stream_queue_buffer(pw.playback.stream, pbuf); + pipewire_updatePlaybackLatency(); } static void pipewire_onPlaybackDrained(void * userdata) @@ -221,10 +257,12 @@ static void pipewire_playbackStopStream(void) pw_stream_destroy(pw.playback.stream); pw.playback.stream = NULL; pw.playback.rateMatch = NULL; + atomic_store_explicit( + &pw.playback.presentationDeadline, 0, memory_order_release); pw_thread_loop_unlock(pw.thread); } -static void pipewire_playbackSetup(int channels, int sampleRate, +static bool pipewire_playbackSetup(int channels, int sampleRate, int requestedPeriodFrames, int * maxPeriodFrames, int * startFrames, LG_AudioPullFn pullFn) { @@ -245,7 +283,7 @@ static void pipewire_playbackSetup(int channels, int sampleRate, { *maxPeriodFrames = pw.playback.maxPeriodFrames; *startFrames = pw.playback.startFrames; - return; + return true; } pipewire_playbackStopStream(); @@ -271,6 +309,12 @@ static void pipewire_playbackSetup(int channels, int sampleRate, PW_KEY_NODE_LATENCY , requestedNodeLatency, NULL ); + if (!props) + { + pw_thread_loop_unlock(pw.thread); + DEBUG_ERROR("Failed to create playback stream properties"); + return false; + } const char * device = option_get_string("pipewire", "outDevice"); if (device) @@ -290,23 +334,31 @@ static void pipewire_playbackSetup(int channels, int sampleRate, NULL ); + if (!pw.playback.stream) + { + pw_thread_loop_unlock(pw.thread); + DEBUG_ERROR("Failed to create the stream"); + return false; + } + // The user can override the default node latency with the PIPEWIRE_LATENCY // environment variable, so get the actual node latency value from the stream. // The actual quantum size may be lower than this value depending on what else // is using the audio device, but we can treat this value as a maximum const struct pw_properties * properties = pw_stream_get_properties(pw.playback.stream); - const char *actualNodeLatency = - pw_properties_get(properties, PW_KEY_NODE_LATENCY); - DEBUG_ASSERT(actualNodeLatency != NULL); + const char *actualNodeLatency = properties ? + pw_properties_get(properties, PW_KEY_NODE_LATENCY) : NULL; unsigned num, denom; - if (sscanf(actualNodeLatency, "%u/%u", &num, &denom) != 2 || - denom != sampleRate) + if (!actualNodeLatency || + sscanf(actualNodeLatency, "%u/%u", &num, &denom) != 2 || + num == 0 || num > INT_MAX || denom != sampleRate) { DEBUG_WARN( "PIPEWIRE_LATENCY value '%s' is invalid or does not match stream sample " - "rate; using %d/%d", actualNodeLatency, requestedPeriodFrames, + "rate; using %d/%d", + actualNodeLatency ? actualNodeLatency : "(unset)", requestedPeriodFrames, sampleRate); struct spa_dict_item items[] = { @@ -327,13 +379,6 @@ static void pipewire_playbackSetup(int channels, int sampleRate, *maxPeriodFrames = pw.playback.maxPeriodFrames; *startFrames = pw.playback.startFrames; - if (!pw.playback.stream) - { - pw_thread_loop_unlock(pw.thread); - DEBUG_ERROR("Failed to create the stream"); - return; - } - params[0] = spa_format_audio_raw_build(&b, SPA_PARAM_EnumFormat, &SPA_AUDIO_INFO_RAW_INIT( .format = SPA_AUDIO_FORMAT_F32, @@ -341,7 +386,7 @@ static void pipewire_playbackSetup(int channels, int sampleRate, .rate = sampleRate )); - pw_stream_connect( + const int result = pw_stream_connect( pw.playback.stream, PW_DIRECTION_OUTPUT, PW_ID_ANY, @@ -351,7 +396,21 @@ static void pipewire_playbackSetup(int channels, int sampleRate, PW_STREAM_FLAG_INACTIVE, params, 1); + if (result < 0) + { + DEBUG_ERROR("Failed to connect playback stream: %s", spa_strerror(result)); + 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); pw_thread_loop_unlock(pw.thread); + return true; } static void pipewire_playbackStart(void) @@ -431,30 +490,12 @@ static void pipewire_playbackMute(bool mute) static uint64_t pipewire_playbackLatency(void) { -#if PW_CHECK_VERSION(0, 3, 50) - if (pw.playback.time.rate.num == 0) + const int64_t deadline = atomic_load_explicit( + &pw.playback.presentationDeadline, memory_order_acquire); + if (deadline <= 0) return 0; - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - - // diff in ns - int64_t diff = SPA_TIMESPEC_TO_NSEC(&ts) - pw.playback.time.now; - - // elapsed frames - int64_t elapsed = - (pw.playback.time.rate.denom * diff) / - (pw.playback.time.rate.num * SPA_NSEC_PER_SEC); - - const int64_t buffered = pw.playback.time.buffered + pw.playback.time.queued; - int64_t latency = (buffered * 1000 / pw.playback.sampleRate) + - ((pw.playback.time.delay - elapsed) * 1000 * - pw.playback.time.rate.num / pw.playback.time.rate.denom); - - return max(0, -latency); -#else - return pw.playback.time.delay + pw.playback.time.queued / pw.playback.stride; -#endif + return max(INT64_C(0), deadline - pipewire_monotonicTime()) / 1000; } static void pipewire_recordStopStream(void) diff --git a/client/audiodevs/PulseAudio/pulseaudio.c b/client/audiodevs/PulseAudio/pulseaudio.c index a472ffbb..87c33c0d 100644 --- a/client/audiodevs/PulseAudio/pulseaudio.c +++ b/client/audiodevs/PulseAudio/pulseaudio.c @@ -23,8 +23,10 @@ #include #include #include +#include #include "common/debug.h" +#include "common/time.h" struct PulseAudio { @@ -44,10 +46,17 @@ struct PulseAudio int sinkChannels; int sinkStride; LG_AudioPullFn sinkPullFn; + _Atomic(int64_t) sinkPresentationDeadline; }; static struct PulseAudio pa = {0}; +static void pulseaudio_unrefOperation(pa_operation * operation) +{ + if (operation) + pa_operation_unref(operation); +} + static void pulseaudio_sink_input_cb(pa_context *c, const pa_sink_input_info *i, int eol, void *userdata) { @@ -69,7 +78,7 @@ static void pulseaudio_subscribe_cb(pa_context *c, { pa_operation *o = pa_context_get_sink_input_info(c, index, pulseaudio_sink_input_cb, NULL); - pa_operation_unref(o); + pulseaudio_unrefOperation(o); } break; } @@ -194,10 +203,15 @@ static void pulseaudio_sink_close_nl(void) if (!pa.sink) return; + pa_stream_set_state_callback(pa.sink, NULL, NULL); pa_stream_set_write_callback(pa.sink, NULL, NULL); - pa_stream_flush(pa.sink, NULL, NULL); + pa_stream_set_underflow_callback(pa.sink, NULL, NULL); + pa_stream_set_overflow_callback(pa.sink, NULL, NULL); + pulseaudio_unrefOperation(pa_stream_flush(pa.sink, NULL, NULL)); pa_stream_unref(pa.sink); pa.sink = NULL; + atomic_store_explicit( + &pa.sinkPresentationDeadline, 0, memory_order_release); } static void pulseaudio_free(void) @@ -224,10 +238,12 @@ static void pulseaudio_state_cb(pa_stream * p, void * userdata) { if (pa.sinkStarting && pa_stream_get_state(pa.sink) == PA_STREAM_READY) { - pa_stream_cork(pa.sink, 0, NULL, NULL); + pulseaudio_unrefOperation(pa_stream_cork(pa.sink, 0, NULL, NULL)); pa.sinkCorked = false; pa.sinkStarting = false; } + + pa_threaded_mainloop_signal(pa.loop, 0); } static void pulseaudio_write_cb(pa_stream * p, size_t nbytes, void * userdata) @@ -239,12 +255,38 @@ static void pulseaudio_write_cb(pa_stream * p, size_t nbytes, void * userdata) uint8_t * dst; - pa_stream_begin_write(p, (void **)&dst, &nbytes); + if (pa_stream_begin_write(p, (void **)&dst, &nbytes) < 0) + { + DEBUG_ERROR("pa_stream_begin_write failed: %s", + pa_strerror(pa_context_errno(pa.context))); + return; + } int frames = nbytes / pa.sinkStride; frames = pa.sinkPullFn(dst, frames); + if (frames <= 0) + { + pa_stream_cancel_write(p); + return; + } - pa_stream_write(p, dst, frames * pa.sinkStride, NULL, 0, PA_SEEK_RELATIVE); + if (pa_stream_write( + p, dst, frames * pa.sinkStride, NULL, 0, PA_SEEK_RELATIVE) < 0) + { + DEBUG_ERROR("pa_stream_write failed: %s", + pa_strerror(pa_context_errno(pa.context))); + pa_stream_cancel_write(p); + return; + } + + pa_usec_t latency; + int negative; + if (pa_stream_get_latency(p, &latency, &negative) == 0) + { + const int64_t latencyNs = negative ? 0 : (int64_t)latency * 1000; + atomic_store_explicit(&pa.sinkPresentationDeadline, + (int64_t)nanotime() + latencyNs, memory_order_release); + } } static void pulseaudio_underflow_cb(pa_stream * p, void * userdata) @@ -257,7 +299,7 @@ static void pulseaudio_overflow_cb(pa_stream * p, void * userdata) DEBUG_WARN("Overflow"); } -static void pulseaudio_setup(int channels, int sampleRate, +static bool pulseaudio_setup(int channels, int sampleRate, int requestedPeriodFrames, int * maxPeriodFrames, int * startFrames, LG_AudioPullFn pullFn) { @@ -265,7 +307,7 @@ static void pulseaudio_setup(int channels, int sampleRate, { *maxPeriodFrames = pa.sinkMaxPeriodFrames; *startFrames = pa.sinkStartFrames; - return; + return true; } pa_sample_spec spec = { @@ -289,30 +331,76 @@ static void pulseaudio_setup(int channels, int sampleRate, pa.sinkChannels = channels; pa.sinkSampleRate = sampleRate; + pa.sinkStride = stride; + pa.sinkPullFn = pullFn; + pa.sinkCorked = true; + pa.sinkStarting = false; pa.sink = pa_stream_new(pa.context, "Looking Glass", &spec, NULL); + if (!pa.sink) + { + DEBUG_ERROR("Failed to create PulseAudio stream: %s", + pa_strerror(pa_context_errno(pa.context))); + pa_threaded_mainloop_unlock(pa.loop); + return false; + } + pa_stream_set_state_callback (pa.sink, pulseaudio_state_cb , NULL); pa_stream_set_write_callback (pa.sink, pulseaudio_write_cb , NULL); pa_stream_set_underflow_callback(pa.sink, pulseaudio_underflow_cb, NULL); pa_stream_set_overflow_callback (pa.sink, pulseaudio_overflow_cb , NULL); - pa_stream_connect_playback(pa.sink, NULL, &attribs, PA_STREAM_START_CORKED, - NULL, NULL); + const pa_stream_flags_t flags = + PA_STREAM_START_CORKED | + PA_STREAM_ADJUST_LATENCY | + PA_STREAM_INTERPOLATE_TIMING | + PA_STREAM_AUTO_TIMING_UPDATE; + if (pa_stream_connect_playback( + pa.sink, NULL, &attribs, flags, NULL, NULL) < 0) + { + DEBUG_ERROR("Failed to connect PulseAudio stream: %s", + pa_strerror(pa_context_errno(pa.context))); + pulseaudio_sink_close_nl(); + pa_threaded_mainloop_unlock(pa.loop); + return false; + } - pa.sinkStride = stride; - pa.sinkPullFn = pullFn; - pa.sinkMaxPeriodFrames = requestedPeriodFrames; - pa.sinkCorked = true; - pa.sinkStarting = false; + while (pa_stream_get_state(pa.sink) == PA_STREAM_CREATING) + pa_threaded_mainloop_wait(pa.loop); - // If something else is, or was recently using a small latency value, - // PulseAudio can request way more data at startup than is reasonable - pa.sinkStartFrames = requestedPeriodFrames * 4; + if (pa_stream_get_state(pa.sink) != PA_STREAM_READY) + { + DEBUG_ERROR("PulseAudio stream did not become ready: %s", + pa_strerror(pa_context_errno(pa.context))); + pulseaudio_sink_close_nl(); + pa_threaded_mainloop_unlock(pa.loop); + return false; + } - *maxPeriodFrames = requestedPeriodFrames; + const pa_buffer_attr * actual = pa_stream_get_buffer_attr(pa.sink); + const uint64_t minRequestFrames = + actual && actual->minreq != UINT32_MAX ? + actual->minreq / (uint64_t)stride : requestedPeriodFrames; + const uint64_t targetFrames = + actual && actual->tlength != UINT32_MAX ? + actual->tlength / (uint64_t)stride : + (uint64_t)requestedPeriodFrames * 2; + const int actualMinRequest = + clamp(minRequestFrames, UINT64_C(1), (uint64_t)INT_MAX); + const int actualTarget = + clamp(max(targetFrames, minRequestFrames), + UINT64_C(1), (uint64_t)INT_MAX); + + pa.sinkMaxPeriodFrames = actualMinRequest; + pa.sinkStartFrames = actualTarget; + + *maxPeriodFrames = pa.sinkMaxPeriodFrames; *startFrames = pa.sinkStartFrames; + atomic_store_explicit( + &pa.sinkPresentationDeadline, 0, memory_order_release); pa_threaded_mainloop_unlock(pa.loop); + return true; } static void pulseaudio_start(void) @@ -327,7 +415,7 @@ static void pulseaudio_start(void) pa.sinkStarting = true; else { - pa_stream_cork(pa.sink, 0, NULL, NULL); + pulseaudio_unrefOperation(pa_stream_cork(pa.sink, 0, NULL, NULL)); pa.sinkCorked = false; } @@ -343,9 +431,11 @@ static void pulseaudio_stop(void) if (needLock) pa_threaded_mainloop_lock(pa.loop); - pa_stream_cork(pa.sink, 1, NULL, NULL); + pulseaudio_unrefOperation(pa_stream_cork(pa.sink, 1, NULL, NULL)); pa.sinkCorked = true; pa.sinkStarting = false; + atomic_store_explicit( + &pa.sinkPresentationDeadline, 0, memory_order_release); if (needLock) pa_threaded_mainloop_unlock(pa.loop); @@ -362,7 +452,8 @@ static void pulseaudio_volume(int channels, const uint16_t volume[]) 9.3234e-7 * pow(1.000211902, volume[i]) - 0.000172787); pa_threaded_mainloop_lock(pa.loop); - pa_context_set_sink_input_volume(pa.context, pa.sinkIndex, &v, NULL, NULL); + pulseaudio_unrefOperation(pa_context_set_sink_input_volume( + pa.context, pa.sinkIndex, &v, NULL, NULL)); pa_threaded_mainloop_unlock(pa.loop); } @@ -373,10 +464,21 @@ static void pulseaudio_mute(bool mute) pa.sinkMuted = mute; pa_threaded_mainloop_lock(pa.loop); - pa_context_set_sink_input_mute(pa.context, pa.sinkIndex, mute, NULL, NULL); + pulseaudio_unrefOperation(pa_context_set_sink_input_mute( + pa.context, pa.sinkIndex, mute, NULL, NULL)); pa_threaded_mainloop_unlock(pa.loop); } +static uint64_t pulseaudio_latency(void) +{ + const int64_t deadline = atomic_load_explicit( + &pa.sinkPresentationDeadline, memory_order_acquire); + if (deadline <= 0) + return 0; + + return max(INT64_C(0), deadline - (int64_t)nanotime()) / 1000; +} + struct LG_AudioDevOps LGAD_PulseAudio = { .name = "PulseAudio", @@ -388,6 +490,7 @@ struct LG_AudioDevOps LGAD_PulseAudio = .start = pulseaudio_start, .stop = pulseaudio_stop, .volume = pulseaudio_volume, - .mute = pulseaudio_mute + .mute = pulseaudio_mute, + .latency = pulseaudio_latency } }; diff --git a/client/include/interface/audiodev.h b/client/include/interface/audiodev.h index cb2b8268..82c5bc4e 100644 --- a/client/include/interface/audiodev.h +++ b/client/include/interface/audiodev.h @@ -44,10 +44,11 @@ struct LG_AudioDevOps struct { - /* setup the stream for playback but don't start it yet + /* setup the stream for playback but don't start it yet, returning false + * if the stream could not be configured * Note: the pull function returns f32 samples */ - void (*setup)(int channels, int sampleRate, int requestedPeriodFrames, + bool (*setup)(int channels, int sampleRate, int requestedPeriodFrames, int * maxPeriodFrames, int * startFrames, LG_AudioPullFn pullFn); /* called when there is data available to start playback */ diff --git a/client/src/audio.c b/client/src/audio.c index f41e7417..33ae625a 100644 --- a/client/src/audio.c +++ b/client/src/audio.c @@ -29,11 +29,36 @@ #include "dynamic/audiodev.h" #include +#include #include #include #include +#include #include +#define PLAYBACK_CLOCK_BANDWIDTH_HZ 0.05 +#define PLAYBACK_PHASE_BANDWIDTH_HZ 0.005 +#define PLAYBACK_OFFSET_FILTER_BANDWIDTH_HZ \ + (20.0 * PLAYBACK_PHASE_BANDWIDTH_HZ) +#define PLAYBACK_PHASE_DEADBAND_SEC 0.0005 +#define PLAYBACK_MAX_RATE_CORRECTION 0.005 +#define PLAYBACK_MAX_RATE_SLEW_PER_SEC 0.005 +#define PLAYBACK_MAX_JITTER_SEC 0.1 +/* 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. */ +#define PLAYBACK_RESAMPLER_DELAY_FRAMES 20 +#define PLAYBACK_TIMESTAMP_DISCONTINUITY_MS 2000 +#define PLAYBACK_RATE_WINDOW_MS 60000 +#define PLAYBACK_RATE_MIN_SPAN_MS 45000 +#define PLAYBACK_RATE_SAMPLE_INTERVAL_MS 100 +#define PLAYBACK_RATE_FILTER_TIME_SEC 60.0 +#define PLAYBACK_RATE_MAX_SAMPLES 1024 +#define PLAYBACK_DEVICE_RATE_CHECK_SEC 1.0 +#define PLAYBACK_DEVICE_RATE_STABLE_SEC 2.0 +#define PLAYBACK_DEVICE_RATE_STABLE_DELTA_PPM 50.0 +#define PLAYBACK_DEVICE_RATE_MAX_ACQUIRE_SEC 20.0 + typedef enum { STREAM_STATE_STOP, @@ -56,43 +81,95 @@ StreamState; typedef struct { - int periodFrames; - double periodSec; - int64_t nextTime; int64_t nextPosition; - double b; - double c; } PlaybackDeviceData; +typedef struct +{ + bool valid; + unsigned int updates; + int64_t time; + int64_t position; + double frameSec; + double phaseResidualSec; +} +PlaybackClock; + +typedef struct +{ + int64_t timeMs; + int64_t position; +} +PlaybackRateSample; + typedef struct { float * framesIn; float * framesOut; + int framesInSize; int framesOutSize; - int periodFrames; - double periodSec; - int64_t nextTime; - int64_t nextPosition; - double b; - double c; + int64_t inputPosition; + int64_t outputPosition; + + uint32_t mediaTime; + int64_t mediaTimeMs; + int64_t mediaLocalOrigin; + int64_t lastPacketTime; + int64_t lastArrivalTime; + double arrivalJitterSec; + bool mediaClockValid; + + PlaybackRateSample rateSamples[PLAYBACK_RATE_MAX_SAMPLES]; + unsigned int rateSampleStart; + unsigned int rateSampleCount; + int64_t rateLastSampleTimeMs; + int64_t rateFilterTimeMs; + double sourceRateRawFrameSec; + double sourceRateFrameSec; + bool sourceRateValid; + bool startupSyncPending; int devPeriodFrames; - int64_t devLastTime; - int64_t devNextTime; - int64_t devLastPosition; - int64_t devNextPosition; + int64_t devReadPosition; + unsigned int deviceTimingSequence; + int64_t deviceClockAcquireStart; + int64_t deviceClockCheckTime; + double deviceClockCheckFrameSec; + double deviceClockStableSec; + bool deviceClockStable; double offsetError; double offsetErrorIntegral; - double ratioIntegral; + double lastRatio; + double lastClockRatio; + int64_t nextLogTime; + int64_t debugBufferFramesSum; + int debugBufferFramesMin; + int debugBufferFramesMax; + double debugSourcePhaseSumSec; + double debugSourcePhaseSquaredSec; + double debugSourcePhaseAbsMaxSec; + unsigned int debugSamples; + + PlaybackClock sourceClock; + PlaybackClock deviceClock; SRC_STATE * src; } PlaybackSpiceData; +typedef struct +{ + atomic_uint sequence; + atomic_int periodFrames; + _Atomic(int64_t) time; + _Atomic(int64_t) position; +} +PlaybackDeviceTiming; + typedef struct { struct LG_AudioDevOps * audioDev; @@ -111,7 +188,8 @@ typedef struct int deviceStartFrames; int targetStartFrames; RingBuffer buffer; - RingBuffer deviceTiming; + PlaybackDeviceTiming deviceTiming; + atomic_uint underruns; RingBuffer timings; GraphHandle graph; @@ -159,6 +237,338 @@ typedef struct } PlaybackDeviceTick; +static void playbackClockReset(PlaybackClock * clock, int64_t time, + int64_t position, double frameSec) +{ + clock->valid = true; + clock->updates = 1; + clock->time = time; + clock->position = position; + clock->frameSec = frameSec; + clock->phaseResidualSec = 0.0; +} + +static bool playbackClockUpdate(PlaybackClock * clock, int64_t time, + int64_t position, double nominalFrameSec) +{ + if (!clock->valid) + { + playbackClockReset(clock, time, position, nominalFrameSec); + return true; + } + + const int64_t frames = position - clock->position; + if (frames <= 0) + return frames == 0; + + const double predicted = + clock->time + frames * clock->frameSec * 1.0e9; + const double error = (time - predicted) * 1.0e-9; + if (fabs(error) >= 0.2) + { + playbackClockReset(clock, time, position, nominalFrameSec); + return false; + } + clock->phaseResidualSec = error; + + const double periodSec = frames * clock->frameSec; + const double omega = + 2.0 * M_PI * PLAYBACK_CLOCK_BANDWIDTH_HZ * periodSec; + const double b = M_SQRT2 * omega; + const double c = omega * omega; + + clock->time = llrint(predicted + b * error * 1.0e9); + clock->position = position; + clock->frameSec += c * error / frames; + clock->frameSec = clamp(clock->frameSec, + nominalFrameSec * (1.0 - PLAYBACK_MAX_RATE_CORRECTION), + nominalFrameSec * (1.0 + PLAYBACK_MAX_RATE_CORRECTION)); + ++clock->updates; + return true; +} + +static bool playbackSourceClockUpdate(PlaybackClock * clock, int64_t time, + int64_t position, double nominalFrameSec) +{ + if (!clock->valid) + { + playbackClockReset(clock, time, position, nominalFrameSec); + return true; + } + + const int64_t frames = position - clock->position; + if (frames <= 0) + return frames == 0; + + const double predicted = + clock->time + frames * clock->frameSec * 1.0e9; + const double residual = (time - predicted) * 1.0e-9; + if (fabs(residual) >= 0.2) + { + playbackClockReset(clock, time, position, nominalFrameSec); + return false; + } + + /* SPICE multimedia time periodically changes phase by several + * milliseconds. Preserve it as a diagnostic and discontinuity signal, but + * advance the source clock solely from decoded sample position. Short-term + * timestamp corrections must not move the playback buffer. */ + clock->time = llrint(predicted); + clock->position = position; + clock->phaseResidualSec = residual; + ++clock->updates; + return true; +} + +static void playbackDeviceClockAcquireReset(PlaybackSpiceData * spiceData) +{ + spiceData->deviceClockAcquireStart = INT64_MIN; + spiceData->deviceClockCheckTime = INT64_MIN; + spiceData->deviceClockStableSec = 0.0; + spiceData->deviceClockStable = false; +} + +static bool playbackDeviceClockAcquire( + PlaybackSpiceData * spiceData, int64_t time) +{ + if (spiceData->deviceClockStable) + return false; + + if (spiceData->deviceClockAcquireStart == INT64_MIN) + { + spiceData->deviceClockAcquireStart = time; + spiceData->deviceClockCheckTime = time; + spiceData->deviceClockCheckFrameSec = + spiceData->deviceClock.frameSec; + return false; + } + + const double checkSec = + (time - spiceData->deviceClockCheckTime) * 1.0e-9; + if (checkSec < PLAYBACK_DEVICE_RATE_CHECK_SEC) + return false; + + const double rateDeltaPpm = fabs( + spiceData->deviceClock.frameSec / + spiceData->deviceClockCheckFrameSec - 1.0) * 1.0e6; + if (rateDeltaPpm <= PLAYBACK_DEVICE_RATE_STABLE_DELTA_PPM) + spiceData->deviceClockStableSec += checkSec; + else + spiceData->deviceClockStableSec = 0.0; + + spiceData->deviceClockCheckTime = time; + spiceData->deviceClockCheckFrameSec = + spiceData->deviceClock.frameSec; + + const double acquireSec = + (time - spiceData->deviceClockAcquireStart) * 1.0e-9; + if (spiceData->deviceClockStableSec < + PLAYBACK_DEVICE_RATE_STABLE_SEC && + acquireSec < PLAYBACK_DEVICE_RATE_MAX_ACQUIRE_SEC) + return false; + + spiceData->deviceClockStable = true; + return true; +} + +static double playbackClockPosition(const PlaybackClock * clock, int64_t time) +{ + return clock->position + + (time - clock->time) * 1.0e-9 / clock->frameSec; +} + +static void playbackSourceRateReset(PlaybackSpiceData * spiceData) +{ + spiceData->rateSampleStart = 0; + spiceData->rateSampleCount = 0; + spiceData->rateLastSampleTimeMs = INT64_MIN; + spiceData->rateFilterTimeMs = INT64_MIN; + spiceData->sourceRateValid = false; +} + +static void playbackSourceRateAdd( + PlaybackSpiceData * spiceData, double nominalFrameSec) +{ + const int64_t timeMs = spiceData->mediaTimeMs; + if (spiceData->rateLastSampleTimeMs != INT64_MIN && + timeMs - spiceData->rateLastSampleTimeMs < + PLAYBACK_RATE_SAMPLE_INTERVAL_MS) + return; + + spiceData->rateLastSampleTimeMs = timeMs; + + while (spiceData->rateSampleCount > 0) + { + const PlaybackRateSample * oldest = + &spiceData->rateSamples[spiceData->rateSampleStart]; + if (timeMs - oldest->timeMs <= PLAYBACK_RATE_WINDOW_MS) + break; + + spiceData->rateSampleStart = + (spiceData->rateSampleStart + 1) % PLAYBACK_RATE_MAX_SAMPLES; + --spiceData->rateSampleCount; + } + + if (spiceData->rateSampleCount == PLAYBACK_RATE_MAX_SAMPLES) + { + spiceData->rateSampleStart = + (spiceData->rateSampleStart + 1) % PLAYBACK_RATE_MAX_SAMPLES; + --spiceData->rateSampleCount; + } + + const unsigned int index = + (spiceData->rateSampleStart + spiceData->rateSampleCount) % + PLAYBACK_RATE_MAX_SAMPLES; + spiceData->rateSamples[index] = (PlaybackRateSample) + { + .timeMs = timeMs, + .position = spiceData->inputPosition + }; + ++spiceData->rateSampleCount; + + const PlaybackRateSample * first = + &spiceData->rateSamples[spiceData->rateSampleStart]; + if (spiceData->rateSampleCount < 2 || + timeMs - first->timeMs < PLAYBACK_RATE_MIN_SPAN_MS) + return; + + double sumPosition = 0.0; + double sumTime = 0.0; + double sumPosition2 = 0.0; + double sumPositionTime = 0.0; + for (unsigned int i = 0; i < spiceData->rateSampleCount; ++i) + { + const PlaybackRateSample * sample = + &spiceData->rateSamples[ + (spiceData->rateSampleStart + i) % PLAYBACK_RATE_MAX_SAMPLES]; + const double position = sample->position - first->position; + const double timeSec = (sample->timeMs - first->timeMs) / 1000.0; + + sumPosition += position; + sumTime += timeSec; + sumPosition2 += position * position; + sumPositionTime += position * timeSec; + } + + const double count = spiceData->rateSampleCount; + const double denominator = + sumPosition2 - sumPosition * sumPosition / count; + if (denominator <= 0.0) + return; + + const double frameSec = + (sumPositionTime - sumPosition * sumTime / count) / denominator; + if (frameSec < nominalFrameSec * + (1.0 - PLAYBACK_MAX_RATE_CORRECTION) || + frameSec > nominalFrameSec * + (1.0 + PLAYBACK_MAX_RATE_CORRECTION)) + return; + + spiceData->sourceRateRawFrameSec = frameSec; + if (!spiceData->sourceRateValid) + { + spiceData->sourceRateFrameSec = frameSec; + spiceData->sourceRateValid = true; + } + else + { + const double elapsedSec = + (timeMs - spiceData->rateFilterTimeMs) / 1000.0; + const double alpha = + -expm1(-elapsedSec / PLAYBACK_RATE_FILTER_TIME_SEC); + spiceData->sourceRateFrameSec += + alpha * (frameSec - spiceData->sourceRateFrameSec); + } + spiceData->rateFilterTimeMs = timeMs; +} + +static void playbackPublishDeviceTiming( + int periodFrames, int64_t time, int64_t position) +{ + PlaybackDeviceTiming * timing = &audio.playback.deviceTiming; + atomic_fetch_add_explicit(&timing->sequence, 1, memory_order_relaxed); + atomic_store_explicit( + &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_fetch_add_explicit(&timing->sequence, 1, memory_order_release); +} + +static bool playbackReadDeviceTiming( + unsigned int previousSequence, PlaybackDeviceTick * tick, + unsigned int * sequence) +{ + PlaybackDeviceTiming * timing = &audio.playback.deviceTiming; + unsigned int before; + unsigned int after; + + do + { + before = atomic_load_explicit(&timing->sequence, memory_order_acquire); + if ((before & 1) || before == previousSequence) + return false; + + tick->periodFrames = + atomic_load_explicit(&timing->periodFrames, memory_order_relaxed); + tick->nextTime = + atomic_load_explicit(&timing->time, memory_order_relaxed); + tick->nextPosition = + atomic_load_explicit(&timing->position, memory_order_relaxed); + atomic_thread_fence(memory_order_acquire); + after = atomic_load_explicit(&timing->sequence, memory_order_relaxed); + } + while (before != after); + + *sequence = after; + return true; +} + +static void playbackResetMediaClock( + PlaybackSpiceData * spiceData, uint32_t time, int64_t now) +{ + spiceData->mediaTime = time; + spiceData->mediaTimeMs = 0; + spiceData->mediaLocalOrigin = now; + spiceData->lastPacketTime = INT64_MIN; + spiceData->lastArrivalTime = INT64_MIN; + spiceData->mediaClockValid = true; + spiceData->inputPosition = 0; + spiceData->sourceClock.valid = false; + playbackSourceRateReset(spiceData); +} + +static void playbackPrepareMediaClock( + PlaybackSpiceData * spiceData, uint32_t time) +{ + spiceData->mediaTime = time; + spiceData->mediaClockValid = false; + spiceData->inputPosition = 0; + spiceData->sourceClock.valid = false; + playbackSourceRateReset(spiceData); +} + +static int64_t playbackMapMediaTime(PlaybackSpiceData * spiceData, + uint32_t time, int64_t now, bool * discontinuity) +{ + if (!spiceData->mediaClockValid) + { + playbackResetMediaClock(spiceData, time, now); + return now; + } + + const int32_t deltaMs = (int32_t)(time - spiceData->mediaTime); + if (deltaMs < 0 || deltaMs > PLAYBACK_TIMESTAMP_DISCONTINUITY_MS) + { + playbackResetMediaClock(spiceData, time, now); + *discontinuity = true; + return now; + } + + spiceData->mediaTime = time; + spiceData->mediaTimeMs += deltaMs; + return spiceData->mediaLocalOrigin + spiceData->mediaTimeMs * 1000000; +} + static void playbackStop(void); static MsgBoxHandle recordCancelConfirmLocked(void); static void realRecordStartLocked( @@ -286,7 +696,6 @@ static void playbackStop(void) playbackSetState(STREAM_STATE_STOP); ringbuffer_free(&audio.playback.buffer); - ringbuffer_free(&audio.playback.deviceTiming); audio.playback.spiceData.src = src_delete(audio.playback.spiceData.src); if (audio.playback.spiceData.framesIn) @@ -295,11 +704,14 @@ static void playbackStop(void) free(audio.playback.spiceData.framesOut); audio.playback.spiceData.framesIn = NULL; audio.playback.spiceData.framesOut = NULL; + audio.playback.spiceData.framesInSize = 0; + audio.playback.spiceData.framesOutSize = 0; } if (audio.playback.timings) { app_unregisterGraph(audio.playback.graph); + audio.playback.graph = NULL; ringbuffer_free(&audio.playback.timings); } } @@ -320,12 +732,14 @@ static int playbackPullFrames(uint8_t * dst, int frames) { if (playbackGetState() == STREAM_STATE_SETUP_DEVICE) { - /* If necessary, slew backwards to play silence until we reach the target - * startup latency. This avoids underrunning the buffer if the audio - * device starts earlier than required. */ - int offset = ringbuffer_getCount(audio.playback.buffer) - + /* 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. */ + const int offset = ringbuffer_getCount(audio.playback.buffer) - audio.playback.targetStartFrames; - if (offset < 0) + if (offset != 0) { data->nextPosition += offset; ringbuffer_consume(audio.playback.buffer, NULL, offset); @@ -334,65 +748,17 @@ static int playbackPullFrames(uint8_t * dst, int frames) playbackSetState(STREAM_STATE_RUN); } - // Measure the device clock and post to the Spice thread - if (frames != data->periodFrames) - { - double newPeriodSec = (double) frames / audio.playback.sampleRate; - - bool init = data->periodFrames == 0; - if (init) - data->nextTime = now + llrint(newPeriodSec * 1.0e9); - else - /* Due to the double-buffered nature of audio playback, we are filling - * in the next buffer while the device is playing the previous buffer. - * This results in slightly unintuitive behaviour when the period size - * changes. The device will request enough samples for the new period - * size, but won't call us again until the previous buffer at the old - * size has finished playing. So, to avoid a blip in the timing - * calculations, we must set the estimated next wakeup time based upon - * the previous period size, not the new one. */ - data->nextTime += llrint(data->periodSec * 1.0e9); - - data->periodFrames = frames; - data->periodSec = newPeriodSec; - data->nextPosition += frames; - - double bandwidth = 0.05; - double omega = 2.0 * M_PI * bandwidth * data->periodSec; - data->b = M_SQRT2 * omega; - data->c = omega * omega; - } - else - { - double error = (now - data->nextTime) * 1.0e-9; - if (fabs(error) >= 0.2) - { - // Clock error is too high; slew the read pointer and reset the timing - // parameters to avoid getting too far out of sync - int slewFrames = round(error * audio.playback.sampleRate); - ringbuffer_consume(audio.playback.buffer, NULL, slewFrames); - - data->periodSec = (double) frames / audio.playback.sampleRate; - data->nextTime = now + llrint(data->periodSec * 1.0e9); - data->nextPosition += slewFrames + frames; - } - else - { - data->nextTime += - llrint((data->b * error + data->periodSec) * 1.0e9); - data->periodSec += data->c * error; - data->nextPosition += frames; - } - } - - PlaybackDeviceTick tick = - { - .periodFrames = data->periodFrames, - .nextTime = data->nextTime, - .nextPosition = data->nextPosition - }; - ringbuffer_push(audio.playback.deviceTiming, &tick); - + /* 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); + data->nextPosition += frames; + if (g_params.audioDebug && + playbackGetState() == STREAM_STATE_RUN && + ringbuffer_getCount(audio.playback.buffer) < frames) + atomic_fetch_add_explicit( + &audio.playback.underruns, 1, memory_order_relaxed); ringbuffer_consume(audio.playback.buffer, dst, frames); } else @@ -428,18 +794,33 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format, if (!audio.audioDev) return; + if (channels < 1 || channels > 8 || sampleRate < 8000 || + sampleRate > 384000 || format != PS_AUDIO_FMT_S16) + { + DEBUG_ERROR("Invalid playback format: %d channels, %d Hz, format %d", + channels, sampleRate, format); + if (playbackGetState() != STREAM_STATE_STOP) + playbackStop(); + return; + } + static int lastChannels = 0; static int lastSampleRate = 0; + static PSAudioFormat lastFormat = PS_AUDIO_FMT_INVALID; StreamState state = playbackGetState(); if (state == STREAM_STATE_KEEP_ALIVE && - channels == lastChannels && sampleRate == lastSampleRate) + channels == lastChannels && sampleRate == lastSampleRate && + format == lastFormat) { StreamState expected = STREAM_STATE_KEEP_ALIVE; if (atomic_compare_exchange_strong_explicit( &audio.playback.state, &expected, STREAM_STATE_RESUMING, memory_order_acq_rel, memory_order_acquire)) + { + playbackPrepareMediaClock(&audio.playback.spiceData, time); return; + } state = expected; } @@ -458,36 +839,75 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format, const int bufferFrames = sampleRate; audio.playback.buffer = ringbuffer_newUnbounded(bufferFrames, channels * sizeof(float)); - - audio.playback.deviceTiming = ringbuffer_new(16, sizeof(PlaybackDeviceTick)); + if (!audio.playback.buffer) + { + audio.playback.spiceData.src = + src_delete(audio.playback.spiceData.src); + return; + } lastChannels = channels; lastSampleRate = sampleRate; + lastFormat = format; audio.playback.channels = channels; audio.playback.sampleRate = sampleRate; audio.playback.stride = channels * sizeof(float); playbackSetState(STREAM_STATE_SETUP_SPICE); - audio.playback.deviceData.periodFrames = 0; audio.playback.deviceData.nextPosition = 0; - audio.playback.spiceData.periodFrames = 0; - audio.playback.spiceData.nextPosition = 0; + audio.playback.spiceData.inputPosition = 0; + audio.playback.spiceData.outputPosition = 0; audio.playback.spiceData.devPeriodFrames = 0; - audio.playback.spiceData.devLastTime = INT64_MIN; - audio.playback.spiceData.devNextTime = INT64_MIN; + audio.playback.spiceData.devReadPosition = 0; + audio.playback.spiceData.deviceTimingSequence = 0; + playbackDeviceClockAcquireReset(&audio.playback.spiceData); audio.playback.spiceData.offsetError = 0.0; audio.playback.spiceData.offsetErrorIntegral = 0.0; 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.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.deviceClock.valid = false; + playbackPrepareMediaClock(&audio.playback.spiceData, time); - int requestedPeriodFrames = max(g_params.audioPeriodSize, 1); + atomic_store_explicit( + &audio.playback.deviceTiming.sequence, 0, memory_order_relaxed); + atomic_store_explicit( + &audio.playback.deviceTiming.periodFrames, 0, memory_order_relaxed); + atomic_store_explicit( + &audio.playback.deviceTiming.time, 0, memory_order_relaxed); + atomic_store_explicit( + &audio.playback.deviceTiming.position, 0, memory_order_relaxed); + atomic_store_explicit( + &audio.playback.underruns, 0, 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.audioDev->playback.setup(channels, sampleRate, requestedPeriodFrames, - &audio.playback.deviceMaxPeriodFrames, &audio.playback.deviceStartFrames, - playbackPullFrames); - DEBUG_ASSERT(audio.playback.deviceMaxPeriodFrames > 0); + if (!audio.audioDev->playback.setup(channels, sampleRate, + requestedPeriodFrames, &audio.playback.deviceMaxPeriodFrames, + &audio.playback.deviceStartFrames, playbackPullFrames) || + audio.playback.deviceMaxPeriodFrames <= 0 || + audio.playback.deviceStartFrames < 0) + { + DEBUG_ERROR("Failed to configure audio playback device"); + playbackStop(); + return; + } // if a volume level was stored, set it before we return if (audio.playback.volumeChannels) @@ -499,10 +919,18 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format, if (audio.audioDev->playback.mute) audio.audioDev->playback.mute(audio.playback.mute); - // if the audio dev can report it's latency setup a timing graph - audio.playback.timings = ringbuffer_new(1200, sizeof(float)); - audio.playback.graph = app_registerGraph("PLAYBACK", - audio.playback.timings, 0.0f, 200.0f, audioGraphFormatFn); + // 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); @@ -579,15 +1007,47 @@ void audio_playbackMute(bool mute) static double computeDevicePosition(int64_t curTime) { - // Interpolate to calculate the current device position - PlaybackSpiceData * spiceData = &audio.playback.spiceData; - return spiceData->devLastPosition + - (spiceData->devNextPosition - spiceData->devLastPosition) * - ((double) (curTime - spiceData->devLastTime) / - (spiceData->devNextTime - spiceData->devLastTime)); + return playbackClockPosition( + &audio.playback.spiceData.deviceClock, curTime); } -void audio_playbackData(uint8_t * data, size_t size) +static bool playbackEnsureConversionBuffers( + PlaybackSpiceData * spiceData, int frames) +{ + if (frames > spiceData->framesInSize) + { + float * framesIn = realloc(spiceData->framesIn, + (size_t)frames * audio.playback.stride); + if (!framesIn) + { + DEBUG_ERROR("Failed to grow playback input buffer"); + return false; + } + + spiceData->framesIn = framesIn; + spiceData->framesInSize = frames; + } + + const int framesOut = + (int)ceil(frames * (1.0 + PLAYBACK_MAX_RATE_CORRECTION)) + 64; + if (framesOut > spiceData->framesOutSize) + { + float * output = realloc(spiceData->framesOut, + (size_t)framesOut * audio.playback.stride); + if (!output) + { + DEBUG_ERROR("Failed to grow playback output buffer"); + return false; + } + + spiceData->framesOut = output; + spiceData->framesOutSize = framesOut; + } + + return true; +} + +void audio_playbackData(uint8_t * data, size_t size, uint32_t time) { StreamState state = playbackGetState(); if (state == STREAM_STATE_STOP_PENDING) @@ -600,212 +1060,251 @@ void audio_playbackData(uint8_t * data, size_t size) return; PlaybackSpiceData * spiceData = &audio.playback.spiceData; - int64_t now = nanotime(); + const int64_t now = nanotime(); + const double nominalFrameSec = 1.0 / audio.playback.sampleRate; - // Convert from s16 to f32 samples - int spiceStride = audio.playback.channels * sizeof(int16_t); - int frames = size / spiceStride; - bool periodChanged = frames != spiceData->periodFrames; - bool init = spiceData->periodFrames == 0; - - if (periodChanged) + const int spiceStride = audio.playback.channels * sizeof(int16_t); + if (size % spiceStride != 0 || size / spiceStride > INT_MAX) { - if (spiceData->framesIn) - { - free(spiceData->framesIn); - free(spiceData->framesOut); - } - spiceData->periodFrames = frames; - spiceData->framesIn = malloc(frames * audio.playback.stride); - if (!spiceData->framesIn) - { - DEBUG_ERROR("Failed to malloc framesIn"); - playbackStop(); - return; - } - - spiceData->framesOutSize = round(frames * 1.1); - spiceData->framesOut = - malloc(spiceData->framesOutSize * audio.playback.stride); - if (!spiceData->framesOut) - { - DEBUG_ERROR("Failed to malloc framesOut"); - playbackStop(); - return; - } + DEBUG_ERROR("Invalid playback packet size: %zu bytes for stride %d", + size, spiceStride); + playbackStop(); + return; } + const int frames = size / spiceStride; + if (frames == 0 || frames > audio.playback.sampleRate * 2) + { + DEBUG_ERROR("Invalid playback packet length: %d frames", frames); + playbackStop(); + return; + } + + if (!playbackEnsureConversionBuffers(spiceData, frames)) + { + playbackStop(); + return; + } src_short_to_float_array((int16_t *) data, spiceData->framesIn, frames * audio.playback.channels); - // Receive timing information from the audio device thread + bool discontinuity = false; + const int64_t packetTime = + playbackMapMediaTime(spiceData, time, now, &discontinuity); + + if (spiceData->lastPacketTime != INT64_MIN && + spiceData->lastArrivalTime != INT64_MIN) + { + const double mediaDelta = + (packetTime - spiceData->lastPacketTime) * 1.0e-9; + const double arrivalDelta = + (now - spiceData->lastArrivalTime) * 1.0e-9; + const double jitter = fabs(arrivalDelta - mediaDelta); + + /* Keep a slowly decaying peak rather than feeding arrival jitter into the + * virtual clock. This lets the buffer absorb real delivery jitter while + * the rate controller follows only the SPICE multimedia clock. */ + spiceData->arrivalJitterSec = + min(PLAYBACK_MAX_JITTER_SEC, + max(jitter, spiceData->arrivalJitterSec * 0.999)); + } + spiceData->lastPacketTime = packetTime; + spiceData->lastArrivalTime = now; + + playbackSourceRateAdd(spiceData, nominalFrameSec); + if (!playbackSourceClockUpdate(&spiceData->sourceClock, + packetTime, spiceData->inputPosition, nominalFrameSec)) + discontinuity = true; + if (spiceData->sourceRateValid) + spiceData->sourceClock.frameSec = spiceData->sourceRateFrameSec; + int64_t curTime = spiceData->sourceClock.time; + int64_t curPosition = spiceData->outputPosition; + + // Receive the newest timing information from the audio device thread. PlaybackDeviceTick deviceTick; - while (ringbuffer_consume(audio.playback.deviceTiming, &deviceTick, 1)) + unsigned int deviceSequence; + bool deviceClockBecameStable = false; + if (playbackReadDeviceTiming(spiceData->deviceTimingSequence, + &deviceTick, &deviceSequence)) { + spiceData->deviceTimingSequence = deviceSequence; spiceData->devPeriodFrames = deviceTick.periodFrames; - spiceData->devLastTime = spiceData->devNextTime; - spiceData->devLastPosition = spiceData->devNextPosition; - spiceData->devNextTime = deviceTick.nextTime; - spiceData->devNextPosition = deviceTick.nextPosition; - } - - /* Determine the target latency. This is made up of the maximum audio device - * period (or the current actual period, if larger than the expected maximum), - * plus a little extra to absorb timing jitter, and a configurable - * additional buffer period. The default is set high enough to absorb typical - * timing jitter from qemu. */ - int configLatencyMs = max(g_params.audioBufferLatency, 0); - int maxPeriodFrames = - max(audio.playback.deviceMaxPeriodFrames, spiceData->devPeriodFrames); - double targetLatencyFrames = - maxPeriodFrames * 1.1 + - configLatencyMs * audio.playback.sampleRate / 1000.0; - - /* If the device is currently at a lower period size than its maximum (which - * can happen, for example, if another application has requested a lower - * latency) then we need to take that into account in our target latency. - * - * The reason to do this is not necessarily obvious, since we already set the - * target latency based upon the maximum period size. The problem stems from - * the way the device changes the period size. When the period size is - * reduced, there will be a transitional period where `playbackPullFrames` is - * invoked with the new smaller period size, but the time until the next - * invocation is based upon the previous size. This happens because the device - * is preparing the next small buffer while still playing back the previous - * large buffer. The result of this is that we end up with a surplus of data - * in the ring buffer. The overall latency is unchanged, but the balance has - * shifted: there is more data in our ring buffer and less in the device - * buffer. - * - * Unaccounted for, this would be detected as an offset error and playback - * would be sped up to bring things back in line. In isolation, this is not - * inherently problematic, and may even be desirable because it would reduce - * the overall latency. The real problem occurs when the period size goes back - * up. - * - * When the period size increases, the exact opposite happens. The device will - * suddenly request data at the new period size, but the timing interval will - * be based upon the previous period size during the transition. If there is - * not enough data to satisfy this then playback will start severely - * underrunning until the timing loop can correct for the error. - * - * To counteract this issue, if the current period size is smaller than the - * maximum period size then we increase the target latency by the difference. - * This keeps the offset error stable and ensures we have enough data in the - * buffer to absorb rate increases. */ - if (spiceData->devPeriodFrames != 0 && - spiceData->devPeriodFrames < audio.playback.deviceMaxPeriodFrames) - targetLatencyFrames += - audio.playback.deviceMaxPeriodFrames - spiceData->devPeriodFrames; - - // Measure the Spice audio clock - int64_t curTime; - int64_t curPosition; - double devPosition = DBL_MIN; - if (periodChanged) - { - if (init) - spiceData->nextTime = now; - - curTime = spiceData->nextTime; - curPosition = spiceData->nextPosition; - - spiceData->periodSec = (double) frames / audio.playback.sampleRate; - spiceData->nextTime += llrint(spiceData->periodSec * 1.0e9); - - double bandwidth = 0.05; - double omega = 2.0 * M_PI * bandwidth * spiceData->periodSec; - spiceData->b = M_SQRT2 * omega; - spiceData->c = omega * omega; - } - else - { - double error = (now - spiceData->nextTime) * 1.0e-9; - state = playbackGetState(); - if (fabs(error) >= 0.2 || - state == STREAM_STATE_KEEP_ALIVE || - state == STREAM_STATE_RESUMING) + spiceData->devReadPosition = + deviceTick.nextPosition + deviceTick.periodFrames; + if (!playbackClockUpdate(&spiceData->deviceClock, + deviceTick.nextTime, deviceTick.nextPosition, nominalFrameSec)) { - /* Clock error is too high or we are starting a new playback; slew the - * write pointer and reset the timing parameters to get back in sync. If - * we know the device playback position then we can slew directly to the - * target latency, otherwise just slew based upon the error amount */ - int slewFrames; - if (spiceData->devLastTime != INT64_MIN) - { - devPosition = computeDevicePosition(now); - double targetPosition = devPosition + targetLatencyFrames; + playbackDeviceClockAcquireReset(spiceData); + discontinuity = true; + } + else + deviceClockBecameStable = + playbackDeviceClockAcquire(spiceData, deviceTick.nextTime); + } - // If starting a new playback we need to allow a little extra time for - // the resampler startup latency - if (state == STREAM_STATE_KEEP_ALIVE || - state == STREAM_STATE_RESUMING) - { - int resamplerLatencyFrames = 20; - targetPosition += resamplerLatencyFrames; - } + 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; + } - slewFrames = round(targetPosition - spiceData->nextPosition); - } - else - slewFrames = round(error * audio.playback.sampleRate); + const int configLatencyMs = max(g_params.audioBufferLatency, 0); + const int maxPeriodFrames = + max(audio.playback.deviceMaxPeriodFrames, spiceData->devPeriodFrames); + const double configuredJitterFrames = + configLatencyMs * audio.playback.sampleRate / 1000.0; + const double measuredJitterFrames = + (spiceData->arrivalJitterSec + 0.001) * audio.playback.sampleRate; + const double targetBufferFrames = + maxPeriodFrames * 1.1 + + max(configuredJitterFrames, measuredJitterFrames); + const double targetLatencyFrames = + targetBufferFrames + PLAYBACK_RESAMPLER_DELAY_FRAMES; - ringbuffer_append(audio.playback.buffer, NULL, slewFrames); + 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); + ringbuffer_append(audio.playback.buffer, NULL, slewFrames); + spiceData->outputPosition += slewFrames; + curPosition += slewFrames; + spiceData->startupSyncPending = false; + } - curTime = now; - curPosition = spiceData->nextPosition + slewFrames; + double devPosition = DBL_MIN; + state = playbackGetState(); + if ((discontinuity || + state == STREAM_STATE_KEEP_ALIVE || + state == STREAM_STATE_RESUMING) && + spiceData->deviceClock.valid && + spiceData->deviceClockStable) + { + devPosition = computeDevicePosition(curTime); + const double slew = devPosition + targetBufferFrames - curPosition; + const int slewFrames = clamp(llrint(slew), (int64_t)INT_MIN, + (int64_t)INT_MAX); + ringbuffer_append(audio.playback.buffer, NULL, slewFrames); + spiceData->outputPosition += slewFrames; + curPosition += slewFrames; - spiceData->periodSec = (double) frames / audio.playback.sampleRate; - spiceData->nextTime = now + llrint(spiceData->periodSec * 1.0e9); - spiceData->nextPosition = curPosition; + spiceData->offsetError = 0.0; + spiceData->offsetErrorIntegral = 0.0; + spiceData->ratioIntegral = 0.0; + playbackSetState(STREAM_STATE_RUN); + } - spiceData->offsetError = 0.0; - spiceData->offsetErrorIntegral = 0.0; - spiceData->ratioIntegral = 0.0; + double actualLatencyFrames = 0.0; + if (spiceData->deviceClock.valid) + { + if (spiceData->deviceClockStable) + { + if (devPosition == DBL_MIN) + devPosition = computeDevicePosition(curTime); - playbackSetState(STREAM_STATE_RUN); + actualLatencyFrames = + curPosition - devPosition + PLAYBACK_RESAMPLER_DELAY_FRAMES; + const double 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; + const double b = M_SQRT2 * omega; + const double c = omega * omega; + + spiceData->offsetError += b * error + + spiceData->offsetErrorIntegral; + spiceData->offsetErrorIntegral += c * error; } else { - curTime = spiceData->nextTime; - curPosition = spiceData->nextPosition; - - spiceData->nextTime += - llrint((spiceData->b * error + spiceData->periodSec) * 1.0e9); - spiceData->periodSec += spiceData->c * error; + actualLatencyFrames = + curPosition - spiceData->devReadPosition + + PLAYBACK_RESAMPLER_DELAY_FRAMES; + spiceData->offsetError = 0.0; + spiceData->offsetErrorIntegral = 0.0; + spiceData->ratioIntegral = 0.0; } } - /* Measure the offset between the Spice position and the device position, - * and how far away this is from the target latency. We use this to adjust - * the playback speed to bring them back in line. This value can change - * quite rapidly, particularly at the start of playback, so filter it to - * avoid sudden pitch shifts which will be noticeable to the user. */ - double actualOffset = 0.0; - double offsetError = spiceData->offsetError; - if (spiceData->devLastTime != INT64_MIN) + /* 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. + * + * 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. */ + const double naturalFrequency = + 2.0 * M_PI * PLAYBACK_PHASE_BANDWIDTH_HZ; + const double kp = + M_SQRT2 * naturalFrequency / audio.playback.sampleRate; + const double ki = + naturalFrequency * naturalFrequency / audio.playback.sampleRate; + if (spiceData->deviceClockStable && + spiceData->sourceRateValid && + spiceData->deviceClock.updates >= 2) { - if (devPosition == DBL_MIN) - devPosition = computeDevicePosition(curTime); - - actualOffset = curPosition - devPosition; - double actualOffsetError = -(actualOffset - targetLatencyFrames); - - double error = actualOffsetError - offsetError; - spiceData->offsetError += spiceData->b * error + - spiceData->offsetErrorIntegral; - spiceData->offsetErrorIntegral += spiceData->c * error; + const double clockRatio = clamp( + spiceData->sourceRateFrameSec / + spiceData->deviceClock.frameSec, + 1.0 - PLAYBACK_MAX_RATE_CORRECTION, + 1.0 + PLAYBACK_MAX_RATE_CORRECTION); + spiceData->ratioIntegral -= + (clockRatio - spiceData->lastClockRatio) / ki; + spiceData->lastClockRatio = clockRatio; } - // Resample the audio to adjust the playback speed. Use a PI controller to - // adjust the resampling ratio based upon the measured offset - double kp = 0.5e-6; - double ki = 1.0e-16; + const double periodSec = frames * nominalFrameSec; + /* SPICE timestamps have millisecond resolution. Do not resample in + * response to phase error that cannot be distinguished from quantization; + * subtracting the deadband outside it keeps the response continuous. */ + const double phaseDeadbandFrames = + PLAYBACK_PHASE_DEADBAND_SEC * audio.playback.sampleRate; + double phaseError = spiceData->offsetError; + if (fabs(phaseError) <= phaseDeadbandFrames) + phaseError = 0.0; + else + phaseError -= copysign(phaseDeadbandFrames, phaseError); - spiceData->ratioIntegral += offsetError * spiceData->periodSec; + const double candidateIntegral = + spiceData->ratioIntegral + phaseError * periodSec; + const double phaseCorrection = + kp * phaseError + ki * candidateIntegral; + const double desiredRatio = + spiceData->lastClockRatio + phaseCorrection; + const double boundedRatio = clamp(desiredRatio, + 1.0 - PLAYBACK_MAX_RATE_CORRECTION, + 1.0 + PLAYBACK_MAX_RATE_CORRECTION); - double piOutput = kp * offsetError + ki * spiceData->ratioIntegral; - double ratio = 1.0 + piOutput; + if (desiredRatio == boundedRatio || + (desiredRatio > boundedRatio && phaseError < 0.0) || + (desiredRatio < boundedRatio && phaseError > 0.0)) + spiceData->ratioIntegral = candidateIntegral; + + const double maxRatioStep = + PLAYBACK_MAX_RATE_SLEW_PER_SEC * periodSec; + const double ratio = clamp(boundedRatio, + spiceData->lastRatio - maxRatioStep, + spiceData->lastRatio + maxRatioStep); + spiceData->lastRatio = ratio; int consumed = 0; while (consumed < frames) @@ -827,6 +1326,14 @@ void audio_playbackData(uint8_t * data, size_t size) if (error) { DEBUG_ERROR("Resampling failed: %s", src_strerror(error)); + playbackStop(); + return; + } + + if (srcData.input_frames_used == 0 && srcData.output_frames_gen == 0) + { + DEBUG_ERROR("Resampler made no progress"); + playbackStop(); return; } @@ -834,38 +1341,117 @@ void audio_playbackData(uint8_t * data, size_t size) srcData.output_frames_gen); consumed += srcData.input_frames_used; - spiceData->nextPosition += srcData.output_frames_gen; + spiceData->outputPosition += srcData.output_frames_gen; } + spiceData->inputPosition += frames; if (playbackGetState() == STREAM_STATE_SETUP_SPICE) { - /* Latency corrections at startup can be quite significant due to poor - * packet pacing from Spice, so require at least two full Spice periods' - * worth of data in addition to the startup delay requested by the device - * before starting playback to minimise the chances of underrunning. */ - int startFrames = - spiceData->periodFrames * 2 + audio.playback.deviceStartFrames; - audio.playback.targetStartFrames = startFrames; - - /* The actual time between opening the device and the device starting to - * pull data can range anywhere between nearly instant and hundreds of - * milliseconds. To minimise startup latency, we open the device - * immediately. If the device starts earlier than required (as per the - * `startFrames` value we just calculated), then a period of silence will be - * inserted at the beginning of playback to avoid underrunning. If it starts - * later, then we just accept the higher latency and let the adaptive - * resampling deal with it. */ + /* Reserve enough data for the backend's immediate startup pulls while + * leaving the requested steady-state target afterwards. */ + audio.playback.targetStartFrames = + ceil(targetBufferFrames) + audio.playback.deviceStartFrames; playbackSetState(STREAM_STATE_SETUP_DEVICE); audio.audioDev->playback.start(); } - double latencyFrames = actualOffset; - if (audio.audioDev->playback.latency) - latencyFrames += audio.audioDev->playback.latency(); + if (!g_params.audioDebug) + return; - const float latency = latencyFrames * 1000.0 / audio.playback.sampleRate; - ringbuffer_push(audio.playback.timings, &latency); - app_invalidateGraph(audio.playback.graph); + 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) + { + const float latency = latencyMs; + ringbuffer_push(audio.playback.timings, &latency); + app_invalidateGraph(audio.playback.graph); + } + + if (now >= spiceData->nextLogTime) + { + 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 : + 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", + 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); + + 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->nextLogTime = now + INT64_C(5000000000); + } } bool audio_supportsRecord(void) diff --git a/client/src/audio.h b/client/src/audio.h index c740ad6a..9a1d2552 100644 --- a/client/src/audio.h +++ b/client/src/audio.h @@ -32,7 +32,7 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format, void audio_playbackStop(void); void audio_playbackVolume(int channels, const uint16_t volume[]); void audio_playbackMute(bool mute); -void audio_playbackData(uint8_t * data, size_t size); +void audio_playbackData(uint8_t * data, size_t size, uint32_t time); bool audio_supportsRecord(void); void audio_recordStart(int channels, int sampleRate, PSAudioFormat format); diff --git a/client/src/config.c b/client/src/config.c index a90daa44..b843f7ec 100644 --- a/client/src/config.c +++ b/client/src/config.c @@ -530,19 +530,26 @@ static struct Option options[] = }, // audio options + { + .module = "audio", + .name = "debug", + .description = "Enable audio synchronization diagnostics", + .type = OPTION_TYPE_BOOL, + .value.x_bool = false + }, { .module = "audio", .name = "periodSize", - .description = "Requested audio device period size in samples", + .description = "Requested audio device period size in samples (0 = 10 ms)", .type = OPTION_TYPE_INT, - .value.x_int = 2048 + .value.x_int = 0 }, { .module = "audio", .name = "bufferLatency", .description = "Additional buffer latency in milliseconds", .type = OPTION_TYPE_INT, - .value.x_int = 13 + .value.x_int = 4 }, { .module = "audio", @@ -763,6 +770,7 @@ bool config_load(int argc, char * argv[]) g_params.largeCursorDot = option_get_bool("spice", "largeCursorDot"); } + 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.micShowIndicator = option_get_bool("audio", "micShowIndicator"); diff --git a/client/src/main.h b/client/src/main.h index 1beebde3..e181cc07 100644 --- a/client/src/main.h +++ b/client/src/main.h @@ -237,6 +237,7 @@ struct AppParams bool showCursorDot; bool largeCursorDot; + bool audioDebug; int audioPeriodSize; int audioBufferLatency; bool micShowIndicator; diff --git a/repos/PureSpice b/repos/PureSpice index 78253c22..3b9fa5f2 160000 --- a/repos/PureSpice +++ b/repos/PureSpice @@ -1 +1 @@ -Subproject commit 78253c22ed534a0eb20e9e11788cc51538665271 +Subproject commit 3b9fa5f245adc0a7ea1ab60a13743f91d5079b19