From 9af1c0e82bf83a2be6a7e4b5732645a3f95291fa Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 12 Mar 2026 16:38:32 +0100 Subject: [PATCH 01/10] feat: handle `set_static_delay` server command and advertise support Add SET_STATIC_DELAY handling in TUI and daemon, pass state_supported_commands to SendspinClient, clamp keyboard delay adjustment to 0-5000ms range. --- sendspin/daemon/daemon.py | 9 +++++++++ sendspin/tui/app.py | 11 +++++++++++ sendspin/tui/keyboard.py | 3 ++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index 8432417..a0c6965 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -102,6 +102,7 @@ def _create_client(self, static_delay_ms: float = 0.0) -> SendspinClient: supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], ), static_delay_ms=static_delay_ms, + state_supported_commands=[PlayerCommand.SET_STATIC_DELAY], initial_volume=self._audio_handler.volume, initial_muted=self._audio_handler.muted, ) @@ -339,6 +340,14 @@ def _handle_server_command(self, payload: ServerCommandPayload) -> None: elif player_cmd.command == PlayerCommand.MUTE and player_cmd.mute is not None: self._audio_handler.set_volume(self._audio_handler.volume, muted=player_cmd.mute) logger.info("Server %s player", "muted" if player_cmd.mute else "unmuted") + elif ( + player_cmd.command == PlayerCommand.SET_STATIC_DELAY + and player_cmd.static_delay_ms is not None + ): + # Client library already applied the delay change; persist to settings + assert self._client is not None + self._settings.update(static_delay_ms=self._client.static_delay_ms) + logger.info("Server set delay: %dms", player_cmd.static_delay_ms) def _handle_format_change( self, codec: str | None, sample_rate: int, bit_depth: int, channels: int diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 6503cb7..5fab70a 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -305,6 +305,7 @@ def _create_client(self) -> SendspinClient: ), visualizer_support=visualizer_support, static_delay_ms=delay, + state_supported_commands=[PlayerCommand.SET_STATIC_DELAY], initial_volume=self._audio_handler.volume, initial_muted=self._audio_handler.muted, ) @@ -769,6 +770,16 @@ def _handle_server_command(self, payload: ServerCommandPayload) -> None: self._ui.add_event( "Server muted player" if player_cmd.mute else "Server unmuted player" ) + elif ( + player_cmd.command == PlayerCommand.SET_STATIC_DELAY + and player_cmd.static_delay_ms is not None + ): + # Client library already applied the delay change; + # update UI and persist to settings + assert self._client is not None + self._ui.set_delay(self._client.static_delay_ms) + self._settings.update(static_delay_ms=self._client.static_delay_ms) + self._ui.add_event(f"Server set delay: {player_cmd.static_delay_ms}ms") def _handle_format_change( self, codec: str | None, sample_rate: int, bit_depth: int, channels: int diff --git a/sendspin/tui/keyboard.py b/sendspin/tui/keyboard.py index c913686..cddad1f 100644 --- a/sendspin/tui/keyboard.py +++ b/sendspin/tui/keyboard.py @@ -112,7 +112,8 @@ async def adjust_delay(self, delta: float) -> None: client = self._get_client() if client is None: return - client.set_static_delay_ms(client.static_delay_ms + delta) + new_delay = max(0, min(5000, client.static_delay_ms + delta)) + client.set_static_delay_ms(new_delay) self._ui.set_delay(client.static_delay_ms) self._settings.update(static_delay_ms=client.static_delay_ms) From affaff9cfac00c393440b66a79f3374ab72fd8dd Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Fri, 13 Mar 2026 13:55:14 +0100 Subject: [PATCH 02/10] fix: report delay changes to server after TUI adjustment Without this, TUI delay adjustments were local-only and the server never learned about the new static_delay_ms value. --- sendspin/tui/keyboard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sendspin/tui/keyboard.py b/sendspin/tui/keyboard.py index cddad1f..885a8b2 100644 --- a/sendspin/tui/keyboard.py +++ b/sendspin/tui/keyboard.py @@ -116,6 +116,7 @@ async def adjust_delay(self, delta: float) -> None: client.set_static_delay_ms(new_delay) self._ui.set_delay(client.static_delay_ms) self._settings.update(static_delay_ms=client.static_delay_ms) + self._audio_handler.send_player_volume() def close_server_selector(self) -> None: """Close the server selector panel.""" From 7c272f906392e67e23143fc51ec7c3987281757f Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 16 Mar 2026 16:30:12 +0100 Subject: [PATCH 03/10] refactor: use `PlayerCommand`, migrate old negative delay values --- sendspin/settings.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/sendspin/settings.py b/sendspin/settings.py index 0aedc55..73b6339 100644 --- a/sendspin/settings.py +++ b/sendspin/settings.py @@ -61,7 +61,9 @@ def _update_fields(self, updates: dict[str, Any]) -> bool: async def load(self) -> None: """Load settings from disk.""" loop = asyncio.get_running_loop() - await loop.run_in_executor(None, self._load) + needs_save = await loop.run_in_executor(None, self._load) + if needs_save: + self._schedule_save() async def flush(self) -> None: """Immediately save any pending changes to disk.""" @@ -86,8 +88,11 @@ def _debounced_save(self, loop: asyncio.AbstractEventLoop) -> None: self._debounce_save_handle = None loop.run_in_executor(None, self._save) - def _load(self) -> None: - """Load settings from the settings file (blocking I/O).""" + def _load(self) -> bool: + """Load settings from the settings file (blocking I/O). + + Returns True if a save is needed (e.g., migration applied). + """ raise NotImplementedError def _save(self) -> None: @@ -179,11 +184,12 @@ def update( if changed: self._schedule_save() - def _load(self) -> None: + def _load(self) -> bool: """Load settings from the settings file (blocking I/O).""" + needs_save = False if self._settings_file is None or not self._settings_file.exists(): logger.debug("Settings file does not exist: %s", self._settings_file) - return + return False try: data = json.loads(self._settings_file.read_text()) @@ -194,6 +200,14 @@ def _load(self) -> None: self.player_volume = data.get("player_volume", 25) self.player_muted = data.get("player_muted", False) self.static_delay_ms = data.get("static_delay_ms", 0.0) + # Migrate old sign convention: negative values meant "play earlier", + # new convention uses positive values (0-5000) for the same effect. + if self.static_delay_ms < 0: + self.static_delay_ms = min(5000.0, -self.static_delay_ms) + needs_save = True + elif self.static_delay_ms > 5000: + self.static_delay_ms = 5000.0 + needs_save = True self.last_server_url = data.get("last_server_url") self.client_id = data.get("client_id") self.audio_device = data.get("audio_device") @@ -214,6 +228,7 @@ def _load(self) -> None: ) except (json.JSONDecodeError, OSError) as e: logger.warning("Failed to load settings from %s: %s", self._settings_file, e) + return needs_save @dataclass @@ -249,11 +264,11 @@ def update( if changed: self._schedule_save() - def _load(self) -> None: + def _load(self) -> bool: """Load settings from the settings file (blocking I/O).""" if self._settings_file is None or not self._settings_file.exists(): logger.debug("Settings file does not exist: %s", self._settings_file) - return + return False try: data = json.loads(self._settings_file.read_text()) @@ -266,6 +281,7 @@ def _load(self) -> None: logger.info("Loaded settings from %s", self._settings_file) except (json.JSONDecodeError, OSError) as e: logger.warning("Failed to load settings from %s: %s", self._settings_file, e) + return False async def get_client_settings( From 319f17f6d4dc354dcbebc90c7c4a1871d523d2d4 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Fri, 27 Mar 2026 11:46:34 +0100 Subject: [PATCH 04/10] docs: update delay examples to use positive values --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d90cc06..a2263c0 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Settings are stored in `~/.config/sendspin/`: { "player_volume": 50, "player_muted": false, - "static_delay_ms": -100.0, + "static_delay_ms": 100.0, "last_server_url": "ws://192.168.1.100:8927/sendspin", "name": "Living Room", "client_id": "sendspin-living-room", @@ -287,10 +287,10 @@ Because Sendspin cannot read back external device state from the hook, startup v The player supports adjusting playback delay to compensate for audio hardware latency or achieve better synchronization across devices. ```bash -sendspin --static-delay-ms -100 +sendspin --static-delay-ms 100 ``` -> **Note:** Based on limited testing, the delay value is typically a negative number (e.g., `-100` or `-150`) to compensate for audio hardware buffering. +> **Note:** Based on limited testing, a delay value of 100-150ms is typical to compensate for audio hardware buffering. ### Daemon Mode From 3382faa29d24d9c72aac7baaa1560202ba22f872 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Fri, 10 Apr 2026 15:06:25 +0200 Subject: [PATCH 05/10] feat: smooth playback adjustment on static delay changes Instead of clearing the audio buffer (which the server won't resend), shift the server timestamp cursor so sync correction gradually speeds up or slows down playback to match the new delay. Plumb the delay delta through the audio work queue for both server-initiated and keyboard-initiated changes. --- sendspin/audio.py | 22 ++++++++++++++++++---- sendspin/audio_connector.py | 25 ++++++++++++++++++++++++- sendspin/daemon/daemon.py | 7 ++++++- sendspin/tui/app.py | 7 ++++++- sendspin/tui/keyboard.py | 6 +++++- 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/sendspin/audio.py b/sendspin/audio.py index 763217c..d8abfc8 100644 --- a/sendspin/audio.py +++ b/sendspin/audio.py @@ -149,7 +149,8 @@ def __init__( timestamps (monotonic loop time), accounting for clock drift, offset, and static delay. compute_server_time: Function that converts client timestamps (monotonic - loop time) to server timestamps (inverse of compute_client_time). + loop time) to server timestamps. Pure clock-domain conversion + without static delay adjustment. """ self._compute_client_time = compute_client_time self._compute_server_time = compute_server_time @@ -283,6 +284,20 @@ def set_volume(self, volume: int, *, muted: bool) -> None: self._volume = max(0, min(100, volume)) self._muted = muted + def apply_delay_change(self, delta_us: int) -> None: + """Adjust playback timing after a static delay change. + + Offsets the server timestamp cursor so the sync correction mechanism + gradually speeds up or slows down playback to match the new delay. + This avoids clearing the audio buffer (which the server won't resend). + + Args: + delta_us: Delay change in microseconds (positive = delay increased, + audio should play earlier, cursor shifts back). + """ + if self._server_ts_cursor_us > 0: + self._server_ts_cursor_us -= delta_us + def is_drained(self) -> bool: """Return True when the internal audio queue is empty. @@ -532,11 +547,10 @@ def _update_playback_position_from_dac(self, time: AudioTimeInfo) -> None: logger.exception("Failed to estimate playback position") # If we haven't set the DAC-anchored start yet, approximate it now - if self._scheduled_start_dac_time_us is None and self._scheduled_start_loop_time_us: + if self._scheduled_start_dac_time_us is None and self._first_server_timestamp_us: try: - loop_start = self._scheduled_start_loop_time_us est_dac = self._estimate_dac_time_for_server_timestamp( - self._compute_server_time(loop_start) + self._first_server_timestamp_us ) if est_dac: self._scheduled_start_dac_time_us = est_dac diff --git a/sendspin/audio_connector.py b/sendspin/audio_connector.py index 4401a5c..6b2cbb1 100644 --- a/sendspin/audio_connector.py +++ b/sendspin/audio_connector.py @@ -49,12 +49,21 @@ class _SetVolumeWorkItem: muted: bool +@dataclass(slots=True) +class _DelayChangeWorkItem: + """Delay change notification for the synchronous audio worker.""" + + delta_us: int + + @dataclass(slots=True) class _StopWorkItem: """Stop signal for the synchronous audio worker.""" -type _AudioWorkItem = _ChunkWorkItem | _ClearWorkItem | _SetVolumeWorkItem | _StopWorkItem +type _AudioWorkItem = ( + _ChunkWorkItem | _ClearWorkItem | _SetVolumeWorkItem | _DelayChangeWorkItem | _StopWorkItem +) class _AudioSyncWorker: @@ -108,6 +117,10 @@ def clear(self) -> None: """Clear queued audio on worker.""" self._enqueue(_ClearWorkItem()) + def notify_delay_change(self, delta_us: int) -> None: + """Notify the worker that static delay changed.""" + self._enqueue(_DelayChangeWorkItem(delta_us=delta_us)) + def set_volume(self, volume: int, *, muted: bool) -> None: """Update software volume and forward to worker if enabled.""" if self._use_software_volume: @@ -183,6 +196,10 @@ def _run( player.clear() continue + if item_type is _DelayChangeWorkItem: + player.apply_delay_change(cast(_DelayChangeWorkItem, item).delta_us) + continue + if item_type is _SetVolumeWorkItem: if self._use_software_volume: volume_item = cast(_SetVolumeWorkItem, item) @@ -436,6 +453,12 @@ def _start_audio_worker(self, client: SendspinClient) -> None: if not self._audio_worker.is_running(): raise RuntimeError("Audio worker failed to start") + def notify_delay_change(self, delta_us: int) -> None: + """Notify the audio worker that static delay changed.""" + worker = self._audio_worker + if worker is not None and worker.is_running(): + worker.notify_delay_change(delta_us) + def _clear_audio_worker(self) -> None: """Clear worker queue when worker is available.""" worker = self._audio_worker diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index a0c6965..2cba492 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -344,8 +344,13 @@ def _handle_server_command(self, payload: ServerCommandPayload) -> None: player_cmd.command == PlayerCommand.SET_STATIC_DELAY and player_cmd.static_delay_ms is not None ): - # Client library already applied the delay change; persist to settings + # Client library already applied the delay change; + # notify audio worker so sync correction adjusts timing gradually assert self._client is not None + old_delay_ms = self._settings.static_delay_ms + delta_us = int((self._client.static_delay_ms - old_delay_ms) * 1000) + if delta_us != 0: + self._audio_handler.notify_delay_change(delta_us) self._settings.update(static_delay_ms=self._client.static_delay_ms) logger.info("Server set delay: %dms", player_cmd.static_delay_ms) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 5fab70a..a851f82 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -775,8 +775,13 @@ def _handle_server_command(self, payload: ServerCommandPayload) -> None: and player_cmd.static_delay_ms is not None ): # Client library already applied the delay change; - # update UI and persist to settings + # notify audio worker so sync correction adjusts timing gradually assert self._client is not None + assert self._audio_handler is not None + old_delay_ms = self._settings.static_delay_ms + delta_us = int((self._client.static_delay_ms - old_delay_ms) * 1000) + if delta_us != 0: + self._audio_handler.notify_delay_change(delta_us) self._ui.set_delay(self._client.static_delay_ms) self._settings.update(static_delay_ms=self._client.static_delay_ms) self._ui.add_event(f"Server set delay: {player_cmd.static_delay_ms}ms") diff --git a/sendspin/tui/keyboard.py b/sendspin/tui/keyboard.py index 885a8b2..b542b73 100644 --- a/sendspin/tui/keyboard.py +++ b/sendspin/tui/keyboard.py @@ -112,8 +112,12 @@ async def adjust_delay(self, delta: float) -> None: client = self._get_client() if client is None: return - new_delay = max(0, min(5000, client.static_delay_ms + delta)) + old_delay = client.static_delay_ms + new_delay = max(0, min(5000, old_delay + delta)) client.set_static_delay_ms(new_delay) + actual_delta_us = int((client.static_delay_ms - old_delay) * 1000) + if actual_delta_us != 0: + self._audio_handler.notify_delay_change(actual_delta_us) self._ui.set_delay(client.static_delay_ms) self._settings.update(static_delay_ms=client.static_delay_ms) self._audio_handler.send_player_volume() From 439ac0af65bb267644ddf08d96885f08a3871461 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Fri, 10 Apr 2026 15:47:36 +0200 Subject: [PATCH 06/10] refactor: don't save delay migration to storage immediately --- sendspin/settings.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/sendspin/settings.py b/sendspin/settings.py index 73b6339..833e5ef 100644 --- a/sendspin/settings.py +++ b/sendspin/settings.py @@ -186,7 +186,6 @@ def update( def _load(self) -> bool: """Load settings from the settings file (blocking I/O).""" - needs_save = False if self._settings_file is None or not self._settings_file.exists(): logger.debug("Settings file does not exist: %s", self._settings_file) return False @@ -200,14 +199,11 @@ def _load(self) -> bool: self.player_volume = data.get("player_volume", 25) self.player_muted = data.get("player_muted", False) self.static_delay_ms = data.get("static_delay_ms", 0.0) - # Migrate old sign convention: negative values meant "play earlier", - # new convention uses positive values (0-5000) for the same effect. + # Clamp to valid range; also handles old negative sign convention. if self.static_delay_ms < 0: self.static_delay_ms = min(5000.0, -self.static_delay_ms) - needs_save = True elif self.static_delay_ms > 5000: self.static_delay_ms = 5000.0 - needs_save = True self.last_server_url = data.get("last_server_url") self.client_id = data.get("client_id") self.audio_device = data.get("audio_device") @@ -228,7 +224,7 @@ def _load(self) -> bool: ) except (json.JSONDecodeError, OSError) as e: logger.warning("Failed to load settings from %s: %s", self._settings_file, e) - return needs_save + return False @dataclass From 930ae513f1d9ed6b470303ca66e99dc595758c13 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 11 Apr 2026 20:47:00 +0200 Subject: [PATCH 07/10] Bump aiosendspin to 5.0 --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c380cf3..0463390 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,10 +14,10 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "aiosendspin~=4.4", + "aiosendspin[server]~=5.0", "aiosendspin-mpris~=2.1.1", - "av>=14.0.0", - "numpy>=1.24.0", + "av>=15.0.0", + "numpy>=1.26.0", "pulsectl-asyncio>=1.2.2; platform_system == 'Linux'", "qrcode>=8.0", "readchar>=4.0.0", From bf59e6de85586ff5142c18286feb2f0a123780e6 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 14 Apr 2026 11:12:28 +0200 Subject: [PATCH 08/10] fix: use client clock domain for time synchronization aiosendspin switched to CLOCK_MONOTONIC_RAW on Linux. Use `client.now_us()` instead of `time.monotonic()` / `loop.time()` to keep all components in the same clock domain. --- sendspin/audio.py | 14 +++++++------- sendspin/audio_connector.py | 8 ++++++-- sendspin/visualizer_connector.py | 6 +++--- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/sendspin/audio.py b/sendspin/audio.py index d8abfc8..1852b74 100644 --- a/sendspin/audio.py +++ b/sendspin/audio.py @@ -140,20 +140,25 @@ def __init__( self, compute_client_time: Callable[[int], int], compute_server_time: Callable[[int], int], + now_us: Callable[[], int] | None = None, ) -> None: """ Initialize the audio player. Args: compute_client_time: Function that converts server timestamps to client - timestamps (monotonic loop time), accounting for clock drift, offset, + timestamps (monotonic clock time), accounting for clock drift, offset, and static delay. compute_server_time: Function that converts client timestamps (monotonic - loop time) to server timestamps. Pure clock-domain conversion + clock time) to server timestamps. Pure clock-domain conversion without static delay adjustment. + now_us: Function returning current monotonic time in microseconds. + Must be in the same clock domain as compute_client_time. + Defaults to time.monotonic(). """ 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._format: PCMFormat | None = None self._queue: queue.Queue[_QueuedChunk] = queue.Queue() self._stream: sounddevice.RawOutputStream | None = None @@ -775,11 +780,6 @@ def _get_current_playback_position_us(self) -> int: """Get the current playback position in server timestamp space.""" return self._last_known_playback_position_us - @staticmethod - def _now_us() -> int: - """Return current monotonic time in microseconds.""" - return int(time.monotonic() * 1_000_000) - def get_timing_metrics(self) -> dict[str, float]: """Return current timing metrics for monitoring.""" return { diff --git a/sendspin/audio_connector.py b/sendspin/audio_connector.py index 6b2cbb1..2657a73 100644 --- a/sendspin/audio_connector.py +++ b/sendspin/audio_connector.py @@ -89,11 +89,13 @@ def start( self, compute_play_time: Callable[[int], int], compute_server_time: Callable[[int], int], + now_us: Callable[[], int] | 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._queue = queue.Queue(maxsize=512) self._thread = threading.Thread( target=self._run, @@ -172,7 +174,7 @@ def _run( if queue_obj is None: return - player = AudioPlayer(compute_play_time, compute_server_time) + player = AudioPlayer(compute_play_time, compute_server_time, now_us=self._now_us) current_format: AudioFormat | None = None flac_decoder: FlacDecoder | None = None software_volume = self._initial_volume @@ -449,7 +451,9 @@ def _start_audio_worker(self, client: SendspinClient) -> None: muted=self._muted, ) - self._audio_worker.start(client.compute_play_time, client.compute_server_time) + self._audio_worker.start( + client.compute_play_time, client.compute_server_time, client.now_us + ) if not self._audio_worker.is_running(): raise RuntimeError("Audio worker failed to start") diff --git a/sendspin/visualizer_connector.py b/sendspin/visualizer_connector.py index 29f7ecd..c1498be 100644 --- a/sendspin/visualizer_connector.py +++ b/sendspin/visualizer_connector.py @@ -126,10 +126,10 @@ def _schedule_next(self) -> None: self._timer.cancel() self._timer = None - loop = asyncio.get_running_loop() - now_us = int(loop.time() * 1_000_000) + now_us = self._client.now_us() next_play_us = self._pending[0][0] delay_s = max(0.0, (next_play_us - now_us) / 1_000_000.0) + loop = asyncio.get_running_loop() self._timer = loop.call_later(delay_s, self._emit_due_frames) def _emit_due_frames(self) -> None: @@ -138,7 +138,7 @@ def _emit_due_frames(self) -> None: if self._client is None or not self._pending: return - now_us = int(asyncio.get_running_loop().time() * 1_000_000) + now_us = self._client.now_us() latest_due: VisualizerFrame | None = None while self._pending and self._pending[0][0] <= now_us: _play_us, frame = self._pending.popleft() From 9bdf09de4f7503d6854e692ab974b8592e6a4dae Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 14 Apr 2026 14:28:12 +0200 Subject: [PATCH 09/10] Bump aiosendspin to 5.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0463390..ef8d44d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "aiosendspin[server]~=5.0", + "aiosendspin[server]~=5.1", "aiosendspin-mpris~=2.1.1", "av>=15.0.0", "numpy>=1.26.0", From 995f55d35c4dbf29758d97b731aa36f943ec67be Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 14 Apr 2026 15:32:11 +0200 Subject: [PATCH 10/10] fix: handle `_DelayChangeWorkItem` in format-switch drain loop Without this, a delay change during draining would fall through and be miscast as a `_ChunkWorkItem`. --- sendspin/audio_connector.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sendspin/audio_connector.py b/sendspin/audio_connector.py index 2657a73..d6aaf56 100644 --- a/sendspin/audio_connector.py +++ b/sendspin/audio_connector.py @@ -241,6 +241,9 @@ def _run( software_muted = vol.muted player.set_volume(software_volume, muted=software_muted) continue + if drain_type is _DelayChangeWorkItem: + player.apply_delay_change(cast(_DelayChangeWorkItem, drain_item).delta_us) + continue # Buffer incoming new-format chunks during drain buffered_chunks.append(cast(_ChunkWorkItem, drain_item)) drained = player.is_drained()