2.0.0-Beta9
The big-ticket work in Beta9 is a full rewrite of the time-sync filter to bring it in lockstep with the upstream Sendspin/time-filter reference (post-PR #6, April 2026). Convergence after connect should be ~2× faster and steady-state offset uncertainty should be ~2× tighter in theory; field testing on real devices is welcome — please flag any regressions.
🚀 Algorithm changes (visible at connect time)
Time-sync filter rewritten to match upstream
The 2-D Kalman filter in SendspinTimeFilter now mirrors the upstream Apache-2.0 reference exactly:
- Measurement variance pre-scaled by
max_error_scale = 0.5(upstream PR #6). Our previous formula was 4× more skeptical of measurements, which slowed convergence. - Adaptive forgetting now fires correctly on real disruptions (
|residual| > 3 × max_error→ covariance × 4) instead of firing constantly with negligible effect (the prior|innov| > 0.75 × σ→ covariance × 1.002 pattern was effectively a no-op). - Process noise switched to upstream's diffusion-coefficient form with
process_std_dev = 0anddrift_process_std_dev = 1e-11. The adaptive-Q machinery (innovation window, stability score-based scaling) was removed — upstream's fixed-Q + adaptive-forgetting model avoids the IAE wind-up failure mode for clock sync. - Drift initialization on the second sample now uses the upstream finite-difference + propagation-of-error covariance, instead of a hardcoded sentinel.
- Monotonic guard at the top of
addMeasurementrejects non-monotonic timestamps cleanly. computeMaxError's 1 ms² floor removed —max_erroris nowrtt / 2directly per upstream, instead ofsqrt(1e6 + (rtt/2)²). Removes an over-conservative variance inflation on low-RTT (high-quality) measurements.
Local Android adaptations preserved
The following Android-platform-specific divergences from upstream are intentional and now documented in the source:
- Drift omitted from
serverToClient/clientToServer. Android does not expose DAC clock control, so applying drift in conversions would produce a predicted server-time the renderer cannot achieve. This is the same pattern Spotify Connect, Roon RAAT, Snapcast, and the AirPlay shairport-sync DAC-clock fallback all use. - IQR-based outlier pre-rejection ahead of the Kalman update — defends against heavy-tailed Wi-Fi / cellular RTT spikes that upstream doesn't see.
freeze()/thaw()with 100× covariance inflation for reconnect resilience — upstream has no equivalent.- Lock-free
serverToClient/clientToServerreads (AtomicLong-backed offset,@Volatilestatic-delay components) so the audio render thread never blocks on the time-filter mutex.
SyncErrorFilter (DAC sync-error smoother) follows the same model
The post-hoc DAC sync-error smoother got the same algorithm shape: diffusion-coefficient Q, forgetting at 6σ residuals with 4× covariance kick, finite-difference drift bootstrap, monotonic guard. Tunables sized for the higher-rate (~10 Hz) DAC-error stream. One deliberate divergence from SendspinTimeFilter: no drift-significance gate (gating prediction would systematically bias the drift estimate, and the gate's purpose in upstream — protecting time-conversion outputs — doesn't apply here).
🐛 Bug fixes
Reconnect across servers no longer silently uses the prior server's clock
Connecting to a different SendSpin server after a freeze (during a reconnect cycle) used to restore the prior server's clock offset onto the new session, producing silent sync corruption until the inflated covariance let new measurements pull the filter back. freeze() and thaw() now require a server-identity pair and thaw() discards the snapshot on identity mismatch.
thaw() no longer explodes the filter on first post-thaw measurement
thaw() was failing to restore lastUpdateTime. After a freeze() → reset() → thaw() sequence, the next addMeasurement computed dt against zero (≈10¹⁰ µs since boot), exploding the covariance prediction term. Both lastUpdateTime and the convergence-time anchor are now restored correctly.
Server now sees honest sync-state reporting (client/state)
setSyncState was implemented but never called outside tests, so the server saw state="synchronized" permanently regardless of actual filter health. Beta9 wires it to filter convergence: state defaults to "error" until first convergence; flips to "synchronized" after; flips back to "error" if convergence is lost.
Per Sendspin protocol spec, audio output is now muted while the client reports "error" and resumed when convergence returns. Mute engages only after a successful sync has been established at least once and is then lost — the initial pre-sync window does not silence playback.
Two unrelated pre-existing test failures fixed
MaCommandMultiplexer:partialframes with empty result arrays used to fall through to the final-result handler, which removed the pending command and completed its deferred. Empty partials now correctly route to the event listener.MessageParser.parseServerTime: zero-valued NTP timestamps (legitimate boot-state values) used to be rejected as missing-fields. The validity check now distinguishes absent fields from explicit zeros.
🧹 Internal hygiene
- Three new bug-guarding tests + four behavior tests for the upstream-aligned algorithm
- Threading model on
TimeSyncManagerdocumented in the class KDoc lastUpdateTimemarked@Volatile(it's read lock-free fromgetLastTimeSyncAgeMs)- Removed dead
clientPlayTimeMicrosfield onAudioChunk(written but never read) - Removed flaky iteration-count gate from concurrent-access test (was failing reliably post-JIT-warmup; the safety property is checked unchanged)
- Deleted stale
docs/CLOCK_DRIFT_EXPLAINED.mdthat taught the opposite of the current code
Full Changelog: v2.0.0-Beta8...v2.0.0-Beta9