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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 24 additions & 10 deletions sendspin/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 33 additions & 3 deletions sendspin/audio_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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))

Comment thread
maximmaxim345 marked this conversation as resolved.
def set_volume(self, volume: int, *, muted: bool) -> None:
"""Update software volume and forward to worker if enabled."""
if self._use_software_volume:
Expand Down Expand Up @@ -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
Expand All @@ -183,6 +198,10 @@ def _run(
player.clear()
continue

if item_type is _DelayChangeWorkItem:
player.apply_delay_change(cast(_DelayChangeWorkItem, item).delta_us)
continue

Comment thread
maximmaxim345 marked this conversation as resolved.
if item_type is _SetVolumeWorkItem:
if self._use_software_volume:
volume_item = cast(_SetVolumeWorkItem, item)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions sendspin/daemon/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down
26 changes: 19 additions & 7 deletions sendspin/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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:
Expand Down Expand Up @@ -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())
Expand All @@ -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:
Comment thread
maximmaxim345 marked this conversation as resolved.
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")
Expand All @@ -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
Expand Down Expand Up @@ -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())
Expand All @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions sendspin/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion sendspin/tui/keyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
6 changes: 3 additions & 3 deletions sendspin/visualizer_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Expand Down