From 483d7449b93984ee89c863e241ab4e323f6b60fc Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 6 May 2026 15:12:34 +0200 Subject: [PATCH 1/2] fix: don't flag mid-stream join as unsynced start fallback A near-now scheduled start with a converged time filter is a real mid-stream join, not the `now + 500ms` fallback in `compute_play_time`. Require the clock to be unsynced before suspecting fallback. --- sendspin/audio.py | 14 ++++++++++++-- sendspin/audio_connector.py | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/sendspin/audio.py b/sendspin/audio.py index 22fcf33..b189e95 100644 --- a/sendspin/audio.py +++ b/sendspin/audio.py @@ -141,6 +141,7 @@ def __init__( compute_client_time: Callable[[int], int], compute_server_time: Callable[[int], int], now_us: Callable[[], int] | None = None, + is_clock_synced: Callable[[], bool] | None = None, ) -> None: """ Initialize the audio player. @@ -155,10 +156,15 @@ def __init__( now_us: Function returning current monotonic time in microseconds. Must be in the same clock domain as compute_client_time. Defaults to time.monotonic(). + is_clock_synced: Returns True when the time filter has converged. + Used to tell a real mid-stream join (start time near now, clock + trustworthy) apart from the unsynced fallback in compute_play_time + (now + 500ms). Defaults to always-True. """ self._compute_client_time = compute_client_time self._compute_server_time = compute_server_time self._now_us = now_us or (lambda: int(time.monotonic() * 1_000_000)) + self._is_clock_synced = is_clock_synced or (lambda: True) self._format: PCMFormat | None = None self._queue: queue.Queue[_QueuedChunk] = queue.Queue() self._stream: sounddevice.RawOutputStream | None = None @@ -1143,10 +1149,14 @@ def submit(self, server_timestamp_us: int, payload: bytes | bytearray) -> None: self._scheduled_start_dac_time_us = est_dac if est_dac else None self._playback_state = PlaybackState.WAITING_FOR_START self._first_server_timestamp_us = server_timestamp_us - # If scheduled start is very near now, suspect unsynchronized fallback mapping + # Near-now start with unsynced clock = `compute_play_time` fallback; + # suppress catch-up. Synced near-now is a real mid-stream join. # Cast: we just set this via _compute_and_set_loop_start so it's not None scheduled_start = cast("int", self._scheduled_start_loop_time_us) - if scheduled_start - now_us <= self._EARLY_START_THRESHOLD_US: + if ( + scheduled_start - now_us <= self._EARLY_START_THRESHOLD_US + and not self._is_clock_synced() + ): self._early_start_suspect = True # While waiting to start, keep the scheduled loop start updated as time sync improves diff --git a/sendspin/audio_connector.py b/sendspin/audio_connector.py index 910034b..fd82602 100644 --- a/sendspin/audio_connector.py +++ b/sendspin/audio_connector.py @@ -100,12 +100,14 @@ def start( compute_play_time: Callable[[int], int], compute_server_time: Callable[[int], int], now_us: Callable[[], int] | None = None, + is_clock_synced: Callable[[], bool] | None = None, ) -> None: """Start worker thread if needed.""" if self._thread is not None and self._thread.is_alive(): return self._now_us = now_us + self._is_clock_synced = is_clock_synced self._queue = queue.Queue(maxsize=512) self._thread = threading.Thread( target=self._run, @@ -188,7 +190,12 @@ def _run( if queue_obj is None: return - player = AudioPlayer(compute_play_time, compute_server_time, now_us=self._now_us) + player = AudioPlayer( + compute_play_time, + compute_server_time, + now_us=self._now_us, + is_clock_synced=self._is_clock_synced, + ) current_format: AudioFormat | None = None flac_decoder: FlacDecoder | None = None software_volume = self._initial_volume @@ -484,7 +491,10 @@ def _start_audio_worker(self, client: SendspinClient) -> None: ) self._audio_worker.start( - client.compute_play_time, client.compute_server_time, client.now_us + client.compute_play_time, + client.compute_server_time, + client.now_us, + client.is_time_synchronized, ) if not self._audio_worker.is_running(): raise RuntimeError("Audio worker failed to start") From 2ee722512fa8881177b1d98089c6c9fee42e1533 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 6 May 2026 15:54:27 +0200 Subject: [PATCH 2/2] test: update audio worker fakes for new start() params --- tests/test_audio_connector.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_audio_connector.py b/tests/test_audio_connector.py index ed6ec94..055c67b 100644 --- a/tests/test_audio_connector.py +++ b/tests/test_audio_connector.py @@ -29,10 +29,18 @@ def __init__( self.submitted: list[tuple[int, bytes | bytearray, object]] = [] _FakeWorker.instances.append(self) - def start(self, compute_play_time: object, compute_server_time: object) -> None: + def start( + self, + compute_play_time: object, + compute_server_time: object, + now_us: object | None = None, + is_clock_synced: object | None = None, + ) -> None: self.running = True self.compute_play_time = compute_play_time self.compute_server_time = compute_server_time + self.now_us = now_us + self.is_clock_synced = is_clock_synced def is_running(self) -> bool: return self.running @@ -70,6 +78,12 @@ def compute_play_time(self, timestamp_us: int) -> int: def compute_server_time(self, timestamp_us: int) -> int: return timestamp_us + def now_us(self) -> int: + return 0 + + def is_time_synchronized(self) -> bool: + return True + async def send_player_state(self, **_: object) -> None: return