Skip to content

Commit ffd2722

Browse files
Fix sync offset by reanchoring cursor to DAC (#226)
The server timestamp cursor (`_server_ts_cursor_us`) tracked bytes-read-from-queue, which always leads actual DAC playback by the output buffer depth. The sync correction loop saw this as a constant error and converged to the wrong target. Reanchor the cursor to the DAC-derived server time on first playback and on large sync errors. Replaces the destructive `clear()` reanchor (which dropped all buffered audio and restarted) with a lightweight cursor snap. Also logs PortAudio output latency for diagnostics. Supersedes: #222
1 parent a055dc0 commit ffd2722

1 file changed

Lines changed: 30 additions & 10 deletions

File tree

sendspin/audio.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ def __init__(
166166
self._volume: int = 100 # 0-100 range
167167
self._muted: bool = False
168168

169+
self._output_latency_us: int = 0
170+
169171
# Partial chunk tracking (to avoid discarding partial chunks)
170172
self._current_chunk: _QueuedChunk | None = None
171173
self._current_chunk_offset = 0
@@ -206,6 +208,7 @@ def __init__(
206208
self._first_server_timestamp_us: int | None = None
207209
self._early_start_suspect: bool = False
208210
self._has_reanchored: bool = False
211+
self._force_reanchor: bool = True
209212

210213
# Low-overhead drift/sync correction scheduling (sample drop/insert)
211214
self._insert_every_n_frames: int = 0
@@ -252,13 +255,15 @@ def set_format(self, audio_format: AudioFormat, device: AudioDevice) -> None:
252255
latency="high",
253256
device=device.device_id,
254257
)
258+
self._output_latency_us = int(self._stream.latency * self._MICROSECONDS_PER_SECOND)
255259
logger.info(
256-
"Audio stream configured: codec=%s, sample_rate=%d, channels=%d, bit_depth=%d, blocksize=%d, latency=high, device=%s",
260+
"Audio stream configured: codec=%s, sample_rate=%d, channels=%d, bit_depth=%d, blocksize=%d, latency=high, output_latency=%.1f ms, device=%s",
257261
audio_format.codec.value,
258262
pcm_format.sample_rate,
259263
pcm_format.channels,
260264
pcm_format.bit_depth,
261265
self._BLOCKSIZE,
266+
self._output_latency_us / 1000.0,
262267
device.device_id,
263268
)
264269

@@ -340,6 +345,7 @@ def clear(self) -> None:
340345
self._first_server_timestamp_us = None
341346
self._early_start_suspect = False
342347
self._has_reanchored = False
348+
self._force_reanchor = True
343349
self._insert_every_n_frames = 0
344350
self._drop_every_n_frames = 0
345351
self._frames_until_next_insert = 0
@@ -389,6 +395,25 @@ def _audio_callback( # noqa: PLR0915
389395

390396
# Capture exact DAC output time and update playback position
391397
self._update_playback_position_from_dac(time)
398+
399+
# Reanchor: snap read cursor to DAC-derived server time so the
400+
# cursor tracks actual playback position, not bytes-read position.
401+
if (
402+
self._playback_state == PlaybackState.PLAYING
403+
and self._last_known_playback_position_us > 0
404+
and self._server_ts_cursor_us > 0
405+
and self._force_reanchor
406+
):
407+
self._server_ts_cursor_us = self._last_known_playback_position_us
408+
self._server_ts_cursor_remainder = 0
409+
self._force_reanchor = False
410+
self._insert_every_n_frames = 0
411+
self._drop_every_n_frames = 0
412+
self._frames_until_next_insert = 0
413+
self._frames_until_next_drop = 0
414+
self._sync_error_filter.reset()
415+
self._sync_error_filtered_us = 0.0
416+
392417
bytes_written = 0
393418

394419
try:
@@ -772,6 +797,7 @@ def get_timing_metrics(self) -> dict[str, float]:
772797
"playback_position_us": float(self._get_current_playback_position_us()),
773798
"buffered_audio_us": float(self._queued_duration_us),
774799
"dac_samples_recorded": len(self._dac_loop_calibrations),
800+
"output_latency_us": float(self._output_latency_us),
775801
}
776802

777803
def _log_chunk_timing(self, _server_timestamp_us: int) -> None:
@@ -1004,22 +1030,16 @@ def _update_correction_schedule(self, error_us: int) -> None:
10041030
self._drop_every_n_frames = 0
10051031
return
10061032

1007-
# Re-anchor only if error is very large and cooldown has elapsed
1033+
# Re-anchor if error is very large and cooldown has elapsed.
10081034
now_loop_us = self._now_us()
10091035
if (
10101036
abs_err > self._REANCHOR_THRESHOLD_US
10111037
and self._playback_state == PlaybackState.PLAYING
10121038
and now_loop_us - self._last_reanchor_loop_time_us > self._REANCHOR_COOLDOWN_US
10131039
):
1014-
logger.info("Sync error %.1f ms too large; re-anchoring", abs_err / 1000.0)
1015-
# Reset cadence
1016-
self._insert_every_n_frames = 0
1017-
self._drop_every_n_frames = 0
1018-
self._frames_until_next_insert = 0
1019-
self._frames_until_next_drop = 0
1040+
logger.info("Sync error %.1f ms too large; scheduling reanchor", abs_err / 1000.0)
10201041
self._last_reanchor_loop_time_us = now_loop_us
1021-
# Re-anchor on next chunk boundary by clearing queue
1022-
self.clear()
1042+
self._force_reanchor = True
10231043
return
10241044

10251045
# Simple proportional control: correction rate proportional to error

0 commit comments

Comments
 (0)