Skip to content

fix: hold sync corrections while a cold output stream's delay settles - #103

Merged
teancom merged 2 commits into
Sendspin:mainfrom
CrazyDiam0nd-gh:fix/output-delay-startup-transient
Oct 13, 2026
Merged

teancom merged 2 commits into
Sendspin:mainfrom
CrazyDiam0nd-gh:fix/output-delay-startup-transient

Conversation

@CrazyDiam0nd-gh

@CrazyDiam0nd-gh CrazyDiam0nd-gh commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

fix: hold sync corrections while a cold output stream's delay settles

Fixes an audible warble (pitch speeding up, then slowing down, for 5-10 s) after playback starts on a freshly opened output stream. Found while debugging the Music Assistant Companion app on Linux: music-assistant/desktop-app#203.

Problem

On some backends (observed with ALSA over PulseAudio/PipeWire) the output delay reported for a cold stream does not start at its resting value. It overshoots by a few hundred milliseconds in the first second (about 550 ms here) and then relaxes back (to about 250 ms) over several seconds. The audio plays gaplessly; only the estimate moves. raw_error is measured against that estimate and follows it almost 1:1 (with a roughly constant offset), so the planner sees a few hundred ms of "drift" that resolves by itself. It engages at the full 4% cap, then overshoots the other way and slows playback down. That is the warble.

Trace excerpt from two cold starts (callback, playback_delta, raw_error):

cb=119  delta=549.2ms  raw_error=358.4ms
cb=180  delta=440.3ms  raw_error=278.5ms
cb=321  delta=318.4ms  raw_error= 76.2ms
cb=579  delta=255.2ms  raw_error=-13.9ms

with Sync correction engaged: ... drop_every=25 (4%), later insert_every=54..70 (about 1.5%). No underruns, no gaps.

The existing SyncErrorFilter floor-filters one-sided period flapping, but a multi-second relaxation of several hundred milliseconds is a different shape and gets through.

Related

App-side change that removes the cold start for every play after the first:
music-assistant/desktop-app#204

This follows the same idea as #71 (don't treat the beginning of a stream as if it may have drifted, and trust the server's audio until the two clocks have had a chance to differ). #71 and the clock warm-up suppression cover the start of the clock; this covers the start of the output stream, whose reported delay is not in steady state for the first seconds on some backends. The two use the same shape: suppress corrections for a bounded period, then resume normal operation.

Change

  • OutputDelaySettle (in sync_correction.rs): tracks the minimum reported delay over 50-callback windows and reports settled once the floor has stayed within 10 ms of the previous window for four consecutive windows. A plateau of a fraction of a second near the peak is not enough (an earlier version with two windows declared itself settled on exactly such a plateau, and the full-rate correction and warble came back at about 7-10 s).
  • While the delay is unsettled and less than 10 s have passed since playback started, corrections are held back (including gross reanchors; the stream was only just anchored). When it settles, the all-time delta floor used for reanchors is re-seeded and the error filter and engage gate are reset, since earlier measurements describe the transient.
  • For the first 20 s after playback starts, speed corrections are capped at 0.5% (under 9 cents) via CorrectionPlanner::with_max_speed_correction, so any residue of the transient that outlasts the grace period stays inaudible. Thresholds are unchanged.
  • New debug line Output delay settled at ... and a trace line for suppressed corrections.

Testing

  • cargo fmt --check, cargo clippy --all-targets, cargo test (346 passed). New unit tests: settles on a steady delay, ignores period flapping, rejects the cold-start ramp, rejects slow relaxation, rejects a plateau followed by more relaxation, un-settles when the delay moves again, reset, and the capped planner.
  • Real playback on Pop!_OS (ALSA via PulseAudio/PipeWire, USB DAC). Before: a 358-391 ms initial error and a 4% correction on every cold start. After (applied to v0.3.5 for the Companion, which pins that version): the delay settled at 251 ms about 3 s after start, 169 corrections were suppressed during the transient, the strongest speed correction in the run was 0.5%, and by ear the cold first play is clean.

Caveats

  • Only tested on Linux with this audio path. On backends whose delay is already steady (WASAPI, CoreAudio), the first correction after a start may be delayed by about 2-3 s while the tracker confirms the delay is steady. The engage gate already waits about 1 s, so this is a small change, but I haven't measured it there.
  • The 10 s / 20 s / 0.5% / 4-window constants are tuned from one setup. Happy to adjust or make them configurable.
  • Sync across multiple players is looser during the first few seconds of a cold start; the trade-off is no audible warble.

On some backends (observed with ALSA over PulseAudio/PipeWire) the output
delay reported for a freshly opened stream overshoots by a few hundred
milliseconds in the first second and then relaxes back over several
seconds, while the audio plays gaplessly. The sync error is measured
against that delay, so it moves with it, and the corrector responded by
speeding playback up by up to 4% and then slowing it down: an audible
warble for 5-10 seconds after each cold start.

- Add OutputDelaySettle, which tracks the floor of the reported output
  delay over 0.5s windows and reports settled once it has held steady for
  four consecutive windows.
- Hold corrections while the delay is unsettled, for at most 10s after
  playback starts. Re-seed the delay floor and reset the error filter and
  engage gate when it settles, since earlier measurements describe the
  transient.
- Limit speed corrections to 0.5% for the first 20s after a start, so any
  residue of the transient that outlasts the grace period stays inaudible.
