Skip to content

feat: Snapcast per-client latency offset #36

Description

@thomaseleff

What

Add per-client latency offsets using Snapcast's Client.SetLatency RPC. Nodes with different DAC hardware have different inherent output latencies — without per-client compensation, multi-room sync is off by a hardware-dependent constant. Snapcast already supports this; Audera just needs to expose it.

Three touch-points: the Player model, SnapserverClient, and the streamer UI Players tab.

Why

Different DAC hardware (USB, HDMI, I2S) introduces different amounts of output buffering and analog filter delay. Snapcast's latency offset is the standard mechanism for correcting this. ADR 003 documents the broader audio latency context.

Architecture Decisions

Snapcast owns latency state — Audera does not persist it.

Snapcast stores client configuration (including latency) on the server. get_clients() reads it back on every poll. Persisting latency_ms in ~/.audera/players/ would create two sources of truth and a synchronization problem. Instead, latency_ms is a runtime field on Player: parsed from the Snapcast response, excluded from to_dict() (like name), but included in __eq__ so the UI detects latency changes during polling.

No new helper method for the UI control.

The latency input follows the exact same callback pattern as the existing volume slider. Introduce an inline callback in _build_players_tab(), not a new _build_latency_control() method. One new abstraction is one more thing to maintain.

No ADR. This is a thin wrapper over an existing Snapcast RPC with a clear, obvious ownership model. ADR 003 already covers multi-room latency context.

Implementation Tasks

All code snippets are reference only. Read the existing implementation before writing anything.

  • audera/models/player.py — Add latency_ms: int (default 0) to Player. Exclude from to_dict() (alongside name). Keep in __eq__ so polling detects state changes. Update from_dict() / from_config() to parse it from the Snapcast response (not from persisted JSON).

    Reference path in Snapcast response: client['config']['latency']

  • audera/clients/snapserver.py — Add set_client_latency(client_id: str, latency_ms: int) -> dict following the pattern of set_client_volume(). Update get_clients() to populate latency_ms when constructing each Player.

    Reference RPC call:

    self._call('Client.SetLatency', {'id': client_id, 'latency': latency_ms})
  • audera/ui/streamer/pages.py — Add a latency number input (min=-500, max=500, step=1) to each player card in _build_players_tab(), alongside the existing volume/mute controls. Wire its on_change callback inline to set_client_latency(), matching the volume callback pattern.

  • tests/clients/test_snapserver.py — Add a test for set_client_latency() following the existing test_set_client_volume pattern. Requires Docker (snapserver_container fixture).

  • tests/ui/streamer/ — Add a UI integration test asserting the latency input renders in the Players tab with the correct initial value, following the existing volume/mute control test pattern.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions