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.
Background
CamillaDSP provides a built-in
Loudnessfilter type that applies volume-compensated equal-loudness correction per the Fletcher-Munson / ISO 226 curves. The filter takesreference_level(dB),high_boost(dB), andlow_boost(dB). CamillaDSP scales the boost linearly: full boost atreference_level - 20dB, no boost abovereference_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
Loudnessfilter is volume-aware — it scales boost based on the difference between current CamillaDSP volume andreference_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):
Changes
audera/models/dsp.pyAdd fields:
Both fields are persisted (not
exclude=True) — they are user configuration, not runtime state.Add module-level constants and utilities:
audera/dal/dsp.pyNo structural changes —
get_or_createandupdatealready handle the new fields transparently onceDSPConfigis updated.audera/ui/streamer/pages.pyAdd 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:
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. Fixtureaudera_homemonkeypatchesdal.dsp.PATH. Test cases:test_create_and_get: create aDSPConfig, assert it round-trips viaget.test_get_or_create_existing: existing config is not overwritten.test_update_loudness_fields:update()persistsloudness_enabledandloudness_reference_level.tests/models/test_dsp.py(new or extend existing)test_apply_loudness_inserts_filter: assert'audera_loudness'inpipeline['filters']afterapply_loudness.test_apply_loudness_sets_reference_level: assertreference_levelmatches20 * log10(85/100)at default.test_remove_loudness_cleans_filter_and_steps: assert neither filter key nor pipeline steps remain afterremove_loudness.test_apply_then_remove_is_idempotent: pipeline is unchanged afterapply_loudnessthenremove_loudness.test_apply_loudness_does_not_touch_user_filters: user-defined filter keys surviveapply_loudnessandremove_loudness.tests/ui/test_streamer.pyAdd fixtures:
Test cases:
test_loudness_toggle_on: toggling the switch to on callsset_configwithaudera_loudnessinfilters.test_loudness_toggle_off: toggling to off callsset_configwithaudera_loudnessremoved.test_loudness_reference_change_when_disabled: changing reference level when loudness is off does not callset_config.