Skip to content

feat: CamillaDSP native Loudness filter #38

Description

@thomaseleff

Background

CamillaDSP provides a built-in Loudness filter type that applies volume-compensated equal-loudness correction per the Fletcher-Munson / ISO 226 curves. The filter takes reference_level (dB), high_boost (dB), and low_boost (dB). CamillaDSP scales the boost linearly: full boost at reference_level - 20 dB, no boost above reference_level. This is the correct implementation — do not replace it with manually-defined IIR biquads.

Prerequisite: #37 (WS2 — Route volume through CamillaDSP) must land first. The Loudness filter is volume-aware — it scales boost based on the difference between current CamillaDSP volume and reference_level. If Snapcast still owns volume, the filter has no effect.

Estimated size: ~700 LOC (model, DAL, UI, utilities, tests)

Fixed constants (Yamaha YPAO Volume approximation):

_LOUDNESS_HIGH_BOOST: float = 7.0  # dB
_LOUDNESS_LOW_BOOST: float = 7.0   # dB
_LOUDNESS_FILTER_KEY: str = 'audera_loudness'

Changes

audera/models/dsp.py

Add fields:

loudness_enabled: bool = False
loudness_reference_level: int = 85  # percent; converted to dB on apply

Both fields are persisted (not exclude=True) — they are user configuration, not runtime state.

Add module-level constants and utilities:

import math

_LOUDNESS_HIGH_BOOST: float = 7.0
_LOUDNESS_LOW_BOOST: float = 7.0
_LOUDNESS_FILTER_KEY: str = 'audera_loudness'

def apply_loudness(pipeline: dict, reference_level_percent: int) -> dict:
    """Insert the audera_loudness filter into pipeline if not already present."""
    reference_level_db = 20.0 * math.log10(reference_level_percent / 100.0)
    pipeline = dict(pipeline)
    filters = dict(pipeline.get('filters', {}))
    steps = list(pipeline.get('pipeline', []))

    filters[_LOUDNESS_FILTER_KEY] = {
        'type': 'Loudness',
        'parameters': {
            'reference_level': reference_level_db,
            'high_boost': _LOUDNESS_HIGH_BOOST,
            'low_boost': _LOUDNESS_LOW_BOOST,
        },
    }

    # Add a pipeline step per channel if not already present
    existing_names = {name for step in steps for name in step.get('names', [])}
    if _LOUDNESS_FILTER_KEY not in existing_names:
        for channel in range(2):
            steps.append({'type': 'Filter', 'channel': channel, 'names': [_LOUDNESS_FILTER_KEY]})

    pipeline['filters'] = filters
    pipeline['pipeline'] = steps
    return pipeline


def remove_loudness(pipeline: dict) -> dict:
    """Remove the audera_loudness filter and its pipeline steps."""
    pipeline = dict(pipeline)
    filters = {k: v for k, v in pipeline.get('filters', {}).items() if k != _LOUDNESS_FILTER_KEY}
    steps = [
        step for step in pipeline.get('pipeline', [])
        if _LOUDNESS_FILTER_KEY not in step.get('names', [])
    ]
    pipeline['filters'] = filters
    pipeline['pipeline'] = steps
    return pipeline

audera/dal/dsp.py

No structural changes — get_or_create and update already handle the new fields transparently once DSPConfig is updated.

audera/ui/streamer/pages.py

Add a loudness section to the players tab. On toggle, read the current pipeline from CamillaDSP, apply or remove the filter, and push the updated config back:

def _build_loudness_control(self, client: Player, dsp_config: DSPConfig) -> None:
    async def _on_loudness_toggle(e) -> None:
        cdsp = _camilladsp(client.host)
        pipeline = await cdsp.get_config()
        if e.value:
            pipeline = apply_loudness(pipeline, dsp_config.loudness_reference_level)
        else:
            pipeline = remove_loudness(pipeline)
        await cdsp.set_config(pipeline)
        updated = dsp_config.model_copy(update={'loudness_enabled': e.value})
        await dal.dsp.update(updated)

    async def _on_reference_change(e) -> None:
        if not dsp_config.loudness_enabled:
            return
        cdsp = _camilladsp(client.host)
        pipeline = await cdsp.get_config()
        pipeline = remove_loudness(pipeline)
        pipeline = apply_loudness(pipeline, int(e.value))
        await cdsp.set_config(pipeline)
        updated = dsp_config.model_copy(update={'loudness_reference_level': int(e.value)})
        await dal.dsp.update(updated)

    ui.switch('Loudness', value=dsp_config.loudness_enabled, on_change=_on_loudness_toggle)
    ui.number(
        label='Reference level (%)',
        value=dsp_config.loudness_reference_level,
        min=1,
        max=100,
        step=1,
        on_change=_on_reference_change,
    )

The players tab refresh calls dal.dsp.get_or_create(DSPConfig(id=..., player_id=client.id)) to load the persisted config before rendering the loudness controls. This ensures safe provisioning on first render without a separate migration step.

Tests

tests/dal/test_dsp.py (new file)

Follow the pattern in tests/dal/test_players.py. Fixture audera_home monkeypatches dal.dsp.PATH. Test cases:

  • test_create_and_get: create a DSPConfig, assert it round-trips via get.
  • test_get_or_create_existing: existing config is not overwritten.
  • test_update_loudness_fields: update() persists loudness_enabled and loudness_reference_level.

tests/models/test_dsp.py (new or extend existing)

  • test_apply_loudness_inserts_filter: assert 'audera_loudness' in pipeline['filters'] after apply_loudness.
  • test_apply_loudness_sets_reference_level: assert reference_level matches 20 * log10(85/100) at default.
  • test_remove_loudness_cleans_filter_and_steps: assert neither filter key nor pipeline steps remain after remove_loudness.
  • test_apply_then_remove_is_idempotent: pipeline is unchanged after apply_loudness then remove_loudness.
  • test_apply_loudness_does_not_touch_user_filters: user-defined filter keys survive apply_loudness and remove_loudness.

tests/ui/test_streamer.py

Add fixtures:

@pytest.fixture
def mock_camilladsp_config(monkeypatch):
    state = {'config': {'filters': {}, 'pipeline': []}}
    async def _get_config(self): return state['config']
    async def _set_config(self, config): state['config'] = config
    monkeypatch.setattr(CamillaDSPClient, 'get_config', _get_config)
    monkeypatch.setattr(CamillaDSPClient, 'set_config', _set_config)
    return state

@pytest.fixture
def mock_dsp_dal(monkeypatch):
    store = {}
    async def _get_or_create(config): store[config.id] = config; return config
    async def _update(config): store[config.id] = config; return config
    monkeypatch.setattr(dal.dsp, 'get_or_create', _get_or_create)
    monkeypatch.setattr(dal.dsp, 'update', _update)
    return store

Test cases:

  • test_loudness_toggle_on: toggling the switch to on calls set_config with audera_loudness in filters.
  • test_loudness_toggle_off: toggling to off calls set_config with audera_loudness removed.
  • test_loudness_reference_change_when_disabled: changing reference level when loudness is off does not call set_config.

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