Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions sendspin/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions sendspin/audio_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Comment thread
maximmaxim345 marked this conversation as resolved.
if not self._audio_worker.is_running():
raise RuntimeError("Audio worker failed to start")
Expand Down
16 changes: 15 additions & 1 deletion tests/test_audio_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down