From cc1cc27c3405caf742b737e56d4d797a5660b0f6 Mon Sep 17 00:00:00 2001 From: Carlos Rafael Giani Date: Tue, 26 May 2026 11:33:09 +0200 Subject: [PATCH 1/9] node-driver: Factor on_timeout execution delay into current_time --- spa/plugins/support/node-driver.c | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/spa/plugins/support/node-driver.c b/spa/plugins/support/node-driver.c index 578391120..9c3e8232c 100644 --- a/spa/plugins/support/node-driver.c +++ b/spa/plugins/support/node-driver.c @@ -358,6 +358,7 @@ static void on_timeout(struct spa_source *source) { struct impl *this = source->data; uint64_t expirations, nsec, duration, current_time, current_position, position; + uint64_t time_since_nsec; uint32_t rate; double corr = 1.0, err = 0.0; int res; @@ -404,11 +405,22 @@ static void on_timeout(struct spa_source *source) * and this->clock->position values are correct anymore. (Timer * cancellations happen when the realtime clock is being used by * this driver and the user modified the realtime clock for example.) + * + * time_since_nsec is an extra factor that corrects inaccuracies when + * the on_timeout() callback is executed with a slight delay. This + * delay is factored into current_time and later in the err value, + * which means that the DLL has to compensate for it. time_since_nsec + * estimates the delay, and subtracts that estimation, leading to + * a reduced impact on current_time, and thus, the DLL does not have + * to compensate as much, which increases the control loop stability. */ - if (this->props.freewheel || SPA_UNLIKELY(timer_was_canceled)) + if (this->props.freewheel || SPA_UNLIKELY(timer_was_canceled)) { nsec = gettime_nsec(this, this->timer_clockid); - else + time_since_nsec = 0; + } else { nsec = this->next_time; + time_since_nsec = gettime_nsec(this, this->timer_clockid) - this->next_time; + } /* "tracking" means that the driver is following a clock that is not * usable by timerfd. It is an entirely separate clock, for example, @@ -416,10 +428,12 @@ static void on_timeout(struct spa_source *source) * always the monotonic clock, and this->props.clock_id is that entirely * separate clock. If tracking is false, then this->props.clock_id * equals timer_clockid, so "nsec" can directly be used as the current - * driver clock time in that case. */ - if (this->tracking) + * driver clock time in that case. + * (See the comment above for the purpose of time_since_nsec.) */ + if (this->tracking) { current_time = gettime_nsec(this, this->props.clock_id); - else + current_time -= SPA_LIKELY(current_time >= time_since_nsec) ? time_since_nsec : 0; + } else current_time = nsec; current_position = scale_u64(current_time, rate, SPA_NSEC_PER_SEC); From 3ce1f6e4541f8c4c283ee04a6dc021a44ab45569 Mon Sep 17 00:00:00 2001 From: Carlos Rafael Giani Date: Tue, 26 May 2026 11:40:15 +0200 Subject: [PATCH 2/9] node-driver: Reinitialize DLL if error is too large If the error is very large (after a big discontinuity in the timestamps for example), the DLL has a hard time compensating, and a full restart of the DLL without the old measurement history is more appropriate to get the synchronization back on track faster and with fewer oscillations. --- spa/plugins/support/node-driver.c | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/spa/plugins/support/node-driver.c b/spa/plugins/support/node-driver.c index 9c3e8232c..c3e976e55 100644 --- a/spa/plugins/support/node-driver.c +++ b/spa/plugins/support/node-driver.c @@ -360,7 +360,7 @@ static void on_timeout(struct spa_source *source) uint64_t expirations, nsec, duration, current_time, current_position, position; uint64_t time_since_nsec; uint32_t rate; - double corr = 1.0, err = 0.0; + double corr = 1.0, err = 0.0, abs_err = 0.0; int res; bool timer_was_canceled = false; @@ -473,10 +473,17 @@ static void on_timeout(struct spa_source *source) * the graph clock elapsed time, feed this error into the * dll and adjust the timeout of our MONOTONIC clock. */ err = (double)position - (double)current_position; - if (fabs(err) > this->max_error) { - if (fabs(err) > this->max_resync) { - spa_log_warn(this->log, "err %f > max_resync %f, resetting", - err, this->max_resync); + abs_err = fabs(err); + if (abs_err > this->max_error) { + if (abs_err > this->max_resync) { + if (abs_err > (2 * this->max_resync)) { + spa_log_warn(this->log, "err %f > 2 * max_resync %f, reinitializing", + err, this->max_resync); + spa_dll_init(&this->dll); + } else { + spa_log_warn(this->log, "err %f > max_resync %f, resetting", + err, this->max_resync); + } spa_dll_set_bw(&this->dll, SPA_DLL_BW_MIN, duration, rate); position = current_position; err = 0.0; From 0550537ce00136ba56fc61cd279616f57935489b Mon Sep 17 00:00:00 2001 From: Carlos Rafael Giani Date: Tue, 26 May 2026 11:40:56 +0200 Subject: [PATCH 3/9] node-driver: Increase DLL speed by using SPA_DLL_BW_MAX This allows the DLL to react faster to clock pace changes. --- spa/plugins/support/node-driver.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spa/plugins/support/node-driver.c b/spa/plugins/support/node-driver.c index c3e976e55..5180b113a 100644 --- a/spa/plugins/support/node-driver.c +++ b/spa/plugins/support/node-driver.c @@ -484,7 +484,7 @@ static void on_timeout(struct spa_source *source) spa_log_warn(this->log, "err %f > max_resync %f, resetting", err, this->max_resync); } - spa_dll_set_bw(&this->dll, SPA_DLL_BW_MIN, duration, rate); + spa_dll_set_bw(&this->dll, SPA_DLL_BW_MAX, duration, rate); position = current_position; err = 0.0; } else { From 0a8ddba3e924a67e5e8b17c98014dc28e43d2534 Mon Sep 17 00:00:00 2001 From: Martin Geier Date: Wed, 17 Dec 2025 14:26:57 +0100 Subject: [PATCH 4/9] node-driver: report discontinuity flag when resyncing position module-rtp-sink is sending data timestampted with clock provided from a driver. When driver jumps (for example due ptp master change) rtp sink has to jumps too. Rtp sink is checking for difference between expected and actual position and is big enough the jump is done. As resync value in driver is not equal to diff between positions, sink could ends with not precise timestamps up to 1 quantum. Emit discont flag when driver jumps, so sink can be reinitialized without its own heuristic. --- spa/plugins/support/node-driver.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spa/plugins/support/node-driver.c b/spa/plugins/support/node-driver.c index 5180b113a..6cd6a403a 100644 --- a/spa/plugins/support/node-driver.c +++ b/spa/plugins/support/node-driver.c @@ -363,6 +363,7 @@ static void on_timeout(struct spa_source *source) double corr = 1.0, err = 0.0, abs_err = 0.0; int res; bool timer_was_canceled = false; + bool report_discont = false; /* See set_timeout() for an explanation about timer cancelation. */ @@ -380,6 +381,7 @@ static void on_timeout(struct spa_source *source) return; } } + report_discont = timer_was_canceled; if (SPA_LIKELY(this->position)) { duration = this->position->clock.target_duration; @@ -487,6 +489,7 @@ static void on_timeout(struct spa_source *source) spa_dll_set_bw(&this->dll, SPA_DLL_BW_MAX, duration, rate); position = current_position; err = 0.0; + report_discont = true; } else { err = SPA_CLAMPD(err, -this->max_error, this->max_error); } @@ -519,7 +522,7 @@ static void on_timeout(struct spa_source *source) this->clock->next_nsec = this->next_time + nsec_offset; SPA_FLAG_UPDATE(this->clock->flags, SPA_IO_CLOCK_FLAG_DISCONT, - timer_was_canceled); + report_discont); } spa_node_call_ready(&this->callbacks, From 943bce9c8539253481ad7e1623f42148d7f480f9 Mon Sep 17 00:00:00 2001 From: Carlos Rafael Giani Date: Thu, 11 Jun 2026 20:21:13 +0200 Subject: [PATCH 5/9] node-driver: Add sync.force-tracking property This property is useful for forcing tracking behavior even if the clock can directly be used by timerfd. This can be useful in cases where the clock is for example the realtime clock and can shift and change during playback unexpectedly. The node driver can handle this without a DLL. But, in some cases, graphs can experience a more stable behavior if the DLL is used and it manages the readjustment to the updated time. This is also useful for testing and debugging the DLL's behavior. --- spa/plugins/support/node-driver.c | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/spa/plugins/support/node-driver.c b/spa/plugins/support/node-driver.c index 6cd6a403a..d260c0fe7 100644 --- a/spa/plugins/support/node-driver.c +++ b/spa/plugins/support/node-driver.c @@ -39,6 +39,7 @@ SPA_LOG_TOPIC_DEFINE_STATIC(log_topic, "spa.driver"); #define DEFAULT_CLOCK_PREFIX "clock.system" #define DEFAULT_CLOCK_ID CLOCK_MONOTONIC #define DEFAULT_RESYNC_MS 10 +#define DEFAULT_FORCE_TRACKING false #define CLOCK_OFFSET_NAVG 20 #define CLOCK_OFFSET_MAX_ERR (50 * SPA_NSEC_PER_USEC) @@ -60,6 +61,7 @@ struct props { float resync_ms; char clock_device[CLOCK_NAME_MAX]; char clock_interface[CLOCK_NAME_MAX]; + bool force_tracking; }; struct clock_offset { @@ -121,6 +123,7 @@ static void reset_props(struct props *props) props->freewheel_wait = DEFAULT_FREEWHEEL_WAIT; props->resync_ms = DEFAULT_RESYNC_MS; reset_props_strings(props); + props->force_tracking = DEFAULT_FORCE_TRACKING; } static const struct clock_info { @@ -431,7 +434,9 @@ static void on_timeout(struct spa_source *source) * separate clock. If tracking is false, then this->props.clock_id * equals timer_clockid, so "nsec" can directly be used as the current * driver clock time in that case. - * (See the comment above for the purpose of time_since_nsec.) */ + * (See the comment above for the purpose of time_since_nsec.) + * Note that it is possible to force tracking even if the clock is usable + * by timerfd, by setting the "sync.force-tracking" property to true. */ if (this->tracking) { current_time = gettime_nsec(this, this->props.clock_id); current_time -= SPA_LIKELY(current_time >= time_since_nsec) ? time_since_nsec : 0; @@ -1048,6 +1053,9 @@ impl_init(const struct spa_handle_factory *factory, this->props.freewheel_wait = atoi(s); } else if (spa_streq(k, "resync.ms")) { this->props.resync_ms = (float)atof(s); + } else if (spa_streq(k, "sync.force-tracking")) { + this->props.force_tracking = spa_atob(s); + spa_log_info(this->log, "forcing DLL based clock tracking: %d", this->props.force_tracking); } } if (this->props.clock_name[0] == '\0') { @@ -1057,7 +1065,7 @@ impl_init(const struct spa_handle_factory *factory, } ensure_clock_name(this); - this->tracking = !clock_for_timerfd(this->props.clock_id); + this->tracking = this->props.force_tracking || !clock_for_timerfd(this->props.clock_id); this->timer_clockid = this->tracking ? CLOCK_MONOTONIC : this->props.clock_id; this->max_error = 128; From 32fe4dbda350eed6cd1e1ee6e537c9ed22ef921b Mon Sep 17 00:00:00 2001 From: Martin Geier Date: Tue, 26 May 2026 12:34:03 +0200 Subject: [PATCH 6/9] bluez: introduce media.min-latency-ms for media source Target number of samples stored in buffer is calculated by target = SPA_CLAMP(SPA_ROUND_UP(SPA_MAX(spike * 3/2, duration), SPA_CLAMP((int)this->rate / 50, 1, INT32_MAX)), duration, max_buf - 2*packet_size); At beginning of the playback is spike == 0, so the target is set to duration. Our BT is not able to maintain such low latency and drops so spike is increased. This can happens multiple times until playback is stable. This strategy causes few dropouts at the beginning of a new playback, in my case on each new track. Add min-latency-ms property to reduces spikes and the likelihood of dropouts at the beginning. --- spa/include/spa/utils/keys.h | 2 ++ spa/plugins/bluez5/decode-buffer.h | 8 ++++++++ spa/plugins/bluez5/media-source.c | 8 ++++++++ 3 files changed, 18 insertions(+) diff --git a/spa/include/spa/utils/keys.h b/spa/include/spa/utils/keys.h index 59dfc1139..f60d3a3ec 100644 --- a/spa/include/spa/utils/keys.h +++ b/spa/include/spa/utils/keys.h @@ -121,6 +121,8 @@ extern "C" { #define SPA_KEY_API_BLUEZ5_CLASS "api.bluez5.class" /**< a bluetooth class */ #define SPA_KEY_API_BLUEZ5_ICON "api.bluez5.icon" /**< a bluetooth icon */ #define SPA_KEY_API_BLUEZ5_ROLE "api.bluez5.role" /**< "client" or "server" */ +#define SPA_KEY_API_BLUEZ5_MIN_LATENCY_MS \ + "api.bluez5.min-latency-ms" /**< minimum possible latency, in milliseconds */ /** keys for jack api */ #define SPA_KEY_API_JACK "api.jack" /**< key for the JACK api */ diff --git a/spa/plugins/bluez5/decode-buffer.h b/spa/plugins/bluez5/decode-buffer.h index 776c2a0ce..beb34cfe0 100644 --- a/spa/plugins/bluez5/decode-buffer.h +++ b/spa/plugins/bluez5/decode-buffer.h @@ -85,6 +85,8 @@ struct spa_bt_decode_buffer int64_t duration_ns; int64_t next_nsec; + int32_t min_latency; + int32_t delay; int32_t delay_frac; uint32_t prev_samples; @@ -252,6 +254,11 @@ static inline void spa_bt_decode_buffer_set_max_extra_latency(struct spa_bt_deco this->max_extra = samples; } +static inline void spa_bt_decode_buffer_set_min_latency(struct spa_bt_decode_buffer *this, int32_t samples) +{ + this->min_latency = samples; +} + static inline int32_t spa_bt_decode_buffer_get_auto_latency(struct spa_bt_decode_buffer *this) { const int32_t duration = this->duration_ns * this->rate / SPA_NSEC_PER_SEC; @@ -264,6 +271,7 @@ static inline int32_t spa_bt_decode_buffer_get_auto_latency(struct spa_bt_decode SPA_CLAMP((int)this->rate / 50, 1, INT32_MAX)), duration, max_buf - 2*packet_size); + target = SPA_MAX(target, this->min_latency); return SPA_MIN(target, duration + SPA_CLAMP(this->max_extra, 0, INT32_MAX - duration)); } diff --git a/spa/plugins/bluez5/media-source.c b/spa/plugins/bluez5/media-source.c index 01bb4f2fd..73ea6c44e 100644 --- a/spa/plugins/bluez5/media-source.c +++ b/spa/plugins/bluez5/media-source.c @@ -148,6 +148,7 @@ struct impl { unsigned int decode_buffer_target; unsigned int node_latency; + uint32_t min_latency_ms; int fd; struct spa_source source; @@ -1039,6 +1040,11 @@ static int transport_start(struct impl *this) port->current_format.info.raw.rate * 80 / 1000); } + if (this->min_latency_ms) { + spa_bt_decode_buffer_set_min_latency(&port->buffer, + this->min_latency_ms * port->current_format.info.raw.rate / 1000); + } + this->delay.buffer = -1; this->delay.duration = 0; this->update_delay_event = spa_loop_utils_add_event(this->loop_utils, update_delay_event, this); @@ -2249,6 +2255,8 @@ impl_init(const struct spa_handle_factory *factory, spa_scnprintf(this->props.rate, sizeof(this->props.rate), "%s", str); this->props.has_rate = true; } + if ((str = spa_dict_lookup(info, SPA_KEY_API_BLUEZ5_MIN_LATENCY_MS)) != NULL) + spa_atou32(str, &this->min_latency_ms, 0); } if (this->is_duplex) { From fa9eb6320a9bbdeaa285eb87eb472ce1c050900b Mon Sep 17 00:00:00 2001 From: Martin Geier Date: Tue, 26 May 2026 10:52:12 +0200 Subject: [PATCH 7/9] combine-stream: fix incorrect compensate samples on playback restart update_delay is called primarily when the stream format or latency changes, and from playback thread, if stream reports different delay as before. This function calculates the number of compensate samples for each stream based on the latencies of other streams (which must be in a streaming state). During the first playback on a new format, update_delay is called multiple times due to format or latency changes. The delay is calculated only from streams that are currently streaming. If some streams are not yet streaming, their latencies are ignored, and the delay is updated later in the processing thread. The processing thread also stores the stream delay in a local variable (accessed only from that thread, thus requiring no locking). On a subsequent playback using the same format, update_delay is still called a few times, and the delay is updated based on the currently streaming streams. If some streams are not streaming, their latencies are ignored. However, this time, the processing thread fails to update the delay for the previously non-streaming streams. Because the format didn't change, the streams delay matches the last stored delay from the previous playback. As a result, the compensate samples are not recalculated. To properly update the compensate samples, update_delay must also be called when a stream's state changes to streaming (avoiding the need to clear the thread-buffered value, which would require locking in the processing thread). --- src/modules/module-combine-stream.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/module-combine-stream.c b/src/modules/module-combine-stream.c index 81e20b0d1..7acee1463 100644 --- a/src/modules/module-combine-stream.c +++ b/src/modules/module-combine-stream.c @@ -777,6 +777,7 @@ static void stream_state_changed(void *d, enum pw_stream_state old, break; case PW_STREAM_STATE_STREAMING: update_latency(s->impl); + update_delay(s->impl); break; default: break; From bb90fdf03bbf9de289383839c299ecb46c365ff4 Mon Sep 17 00:00:00 2001 From: Carlos Rafael Giani Date: Tue, 26 May 2026 17:01:18 +0200 Subject: [PATCH 8/9] spa: utils: Add SPA_ROUND_UP_POW2_32 and SPA_ROUND_UP_POW2_64 macros --- spa/include/spa/utils/defs.h | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/spa/include/spa/utils/defs.h b/spa/include/spa/utils/defs.h index d5b7928da..53d23aee9 100644 --- a/spa/include/spa/utils/defs.h +++ b/spa/include/spa/utils/defs.h @@ -308,6 +308,34 @@ struct spa_fraction { (uint32_t)(((_val) * (num) + (_denom)-1) / (_denom)); \ }) +/* Macros for getting the next highest power of two, for 32 and 64 bit + * unsigned integers. If the integers are already a power of two, the + * result is unchanged. Source: + * https://graphics.stanford.edu/%7Eseander/bithacks.html#RoundUpPowerOf2 */ + +#define SPA_ROUND_UP_POW2_32(num) \ +({ \ + uint32_t _n = (uint32_t)(num) - 1; \ + _n |= _n >> 1; \ + _n |= _n >> 2; \ + _n |= _n >> 4; \ + _n |= _n >> 8; \ + _n |= _n >> 16; \ + _n + 1; \ +}) + +#define SPA_ROUND_UP_POW2_64(num) \ +({ \ + uint64_t _n = (uint64_t)(num) - 1; \ + _n |= _n >> 1; \ + _n |= _n >> 2; \ + _n |= _n >> 4; \ + _n |= _n >> 8; \ + _n |= _n >> 16; \ + _n |= _n >> 32; \ + _n + 1; \ +}) + #define SPA_PTR_ALIGNMENT(p,align) ((uintptr_t)(p) & ((align)-1)) #define SPA_IS_ALIGNED(p,align) (SPA_PTR_ALIGNMENT(p,align) == 0) From 4eed79ee81f3699e2e32a85aa676f3ed404f49fd Mon Sep 17 00:00:00 2001 From: Carlos Rafael Giani Date: Thu, 12 Feb 2026 20:44:34 +0100 Subject: [PATCH 9/9] spa: utils: add SPA_MACHINE_USES_TWOS_COMPLEMENT macro --- spa/include/spa/utils/defs.h | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spa/include/spa/utils/defs.h b/spa/include/spa/utils/defs.h index 53d23aee9..3fdd86845 100644 --- a/spa/include/spa/utils/defs.h +++ b/spa/include/spa/utils/defs.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #ifdef __cplusplus @@ -336,6 +337,21 @@ struct spa_fraction { _n + 1; \ }) +/* The vast majority of real world machines use a two's complement method. + * However, it is still prudent to check for that instead of just assuming. + * Fortunately, it is simple to check for this by negating INT_MAX and + * subtracting 1 from that. In two's complement, there is one more negative + * integer than there are positive integers (due to two's complement having + * only one zero representation), and this check exploits that particular + * asymmetry. + * It is very important to make sure limits.h is included for this to work, + * since otherwise, INT_MIN and INT_MAX will be missing, and this will _not_ + * cause a preprocessor error - instead, the #if check here silently fails, + * as if the machine used something other than a two's complement method. */ +#if INT_MIN == (-(INT_MAX) - 1) +#define SPA_MACHINE_USES_TWOS_COMPLEMENT +#endif + #define SPA_PTR_ALIGNMENT(p,align) ((uintptr_t)(p) & ((align)-1)) #define SPA_IS_ALIGNED(p,align) (SPA_PTR_ALIGNMENT(p,align) == 0)