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 diff --git a/pyproject.toml b/pyproject.toml index c380cf3..ef8d44d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,10 +14,10 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "aiosendspin~=4.4", + "aiosendspin[server]~=5.1", "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", diff --git a/sendspin/audio.py b/sendspin/audio.py index 763217c..1852b74 100644 --- a/sendspin/audio.py +++ b/sendspin/audio.py @@ -140,19 +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 (inverse of compute_client_time). + 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 @@ -283,6 +289,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 +552,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 @@ -761,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 4401a5c..d6aaf56 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: @@ -80,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, @@ -108,6 +119,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: @@ -159,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 @@ -183,6 +198,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) @@ -222,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() @@ -432,10 +454,18 @@ 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") + 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 8432417..2cba492 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,19 @@ 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; + # 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) def _handle_format_change( self, codec: str | None, sample_rate: int, bit_depth: int, channels: int diff --git a/sendspin/settings.py b/sendspin/settings.py index 0aedc55..833e5ef 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,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()) @@ -194,6 +199,11 @@ 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) + # 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) + elif self.static_delay_ms > 5000: + self.static_delay_ms = 5000.0 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 +224,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 False @dataclass @@ -249,11 +260,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 +277,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( diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 6503cb7..a851f82 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,21 @@ 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; + # 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") 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..b542b73 100644 --- a/sendspin/tui/keyboard.py +++ b/sendspin/tui/keyboard.py @@ -112,9 +112,15 @@ 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) + 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() def close_server_selector(self) -> None: """Close the server selector panel.""" 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()