- Add CorrectionPlanner::with_max_speed_correction.

Tests cover settling on a steady delay, period flapping, the cold-start
ramp, slow relaxation, a plateau followed by further relaxation, reset,
and the capped planner.
@CrazyDiam0nd-gh CrazyDiam0nd-gh changed the title Fixes an audible warble (pitch speeding up, then slowing down, for 5-10 s) after playback starts on a freshly opened output stream. Found while debugging the Music Assistant Companion app on Linux: https://github.com/music-assistant/desktop-app/issues/203. fix: hold sync corrections while a cold output stream's delay settles Oct 8, 2026
@teancom

teancom commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Lovely, thank you!

@teancom
teancom merged commit 85a7303 into Sendspin:main Oct 13, 2026
6 checks passed
@github-actions github-actions Bot mentioned this pull request Oct 13, 2026
teancom pushed a commit to music-assistant/desktop-app that referenced this pull request Oct 14, 2026
## Reuse the open audio output stream between streams

Fixes an audible warble (slow/wobbly pitch for 5-10 s) at the start of
every playlist on Linux. Addresses #203 for every play after the first;
the first play after launch is covered by the library PR linked at the
bottom, so this should not close the issue on its own.

### Problem

`run_playback_thread` builds a new `SyncedPlayer` on every
`CreatePlayer`, i.e. at every `StreamStart`. A freshly opened output
stream is cold: while it settles, the output delay the OS reports
overshoots by a few hundred milliseconds (about 550 ms within the first
second on my setup) and then relaxes back to its resting value (about
250 ms) over ~5 s. The audio plays gaplessly, but the sync error is
measured against that delay, so it swings by the same amount. The
corrector reads the swing as drift, speeds playback up by up to 4%
(`drop_every=25`), overshoots, and then slows it down by about 1.5%
(`insert_every=54..70`).

### Evidence

- The library's example player (unpatched `sendspin-rs`, same server,
same Flatpak sandbox) with one persistent output stream: no warble,
start errors of 4-47 ms, corrections of 0.2-0.5%.
- The same program with a single change, dropping and rebuilding the
output stream at each stream start: the warble comes back with the
Companion's signature (358-391 ms initial error, a 4% correction, then a
-30 to -37 ms overshoot).
- Ruled out: authenticated port 8095 vs direct 8927, the Flatpak
sandbox, the `sendspin-rs` version, PipeWire quantum, network.

### Change

On `CreatePlayer`, keep the existing output stream if all of these hold,
otherwise rebuild exactly as before:

- the PCM format is the same,
- the freshly resolved output device has the same identity as the one
the open stream was built for,
- the previous stream ended no more than 10 minutes ago
(`OUTPUT_STREAM_REUSE_MAX_IDLE`).

The device is still re-resolved on every `StreamStart`, so the behavior
from #40 (a device that disappeared or reconnected is picked up at the
next play) is kept. If the device identity cannot be determined, the
stream is rebuilt. The reuse decision is the pure function
`can_reuse_output_stream`, with unit tests.

### Testing

- `cargo fmt --check`, `cargo clippy --all-targets --all-features -- -D
warnings`, `cargo test` (132 passed, 4 new).
- Built and ran on Pop!_OS (ALSA via PulseAudio/PipeWire, USB DAC). Ten
stream starts in a row: the first created a stream, the other nine
logged `Reusing open audio output stream`, and none of the later plays
warbled.
- Not tested: macOS, Windows, Bluetooth devices.

### Notes

- The first play after launch (or after more than 10 minutes idle) still
opens a cold stream. That part is addressed in the library:
Sendspin/sendspin-rs#103. This app currently
pins `sendspin-rs` at `v0.3.5`, so it will need a version bump once that
is released.
- With the library fix and this change together, the first play and all
later plays are clean on my setup; the strongest speed correction in a
run drops from 4% to 0.5%.
teancom pushed a commit that referenced this pull request Oct 15, 2026
## 🤖 New release

* `sendspin`: 0.3.7 -> 0.3.8 (✓ API compatible changes)

<details><summary><i><b>Changelog</b></i></summary><p>

<blockquote>

##
[0.3.8](v0.3.7...v0.3.8)
- 2026-10-15

### Fixed

- *(audio)* support unsigned 24-bit output
([#107](#107))
- hold sync corrections while a cold output stream's delay settles
([#103](#103))

### Other

- cover startup synchronization correction transitions
([#106](#106))

### Fixed

- Stop a freshly opened output stream from being corrected at up to 4%
speed
for several seconds after playback starts. On some backends (observed
with
ALSA over PulseAudio/PipeWire) the reported output delay of a cold
stream
overshoots by a few hundred milliseconds and then relaxes while the
audio
plays gaplessly, and the sync error moves with it. Corrections are now
held
back until 500ms observation-window floors stay within a 10ms range over
a baseline window and two seconds of comparisons, independent of
callback
size (capped at 10s after playback starts). Speed corrections are
limited to
0.5% for the first 20s after a start. Callback gaps of 500ms or more
discard
settling evidence. The all-time delta floor used for reanchors is
re-seeded
  from the observed floor each time the delay settles.
</blockquote>


</p></details>

---
This PR was generated with
[release-plz](https://github.com/release-plz/release-plz/).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants