Skip to content

feat: Route volume through CamillaDSP #37

Description

@thomaseleff

Background

CamillaDSP's set_volume(level: float) takes a dB value. Snapcast's set_client_volume takes a percent integer. Currently the UI calls Snapcast for volume changes. This workstream re-wires volume changes to CamillaDSP and makes Snapcast volume read-only (kept at 100% / unmuted as a pass-through).

The dB conversion is 20 * log10(percent / 100). At 0%, the result is undefined — use Snapcast mute instead.

Must land before #38 (WS3 — CamillaDSP native Loudness filter). The Loudness filter is volume-aware and only functions correctly when CamillaDSP owns the volume signal.

Estimated size: ~600 LOC (UI, client, helpers, tests)

Changes

audera/ui/streamer/pages.py

Add helpers:

import math

def _camilladsp(host: str) -> CamillaDSPClient:
    return CamillaDSPClient(host=host, port=1234)

def _percent_to_db(percent: int) -> float:
    return 20.0 * math.log10(percent / 100.0)

Replace _build_volume_controls to call CamillaDSP for volume and Snapcast only for mute at 0%:

def _build_volume_controls(self, client: Player) -> None:
    async def _on_volume_change(e) -> None:
        percent = int(e.value)
        if percent == 0:
            await _snapserver(self._host).set_client_volume(client.id, 0, muted=True)
        else:
            await _snapserver(self._host).set_client_volume(client.id, 100, muted=False)
            await _camilladsp(client.host).set_volume(_percent_to_db(percent))

    ui.slider(min=0, max=100, value=client.volume, on_change=_on_volume_change)

The Snapcast volume is kept at 100% (unmuted) at all non-zero values so that CamillaDSP controls the actual gain. At 0%, Snapcast is muted to suppress audio while CamillaDSP volume may remain at any value.

Remove the existing _snapserver.set_client_volume call for non-mute paths. No Snapcast volume slider is shown — Snapcast volume is an implementation detail.

audera/clients/camilladsp.py

No changes — get_volume() and set_volume() already exist.

Tests

tests/ui/test_streamer.py

Add fixtures:

@pytest.fixture
def mock_camilladsp(monkeypatch):
    calls = {}
    async def _set_volume(self, level: float) -> None:
        calls['set_volume'] = level
    monkeypatch.setattr(CamillaDSPClient, 'set_volume', _set_volume)
    return calls

@pytest.fixture
def mock_snapserver_volume(monkeypatch):
    calls = {}
    async def _set_client_volume(self, client_id, volume, muted=False):
        calls['set_client_volume'] = (client_id, volume, muted)
    monkeypatch.setattr(SnapserverClient, 'set_client_volume', _set_client_volume)
    return calls

Add test cases:

  • test_volume_change_nonzero: slider at 50 → set_volume called with ≈ -6.02 dB; Snapcast called with (id, 100, False).
  • test_volume_change_zero: slider at 0 → Snapcast called with (id, 0, True); CamillaDSP set_volume not called.

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