From ee0694db5dd9293a71f9e389e85f86edab07ff33 Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Tue, 7 Jul 2026 20:39:25 -0500 Subject: [PATCH 01/18] feat: reshape DSP to parametric bands; daemon-owned volume (#48) Workstream 1 of #48 (Advanced DSP Configuration): make EQ bands the source of truth and move volume ownership to the CamillaDSP daemon. - models/dsp: add Band; reshape DSPConfig to {id, preamp_db, bands, enabled}; delete the dynamic-loudness machinery - models/player: add dsp_id FK with None->'' coercion for the mixed old/new read_json_auto window - dal/dsp: re-key by config id; add resolve_for_player (get-or-mint-and-link) - clients/camilladsp: add DEFAULT_PERCENT_VOLUME = 25 - ui/streamer: de-wire loudness; seed volume from the daemon, drop the app-side replica and push-on-render - os/dietpi: seed a fresh statefile at --gain -12.04 (= percent_to_db(25)) Co-Authored-By: Claude Opus 4.8 --- audera/clients/camilladsp.py | 1 + audera/dal/dsp.py | 63 +++++++---- audera/models/dsp.py | 140 +++++++++++------------ audera/models/player.py | 13 ++- audera/ui/streamer/pages.py | 62 ++--------- os/dietpi/lib/common.sh | 2 +- tests/clients/test_camilladsp.py | 10 ++ tests/dal/test_dsp.py | 121 +++++++++----------- tests/dal/test_players.py | 49 ++++++++ tests/models/test_dsp.py | 185 +++++++++++-------------------- tests/models/test_player.py | 28 +++++ tests/ui/test_streamer.py | 133 ++++++---------------- 12 files changed, 364 insertions(+), 443 deletions(-) create mode 100644 tests/models/test_player.py diff --git a/audera/clients/camilladsp.py b/audera/clients/camilladsp.py index e6c8ade..8a65999 100644 --- a/audera/clients/camilladsp.py +++ b/audera/clients/camilladsp.py @@ -29,6 +29,7 @@ def __init__(self, host: str, port: int = audera.CAMILLADSP_PORT): MIN_DB: float = -50.0 MAX_DB: float = 0.0 + DEFAULT_PERCENT_VOLUME: int = 25 def _call(self, command: str, value=None) -> dict: """Sends a command to CamillaDSP and returns the response. diff --git a/audera/dal/dsp.py b/audera/dal/dsp.py index f1720fa..4d75f2f 100644 --- a/audera/dal/dsp.py +++ b/audera/dal/dsp.py @@ -2,23 +2,24 @@ import json import os +import uuid from typing import Union -from audera.dal import path -from audera.models import dsp +from audera.dal import path, players +from audera.models import dsp, player PATH: Union[str, os.PathLike] = os.path.join(path.HOME, 'dsp') -def exists(player_id: str) -> bool: +def exists(id: str) -> bool: """Returns `True` when the DSP configuration file exists. Parameters ---------- - player_id: `str` - The player identifier. + id: `str` + The DSP configuration identifier. """ - return os.path.isfile(os.path.abspath(os.path.join(PATH, '.'.join([player_id, 'json'])))) + return os.path.isfile(os.path.abspath(os.path.join(PATH, '.'.join([id, 'json'])))) def create(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: @@ -32,15 +33,15 @@ def create(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: return save(dsp_config) -def get(player_id: str) -> dsp.DSPConfig: +def get(id: str) -> dsp.DSPConfig: """Returns the DSP configuration as an `audera.models.dsp.DSPConfig` object. Parameters ---------- - player_id: `str` - The player identifier. + id: `str` + The DSP configuration identifier. """ - file_path = os.path.join(PATH, '.'.join([player_id, 'json'])) + file_path = os.path.join(PATH, '.'.join([id, 'json'])) with open(file_path, 'r') as f: data = json.load(f) return dsp.DSPConfig.from_dict(data['dsp']) @@ -54,14 +55,34 @@ def get_or_create(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: dsp_config: `audera.models.dsp.DSPConfig` An instance of an `audera.models.dsp.DSPConfig` object. """ - if exists(dsp_config.player_id): - return get(dsp_config.player_id) + if exists(dsp_config.id): + return get(dsp_config.id) else: return create(dsp_config) +def resolve_for_player(player_: player.Player) -> dsp.DSPConfig: + """Returns the player's editable `DSPConfig`, minting and linking one if unassigned. + + If `player_.dsp_id` is set, the referenced config is returned. Otherwise a fresh, + empty `DSPConfig` is minted, saved, and linked to the player (via `players.update` + persisting the `dsp_id`). This is a one-time create-and-link — it reads no legacy + file. + + Parameters + ---------- + player_: `audera.models.player.Player` + An instance of an `audera.models.player.Player` object. + """ + if player_.dsp_id: + return get(player_.dsp_id) + dsp_config = save(dsp.DSPConfig(id=uuid.uuid4().hex)) + players.update(player_.model_copy(update={'dsp_id': dsp_config.id})) + return dsp_config + + def save(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: - """Saves the DSP configuration to `~/.audera/dsp/{player_id}.json`. + """Saves the DSP configuration to `~/.audera/dsp/{id}.json`. Parameters ---------- @@ -70,14 +91,14 @@ def save(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: """ if not os.path.isdir(PATH): os.makedirs(PATH) - file_path = os.path.join(PATH, '.'.join([dsp_config.player_id, 'json'])) + file_path = os.path.join(PATH, '.'.join([dsp_config.id, 'json'])) with open(file_path, 'w') as f: json.dump({'dsp': dsp_config.to_dict()}, f, indent=2) return dsp_config def update(new: dsp.DSPConfig) -> dsp.DSPConfig: - """Updates the DSP configuration file `~/.audera/dsp/{player_id}.json`. + """Updates the DSP configuration file `~/.audera/dsp/{id}.json`. Parameters ---------- @@ -91,13 +112,13 @@ def update(new: dsp.DSPConfig) -> dsp.DSPConfig: return existing -def delete(player_id: str): - """Deletes the DSP configuration file for a player. +def delete(id: str): + """Deletes the DSP configuration file. Parameters ---------- - player_id: `str` - The player identifier. + id: `str` + The DSP configuration identifier. """ - if exists(player_id): - os.remove(os.path.join(PATH, '.'.join([player_id, 'json']))) + if exists(id): + os.remove(os.path.join(PATH, '.'.join([id, 'json']))) diff --git a/audera/models/dsp.py b/audera/models/dsp.py index db3fc33..7fcecf6 100644 --- a/audera/models/dsp.py +++ b/audera/models/dsp.py @@ -3,43 +3,80 @@ from __future__ import annotations import json +from typing import Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field -_LOUDNESS_LOW_BOOST: float = 10.0 -_LOUDNESS_HIGH_BOOST: float = 6.0 -_LOUDNESS_FILTER_KEY: str = 'audera_loudness' -_PREAMP_FILTER_KEY: str = 'audera_preamp_attenuation' -_PREAMP_ATTENUATION_DB: float = -_LOUDNESS_LOW_BOOST - -class DSPConfig(BaseModel): - """A `class` that represents a CamillaDSP pipeline configuration. +class Band(BaseModel): + """A `class` that represents a single parametric-EQ band. Attributes ---------- id: `str` - The DSP configuration identifier. - player_id: `str` - The identifier of the player this configuration belongs to. - pipeline: `dict` - The CamillaDSP pipeline configuration as a dictionary. + The band identifier (stable key for UI rows). + type: `Literal['Peaking', 'LowShelf', 'HighShelf', 'Lowpass', 'Highpass']` + The biquad filter type. + freq: `float` + The center/corner frequency in Hz. + gain: `float` + The gain in dB (ignored for `Lowpass`/`Highpass`). + q: `float` + The filter Q (width). enabled: `bool` - Whether the DSP pipeline is active. + Whether the band is active. """ id: str - player_id: str - pipeline: dict = Field(default_factory=dict) + type: Literal['Peaking', 'LowShelf', 'HighShelf', 'Lowpass', 'Highpass'] = 'Peaking' + freq: float + gain: float = 0.0 + q: float = 0.707 enabled: bool = True - loudness_enabled: bool = False - loudness_reference_level: float = -25.0 - volume: float = 25.0 - @field_validator('loudness_reference_level', mode='before') @classmethod - def _clamp_reference_level(cls, v: float) -> float: - return max(-60.0, min(0.0, float(v))) + def from_dict(cls, dict_object: dict) -> 'Band': + """Returns a `Band` object from a `dict`.""" + return cls.model_validate(dict_object) + + def to_dict(self) -> dict: + """Returns a `Band` object as a `dict`.""" + return { + 'id': self.id, + 'type': self.type, + 'freq': self.freq, + 'gain': self.gain, + 'q': self.q, + 'enabled': self.enabled, + } + + def __repr__(self) -> str: + """Returns a `Band` object as a json-formatted `str`.""" + return json.dumps(self.to_dict(), indent=2) + + +class DSPConfig(BaseModel): + """A `class` that represents a parametric-EQ configuration. + + Bands are the source of truth; the CamillaDSP pipeline is a derived artifact + compiled from `preamp_db` + `bands` on Save (see `audera/domains/dsp/`). + + Attributes + ---------- + id: `str` + The DSP configuration identifier and file key. + preamp_db: `float` + The pre-amp Gain filter attenuation in dB. + bands: `list[Band]` + The parametric-EQ bands (source of truth). + enabled: `bool` + Whether the DSP configuration is active. + """ + + id: str + preamp_db: float = 0.0 + bands: list[Band] = Field(default_factory=list) + enabled: bool = True @classmethod def from_dict(cls, dict_object: dict) -> 'DSPConfig': @@ -50,64 +87,11 @@ def to_dict(self) -> dict: """Returns a `DSPConfig` object as a `dict`.""" return { 'id': self.id, - 'player_id': self.player_id, - 'pipeline': self.pipeline, + 'preamp_db': self.preamp_db, + 'bands': [band.to_dict() for band in self.bands], 'enabled': self.enabled, - 'loudness_enabled': self.loudness_enabled, - 'loudness_reference_level': self.loudness_reference_level, - 'volume': self.volume, } def __repr__(self) -> str: """Returns a `DSPConfig` object as a json-formatted `str`.""" return json.dumps(self.to_dict(), indent=2) - - -def apply_loudness(pipeline: dict, reference_level_db: float = -25.0) -> dict: - """Insert the audera_loudness Loudness filter into pipeline, preceded by a preamp - attenuation Gain filter that offsets the Loudness filter's worst-case low_boost so - the two together never exceed 0 dB of headroom. - """ - pipeline = dict(pipeline) - filters = dict(pipeline.get('filters', {})) - steps = list(pipeline.get('pipeline', [])) - existing_names = {name for step in steps for name in step.get('names', [])} - - filters[_PREAMP_FILTER_KEY] = { - 'type': 'Gain', - 'parameters': {'gain': _PREAMP_ATTENUATION_DB}, - } - if _PREAMP_FILTER_KEY not in existing_names: - for channel in range(2): - steps.append({'type': 'Filter', 'channels': [channel], 'names': [_PREAMP_FILTER_KEY]}) - - filters[_LOUDNESS_FILTER_KEY] = { - 'type': 'Loudness', - 'parameters': { - 'reference_level': reference_level_db, - 'high_boost': _LOUDNESS_HIGH_BOOST, - 'low_boost': _LOUDNESS_LOW_BOOST, - 'fader': 'Main', - }, - } - if _LOUDNESS_FILTER_KEY not in existing_names: - for channel in range(2): - steps.append({'type': 'Filter', 'channels': [channel], 'names': [_LOUDNESS_FILTER_KEY]}) - - pipeline['filters'] = filters - pipeline['pipeline'] = steps - return pipeline - - -def remove_loudness(pipeline: dict) -> dict: - """Remove the audera_loudness filter, its preamp attenuation Gain filter, and their pipeline steps.""" - pipeline = dict(pipeline) - pipeline['filters'] = { - k: v for k, v in pipeline.get('filters', {}).items() if k not in (_LOUDNESS_FILTER_KEY, _PREAMP_FILTER_KEY) - } - pipeline['pipeline'] = [ - step - for step in pipeline.get('pipeline', []) - if _LOUDNESS_FILTER_KEY not in step.get('names', []) and _PREAMP_FILTER_KEY not in step.get('names', []) - ] - return pipeline diff --git a/audera/models/player.py b/audera/models/player.py index f19d50c..4c5c2fe 100644 --- a/audera/models/player.py +++ b/audera/models/player.py @@ -5,7 +5,7 @@ import json from typing import List -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class Player(BaseModel): @@ -27,6 +27,8 @@ class Player(BaseModel): Whether the client is muted. group_id: `str` The identifier of the Snapcast group this client belongs to. + dsp_id: `str` + The identifier of the `DSPConfig` this client references; '' = unassigned. name: `str` The display name from Snapcast responses; not persisted. """ @@ -38,9 +40,16 @@ class Player(BaseModel): volume: int = 100 muted: bool = False group_id: str = '' + dsp_id: str = '' name: str = Field(default='', exclude=True) latency_ms: int = Field(default=0, ge=-500, le=500, exclude=True) + @field_validator('dsp_id', mode='before') + @classmethod + def _coerce_dsp_id(cls, v) -> str: + """Coerces a NULL `dsp_id` (from `read_json_auto` on old files) to ''.""" + return '' if v is None else v + @classmethod def from_dict(cls, dict_object: dict) -> 'Player': """Returns a `Player` object from a `dict`.""" @@ -56,6 +65,7 @@ def to_dict(self) -> dict: 'volume': self.volume, 'muted': self.muted, 'group_id': self.group_id, + 'dsp_id': self.dsp_id, } def __repr__(self) -> str: @@ -74,6 +84,7 @@ def __eq__(self, compare) -> bool: and self.muted == compare.muted and self.group_id == compare.group_id and self.latency_ms == compare.latency_ms + and self.dsp_id == compare.dsp_id ) return False diff --git a/audera/ui/streamer/pages.py b/audera/ui/streamer/pages.py index 930b757..506dbc2 100644 --- a/audera/ui/streamer/pages.py +++ b/audera/ui/streamer/pages.py @@ -14,9 +14,7 @@ import audera from audera.clients import CamillaDSPClient, SnapserverClient -from audera.dal import dsp as dsp_dal from audera.dal import settings as settings_dal -from audera.models.dsp import DSPConfig, apply_loudness, remove_loudness from audera.models.settings import Settings from audera.ui import components, features @@ -302,13 +300,11 @@ def _build_players_tab(self) -> None: with ui.row(wrap=False).classes('items-center gap-4 w-full'): host = client.host - dsp_config = dsp_dal.get_or_create(DSPConfig(id=client.id, player_id=client.id)) - init_vol = dsp_config.volume try: - _camilladsp(host).set_percent_volume(init_vol) + init_vol = _camilladsp(host).get_percent_volume() except Exception: - pass - slider = self._build_volume_controls(client.id, dsp_config, init_vol, client.host) + init_vol = CamillaDSPClient.DEFAULT_PERCENT_VOLUME + slider = self._build_volume_controls(client.id, init_vol, client.host) if mute_cb is not None: slider.bind_enabled_from(mute_cb, 'value', backward=lambda v: not v) @@ -332,7 +328,6 @@ async def _on_enabled_change(self, client, enabled: bool) -> None: def _open_settings_dialog(self, client) -> None: """Opens a settings popup for renaming, latency, and Snapcast volume reset.""" self._dialog_open = True - dsp_config = dsp_dal.get_or_create(DSPConfig(id=client.id, player_id=client.id)) with ui.dialog() as dialog, ui.card().classes('w-96'): ui.label('Settings').classes('font-medium text-lg mb-2') @@ -350,31 +345,6 @@ def _open_settings_dialog(self, client) -> None: 'dense' ).classes('bg-gray-800 text-white') - ui.separator().classes('mt-2 mb-2') - ui.label('DSP').classes('text-sm font-medium') - - with ui.row().classes('w-full items-center justify-between mt-1'): - with ui.column().classes('gap-0'): - ui.label('Loudness').classes('text-xs') - _iso226_url = 'https://cdn.standards.iteh.ai/samples/83117/6afa5bd94e0e4f32812c28c3b0a7b8ac/ISO-226-2023.pdf' - ui.html( - f'See international standard ISO 226, ' - f'reference' - ).classes('text-xs text-gray-500') - loudness_switch = ui.switch( - value=dsp_config.loudness_enabled, - on_change=lambda e: ref_input.set_enabled(e.value), - ) - - ref_input = ui.number( - 'Reference level (dB)', - value=dsp_config.loudness_reference_level, - min=-60.0, - max=0.0, - step=0.5, - ).classes('w-full') - ref_input.set_enabled(dsp_config.loudness_enabled) - ui.separator().classes('mt-4 mb-2') with ui.column().classes('text-xs text-gray-500 gap-1'): ui.label(f'ID {client.id}') @@ -396,23 +366,6 @@ async def _on_save(c=client, ni=name_input, li=latency_input): snap.set_client_latency(c.id, int(li.value)) ui.notify(f'Latency set to {int(li.value)} ms', type='positive', position='top-right') - loudness_on = loudness_switch.value - reference_level = float(ref_input.value) - if loudness_on and not (-60.0 <= reference_level <= 0.0): - ui.notify('Reference level must be between -60 and 0 dB.', type='negative', position='top-right') - return - if loudness_on != dsp_config.loudness_enabled or reference_level != dsp_config.loudness_reference_level: - cdsp = _camilladsp(c.host) - pipeline = await asyncio.to_thread(cdsp.get_config) - if loudness_on: - pipeline = apply_loudness(remove_loudness(pipeline), reference_level) - else: - pipeline = remove_loudness(pipeline) - await asyncio.to_thread(cdsp.set_config, pipeline) - dsp_config.loudness_enabled = loudness_on - dsp_config.loudness_reference_level = reference_level - dsp_dal.update(dsp_config) - self._dialog_open = False dialog.close() self._build_players_tab.refresh() @@ -438,17 +391,19 @@ def _reset_snap_volume(self, client, vol_label=None) -> None: def _build_volume_controls( self, client_id: str, - dsp_config: DSPConfig, initial_volume: float, client_host: str = '', ) -> ui.slider: """Renders a volume icon, slider, and live value label (routed through CamillaDSP). + Volume is owned by the CamillaDSP daemon, which persists it durably via its + `--statefile`; the slider seeds from the daemon (`get_percent_volume`) and writes + through it (`set_percent_volume`) — the app keeps no replica. + The slider is **always** a percent (0-100) control regardless of the 'volume' feature selection, so the handle sits at the same physical spot when toggling between modes. The selection only changes the value label: percent mode shows - `NN%`, dB mode shows `percent_to_db(value)` as `-N.N dB`. `DSPConfig.volume` - (percent) stays the single persisted, canonical value, and both modes drive + `NN%`, dB mode shows `percent_to_db(value)` as `-N.N dB`. Both modes drive CamillaDSP through `set_percent_volume`. Mute is anchored at the slider floor (`percent <= 0`, displayed as `MIN_DB` in dB @@ -464,7 +419,6 @@ def _build_volume_controls( FF_VOLUME_PERC_OR_DB = features.flag_enabled(self.settings, features.VOLUME_KEY, features.FF_VOLUME_PERC_OR_DB) async def _persist_and_sync(percent: float) -> None: - dsp_dal.update(dsp_config.model_copy(update={'volume': percent})) muted = percent <= 0 await asyncio.to_thread( _snapserver(self.settings).set_client_volume, diff --git a/os/dietpi/lib/common.sh b/os/dietpi/lib/common.sh index 025ca99..1a8077c 100644 --- a/os/dietpi/lib/common.sh +++ b/os/dietpi/lib/common.sh @@ -61,7 +61,7 @@ After=sound.target snapclient.service StartLimitIntervalSec=0 [Service] -ExecStart=/usr/local/bin/camilladsp $config_path --statefile $statefile_path -p 1234 --address 0.0.0.0 +ExecStart=/usr/local/bin/camilladsp $config_path --statefile $statefile_path --gain -12.04 -p 1234 --address 0.0.0.0 Restart=always RestartSec=5 diff --git a/tests/clients/test_camilladsp.py b/tests/clients/test_camilladsp.py index c72fe57..37561cd 100644 --- a/tests/clients/test_camilladsp.py +++ b/tests/clients/test_camilladsp.py @@ -46,6 +46,16 @@ def test_percent_to_db(): assert c.percent_to_db(0) == -50.0 +def test_default_percent_volume_matches_shell_gain_literal(): + """Guards the Python constant against the hard-coded `--gain -12.04` shell literal. + + `os/dietpi/lib/common.sh:write_camilladsp_service` seeds a fresh statefile with + `--gain -12.04` (= percent_to_db(25)); the two must never silently drift. + """ + c = CamillaDSPClient('localhost', 0) + assert round(c.percent_to_db(CamillaDSPClient.DEFAULT_PERCENT_VOLUME), 2) == -12.04 + + def test_db_to_percent(): c = CamillaDSPClient('localhost', 0) assert c.db_to_percent(0.0) == pytest.approx(100.0) diff --git a/tests/dal/test_dsp.py b/tests/dal/test_dsp.py index 77fcaab..c03ab4b 100644 --- a/tests/dal/test_dsp.py +++ b/tests/dal/test_dsp.py @@ -1,48 +1,33 @@ import audera.dal.dsp as dsp_dal -from audera.models.dsp import DSPConfig - -_COMPLEX_PIPELINE = { - 'filters': { - 'low_pass': {'type': 'Biquad', 'parameters': {'type': 'Lowpass', 'freq': 2000, 'q': 0.707}}, - 'high_pass': {'type': 'Biquad', 'parameters': {'type': 'Highpass', 'freq': 80, 'q': 0.5}}, - }, - 'mixers': { - 'stereo': { - 'channels': {'in': 2, 'out': 2}, - 'mapping': [ - {'dest': 0, 'sources': [{'channel': 0, 'gain': 0, 'inverted': False}]}, - {'dest': 1, 'sources': [{'channel': 1, 'gain': 0, 'inverted': False}]}, - ], - } - }, - 'pipeline': [ - {'type': 'Mixer', 'name': 'stereo'}, - {'type': 'Filter', 'channels': [0], 'names': ['low_pass']}, - {'type': 'Filter', 'channels': [1], 'names': ['high_pass']}, - ], -} - - -def _make_dsp(player_id='player1') -> DSPConfig: +import audera.dal.players as players_dal +from audera.models.dsp import Band, DSPConfig +from audera.models.player import Player + + +def _make_dsp(id='dsp-1') -> DSPConfig: return DSPConfig( - id='dsp-1', - player_id=player_id, - pipeline={'filters': {}, 'mixers': {}, 'pipeline': []}, + id=id, + preamp_db=-6.0, + bands=[Band(id='b1', type='LowShelf', freq=90.0, gain=10.0)], enabled=True, ) +def _make_player(id='abc123', dsp_id='') -> Player: + return Player(id=id, host='192.168.1.50', port=1704, connected=True, dsp_id=dsp_id) + + def test_dsp_create(audera_home): config = _make_dsp() dsp_dal.create(config) - assert dsp_dal.exists(config.player_id) + assert dsp_dal.exists(config.id) def test_dsp_get(audera_home): config = _make_dsp() dsp_dal.create(config) - result = dsp_dal.get(config.player_id) + result = dsp_dal.get(config.id) assert result == config @@ -50,45 +35,43 @@ def test_dsp_update(audera_home): config = _make_dsp() dsp_dal.create(config) - updated = DSPConfig( - id=config.id, - player_id=config.player_id, - pipeline={'filters': {'lp': {}}, 'mixers': {}, 'pipeline': []}, - enabled=False, - ) + updated = config.model_copy(update={'enabled': False}) dsp_dal.update(updated) - result = dsp_dal.get(config.player_id) + result = dsp_dal.get(config.id) assert result.enabled is False - assert 'lp' in result.pipeline.get('filters', {}) def test_dsp_delete(audera_home): config = _make_dsp() dsp_dal.create(config) - dsp_dal.delete(config.player_id) - assert not dsp_dal.exists(config.player_id) + dsp_dal.delete(config.id) + assert not dsp_dal.exists(config.id) -def test_dsp_pipeline_preserved(audera_home): +def test_dsp_bands_round_trip(audera_home): config = DSPConfig( - id='dsp-complex', - player_id='player-complex', - pipeline=_COMPLEX_PIPELINE, - enabled=True, + id='dsp-bands', + preamp_db=-3.0, + bands=[ + Band(id='b1', type='LowShelf', freq=90.0, gain=10.0, q=0.7), + Band(id='b2', type='HighShelf', freq=8000.0, gain=6.0, q=0.7), + Band(id='b3', type='Peaking', freq=1000.0, gain=-3.0, q=2.0, enabled=False), + ], ) dsp_dal.create(config) - result = dsp_dal.get(config.player_id) - assert result.pipeline == _COMPLEX_PIPELINE + result = dsp_dal.get(config.id) + assert result == config + assert [b.type for b in result.bands] == ['LowShelf', 'HighShelf', 'Peaking'] def test_dsp_get_or_create_creates(audera_home): config = _make_dsp() - assert not dsp_dal.exists(config.player_id) + assert not dsp_dal.exists(config.id) dsp_dal.get_or_create(config) - assert dsp_dal.exists(config.player_id) + assert dsp_dal.exists(config.id) def test_dsp_get_or_create_reads(audera_home): @@ -99,27 +82,27 @@ def test_dsp_get_or_create_reads(audera_home): assert result == config -def test_dsp_loudness_fields_default(audera_home): - config = _make_dsp() - dsp_dal.create(config) - result = dsp_dal.get(config.player_id) - assert result.loudness_enabled is False - assert result.loudness_reference_level == -25.0 +def test_resolve_for_player_mints_and_links(audera_home): + player = _make_player(dsp_id='') + players_dal.create(player) + config = dsp_dal.resolve_for_player(player) -def test_dsp_update_loudness_fields(audera_home): - config = _make_dsp() - dsp_dal.create(config) - updated = config.model_copy(update={'loudness_enabled': True}) - dsp_dal.update(updated) - result = dsp_dal.get(config.player_id) - assert result.loudness_enabled is True + # The minted config is saved, keyed by its own id + assert dsp_dal.exists(config.id) + assert config.bands == [] + assert config.preamp_db == 0.0 + # The player is linked (dsp_id persisted) and the returned config re-reads + assert players_dal.get(player.id).dsp_id == config.id + assert dsp_dal.get(config.id) == config -def test_dsp_update_loudness_reference_level(audera_home): - config = _make_dsp() - dsp_dal.create(config) - updated = config.model_copy(update={'loudness_reference_level': -40.0}) - dsp_dal.update(updated) - result = dsp_dal.get(config.player_id) - assert result.loudness_reference_level == -40.0 + +def test_resolve_for_player_returns_existing(audera_home): + existing = _make_dsp(id='dsp-existing') + dsp_dal.create(existing) + player = _make_player(dsp_id='dsp-existing') + players_dal.create(player) + + config = dsp_dal.resolve_for_player(player) + assert config == existing diff --git a/tests/dal/test_players.py b/tests/dal/test_players.py index ffc3fe0..54575fe 100644 --- a/tests/dal/test_players.py +++ b/tests/dal/test_players.py @@ -1,7 +1,17 @@ +import json +import os + import audera.dal.players as players from audera.models.player import Player +def _write_player_file(id: str, data: dict) -> None: + """Writes a hand-crafted player config file directly to the players DAL path.""" + os.makedirs(players.PATH, exist_ok=True) + with open(os.path.join(players.PATH, f'{id}.json'), 'w') as f: + json.dump({'player': data}, f) + + def _make_player(id='abc123', host='192.168.1.50', connected=True) -> Player: return Player( id=id, @@ -95,3 +105,42 @@ def test_get_all_connected_players(audera_home): result = players.get_all_connected_players() assert len(result) == 1 assert result[0].id == 'on' + + +def test_get_all_players_unions_mixed_old_and_new_files(audera_home): + """Guards the DuckDB read_json_auto schema-union path across mixed old/new files. + + An old player file predates the `dsp_id` column; a new one carries it. Reading both + back must not raise, and the old row's `dsp_id` must surface as '' (coerced from the + NULL DuckDB fills in for the missing column). + """ + _write_player_file( + 'old', + { + 'id': 'old', + 'host': '192.168.1.1', + 'port': 1704, + 'connected': True, + 'volume': 80, + 'muted': False, + 'group_id': '', + }, + ) + _write_player_file( + 'new', + { + 'id': 'new', + 'host': '192.168.1.2', + 'port': 1704, + 'connected': True, + 'volume': 80, + 'muted': False, + 'group_id': '', + 'dsp_id': 'dsp-1', + }, + ) + + result = {p.id: p for p in players.get_all_players()} + assert set(result) == {'old', 'new'} + assert result['old'].dsp_id == '' + assert result['new'].dsp_id == 'dsp-1' diff --git a/tests/models/test_dsp.py b/tests/models/test_dsp.py index 38bc324..56e728f 100644 --- a/tests/models/test_dsp.py +++ b/tests/models/test_dsp.py @@ -1,130 +1,77 @@ -import pytest - -from audera.models.dsp import ( - _LOUDNESS_FILTER_KEY, - _LOUDNESS_HIGH_BOOST, - _LOUDNESS_LOW_BOOST, - _PREAMP_ATTENUATION_DB, - _PREAMP_FILTER_KEY, - DSPConfig, - apply_loudness, - remove_loudness, -) - - -@pytest.fixture -def empty_pipeline(): - return {'filters': {}, 'pipeline': []} - - -@pytest.fixture -def user_pipeline(): - return { - 'filters': {'user_filter': {'type': 'Biquad', 'parameters': {}}}, - 'pipeline': [{'type': 'Filter', 'channels': [0], 'names': ['user_filter']}], +from audera.models.dsp import Band, DSPConfig + + +def test_band_defaults(): + band = Band(id='b1', freq=1000.0) + assert band.type == 'Peaking' + assert band.gain == 0.0 + assert band.q == 0.707 + assert band.enabled is True + + +def test_band_to_dict(): + band = Band(id='b1', type='LowShelf', freq=90.0, gain=10.0, q=0.7, enabled=False) + assert band.to_dict() == { + 'id': 'b1', + 'type': 'LowShelf', + 'freq': 90.0, + 'gain': 10.0, + 'q': 0.7, + 'enabled': False, } -@pytest.fixture -def applied_pipeline(empty_pipeline): - return apply_loudness(empty_pipeline) +def test_band_from_dict_round_trip(): + band = Band(id='b1', type='HighShelf', freq=8000.0, gain=6.0, q=0.7) + assert Band.from_dict(band.to_dict()) == band -def test_apply_loudness_inserts_filter(applied_pipeline): - assert _LOUDNESS_FILTER_KEY in applied_pipeline['filters'] +def test_dsp_config_defaults(): + config = DSPConfig(id='x') + assert config.preamp_db == 0.0 + assert config.bands == [] + assert config.enabled is True -def test_apply_loudness_fader_param(applied_pipeline): - params = applied_pipeline['filters'][_LOUDNESS_FILTER_KEY]['parameters'] - assert params['fader'] == 'Main' - assert params['reference_level'] == -25.0 +def test_dsp_config_to_dict_keys(): + config = DSPConfig(id='x') + assert set(config.to_dict().keys()) == {'id', 'preamp_db', 'bands', 'enabled'} -def test_apply_loudness_custom_reference_level(empty_pipeline): - result = apply_loudness(empty_pipeline, -40.0) - params = result['filters'][_LOUDNESS_FILTER_KEY]['parameters'] - assert params['reference_level'] == -40.0 +def test_dsp_config_bands_round_trip(): + config = DSPConfig( + id='x', + preamp_db=-6.0, + bands=[ + Band(id='b1', type='LowShelf', freq=90.0, gain=10.0), + Band(id='b2', type='HighShelf', freq=8000.0, gain=6.0), + ], + ) + result = DSPConfig.from_dict(config.to_dict()) + assert result == config + assert result.bands[0].type == 'LowShelf' + assert result.bands[1].freq == 8000.0 -def test_apply_loudness_boost_values(applied_pipeline): - params = applied_pipeline['filters'][_LOUDNESS_FILTER_KEY]['parameters'] - assert params['low_boost'] == _LOUDNESS_LOW_BOOST - assert params['high_boost'] == _LOUDNESS_HIGH_BOOST - - -def test_apply_loudness_adds_pipeline_steps(applied_pipeline): - loudness_steps = [s for s in applied_pipeline['pipeline'] if _LOUDNESS_FILTER_KEY in s.get('names', [])] - assert len(loudness_steps) == 2 - - -def test_apply_loudness_inserts_preamp_filter(applied_pipeline): - assert _PREAMP_FILTER_KEY in applied_pipeline['filters'] - - -def test_apply_loudness_preamp_gain_value(applied_pipeline): - params = applied_pipeline['filters'][_PREAMP_FILTER_KEY]['parameters'] - assert params['gain'] == _PREAMP_ATTENUATION_DB - assert params['gain'] == -_LOUDNESS_LOW_BOOST - - -def test_apply_loudness_adds_preamp_pipeline_steps(applied_pipeline): - preamp_steps = [s for s in applied_pipeline['pipeline'] if _PREAMP_FILTER_KEY in s.get('names', [])] - assert len(preamp_steps) == 2 - - -def test_apply_loudness_orders_preamp_before_loudness(applied_pipeline): - names_in_order = [step['names'][0] for step in applied_pipeline['pipeline']] - assert names_in_order.index(_PREAMP_FILTER_KEY) < names_in_order.index(_LOUDNESS_FILTER_KEY) - - -def test_remove_loudness_cleans_filter_and_steps(applied_pipeline): - result = remove_loudness(applied_pipeline) - assert _LOUDNESS_FILTER_KEY not in result['filters'] - assert all(_LOUDNESS_FILTER_KEY not in s.get('names', []) for s in result['pipeline']) - - -def test_remove_loudness_cleans_preamp_filter_and_steps(applied_pipeline): - result = remove_loudness(applied_pipeline) - assert _PREAMP_FILTER_KEY not in result['filters'] - assert all(_PREAMP_FILTER_KEY not in s.get('names', []) for s in result['pipeline']) - - -def test_apply_then_remove_is_idempotent(empty_pipeline): - result = remove_loudness(apply_loudness(empty_pipeline)) - assert result['filters'] == empty_pipeline['filters'] - assert result['pipeline'] == empty_pipeline['pipeline'] - - -def test_apply_loudness_does_not_touch_user_filters(user_pipeline): - applied = apply_loudness(user_pipeline) - assert 'user_filter' in applied['filters'] - removed = remove_loudness(applied) - assert 'user_filter' in removed['filters'] - assert any('user_filter' in s.get('names', []) for s in removed['pipeline']) - - -def test_apply_loudness_idempotent_steps(empty_pipeline): - once = apply_loudness(empty_pipeline) - twice = apply_loudness(once) - loudness_steps = [s for s in twice['pipeline'] if _LOUDNESS_FILTER_KEY in s.get('names', [])] - assert len(loudness_steps) == 2 - - -def test_apply_loudness_idempotent_preamp_steps(empty_pipeline): - once = apply_loudness(empty_pipeline) - twice = apply_loudness(once) - preamp_steps = [s for s in twice['pipeline'] if _PREAMP_FILTER_KEY in s.get('names', [])] - assert len(preamp_steps) == 2 - - -def test_dsp_config_loudness_defaults(): - config = DSPConfig(id='x', player_id='x') - assert config.loudness_enabled is False - assert config.loudness_reference_level == -25.0 - - -def test_dsp_config_to_dict_includes_loudness_fields(): - config = DSPConfig(id='x', player_id='x', loudness_enabled=True) - d = config.to_dict() - assert d['loudness_enabled'] is True - assert d['loudness_reference_level'] == -25.0 +def test_dsp_config_legacy_dict_drops_retired_keys(): + legacy = { + 'id': 'x', + 'player_id': 'player-1', + 'pipeline': {'filters': {}, 'pipeline': []}, + 'loudness_enabled': True, + 'loudness_reference_level': -30.0, + 'volume': 40.0, + 'enabled': True, + } + config = DSPConfig.from_dict(legacy) + result = config.to_dict() + assert set(result.keys()) == {'id', 'preamp_db', 'bands', 'enabled'} + for retired in ('player_id', 'pipeline', 'loudness_enabled', 'loudness_reference_level', 'volume'): + assert retired not in result + + +def test_dsp_config_id_is_identity(): + a = DSPConfig(id='same', preamp_db=-3.0) + b = DSPConfig(id='same', preamp_db=-3.0) + assert a == b + assert a != DSPConfig(id='other', preamp_db=-3.0) diff --git a/tests/models/test_player.py b/tests/models/test_player.py new file mode 100644 index 0000000..55998e9 --- /dev/null +++ b/tests/models/test_player.py @@ -0,0 +1,28 @@ +from audera.models.player import Player + + +def _make_player(dsp_id: str = '') -> Player: + return Player(id='abc123', host='192.168.1.50', port=1704, dsp_id=dsp_id) + + +def test_dsp_id_defaults_to_empty_string(): + player = _make_player() + assert player.dsp_id == '' + + +def test_dsp_id_round_trips_through_to_dict(): + player = _make_player(dsp_id='dsp-1') + assert player.to_dict()['dsp_id'] == 'dsp-1' + assert Player.from_dict(player.to_dict()) == player + + +def test_dsp_id_participates_in_eq(): + a = _make_player(dsp_id='dsp-1') + b = _make_player(dsp_id='dsp-2') + assert a != b + assert a == _make_player(dsp_id='dsp-1') + + +def test_dsp_id_none_coerces_to_empty_string(): + player = Player.from_dict({'id': 'abc123', 'host': '192.168.1.50', 'port': 1704, 'dsp_id': None}) + assert player.dsp_id == '' diff --git a/tests/ui/test_streamer.py b/tests/ui/test_streamer.py index 37d8dda..cdf5c84 100644 --- a/tests/ui/test_streamer.py +++ b/tests/ui/test_streamer.py @@ -9,7 +9,6 @@ import audera.ui.streamer.pages as streamer_pages from audera.clients import CamillaDSPClient, SnapserverClient -from audera.dal import dsp as dsp_dal from audera.dal import settings as settings_dal from audera.models.player import Player from audera.models.settings import Settings @@ -43,14 +42,19 @@ def mock_snapserver_with_muted_client(monkeypatch): @pytest.fixture def mock_camilladsp(monkeypatch): + # Stateful, like the real daemon: get returns the last-set percent, seeded at 80. + # The players tab reseeds the slider from get_percent_volume on every (re-)render, + # so a static mock would revert a drag the moment the refresh timer fires. calls = {} + state = {'volume': 80} def _set_percent_volume(self, percent: int) -> None: calls['set_percent_volume'] = percent + state['volume'] = percent def _get_percent_volume(self) -> int: calls['get_percent_volume'] = True - return 80 + return state['volume'] def _set_volume(self, level: float) -> None: calls['set_volume'] = level @@ -61,21 +65,6 @@ def _set_volume(self, level: float) -> None: return calls -@pytest.fixture -def mock_camilladsp_config(monkeypatch): - calls = {} - - def _get_config(self): - return {'filters': {}, 'pipeline': []} - - def _set_config(self, config): - calls['set_config'] = config - - monkeypatch.setattr(CamillaDSPClient, 'get_config', _get_config) - monkeypatch.setattr(CamillaDSPClient, 'set_config', _set_config) - return calls - - @pytest.fixture def mock_snapserver_volume(monkeypatch): calls = {} @@ -87,6 +76,18 @@ def _set_client_volume(self, client_id: str, percent: int, muted: bool = False): return calls +@pytest.fixture +def db_volume_mode(audera_home): + """Seeds settings so the volume control renders in dB mode (rather than percent).""" + settings_dal.create( + Settings( + plexamp_host='localhost', + snapserver_host='localhost', + features={features.VOLUME_KEY: features.FF_VOLUME_PERC_OR_DB}, + ) + ) + + async def test_index_renders_tabs(audera_home, mock_snapserver_empty, monkeypatch, user: User): monkeypatch.setattr(streamer_pages, '_plexamp_state', lambda: 'inactive') Page().load() @@ -264,18 +265,18 @@ async def test_run_preamble_does_not_set_script_mode(audera_home, monkeypatch, u ) -async def test_volume_slider_seeded_from_dal( +async def test_volume_slider_seeded_from_daemon( audera_home, mock_snapserver_with_client, mock_camilladsp, - monkeypatch, user: User, ): - monkeypatch.setattr(streamer_pages, '_camilladsp', lambda h: CamillaDSPClient(h)) Page().load() await user.open('/') - # Volume is read from DAL (default 25) and pushed to CamillaDSP via set_percent_volume - assert mock_camilladsp.get('set_percent_volume') == 25 + # Volume seeds from the daemon via get_percent_volume — no app-side replica, no + # push-on-render. + assert mock_camilladsp.get('get_percent_volume') is True + assert mock_camilladsp.get('set_percent_volume') is None async def test_players_tab_volume_percent_mode_shows_icon_and_label( @@ -284,23 +285,16 @@ async def test_players_tab_volume_percent_mode_shows_icon_and_label( Page().load() await user.open('/') await user.should_see(kind=ui.icon, content='volume_up') - await user.should_see('25%') + await user.should_see('80%') # seeded from the daemon's get_percent_volume async def test_players_tab_volume_db_mode_shows_icon_and_label( - audera_home, mock_snapserver_with_client, mock_camilladsp, user: User + audera_home, mock_snapserver_with_client, mock_camilladsp, db_volume_mode, user: User ): - settings_dal.create( - Settings( - plexamp_host='localhost', - snapserver_host='localhost', - features={features.VOLUME_KEY: features.FF_VOLUME_PERC_OR_DB}, - ) - ) Page().load() await user.open('/') await user.should_see(kind=ui.icon, content='volume_up') - await user.should_see('-12.0 dB') # percent_to_db(25) == -12.041... + await user.should_see('-1.9 dB') # percent_to_db(80) == -1.938... async def test_players_tab_volume_percent_slider_change_persists_and_updates_label( @@ -315,19 +309,11 @@ async def test_players_tab_volume_percent_slider_change_persists_and_updates_lab assert mock_camilladsp.get('set_percent_volume') == 60 assert mock_camilladsp.get('set_volume') is None assert mock_snapserver_volume.get('set_client_volume') == ('abc123', 100, False) - assert dsp_dal.get('abc123').volume == 60 async def test_players_tab_volume_db_slider_change_persists_percent_and_shows_db( - audera_home, mock_snapserver_with_client, mock_camilladsp, mock_snapserver_volume, user: User + audera_home, mock_snapserver_with_client, mock_camilladsp, mock_snapserver_volume, db_volume_mode, user: User ): - settings_dal.create( - Settings( - plexamp_host='localhost', - snapserver_host='localhost', - features={features.VOLUME_KEY: features.FF_VOLUME_PERC_OR_DB}, - ) - ) Page().load() await user.open('/') # dB mode uses the same percent (0-100) slider; only the label shows dB. @@ -338,19 +324,11 @@ async def test_players_tab_volume_db_slider_change_persists_percent_and_shows_db assert mock_camilladsp.get('set_percent_volume') == 50 assert mock_camilladsp.get('set_volume') is None assert mock_snapserver_volume.get('set_client_volume') == ('abc123', 100, False) - assert dsp_dal.get('abc123').volume == 50 async def test_players_tab_volume_db_slider_floor_mutes_via_snapcast( - audera_home, mock_snapserver_with_client, mock_camilladsp, mock_snapserver_volume, user: User + audera_home, mock_snapserver_with_client, mock_camilladsp, mock_snapserver_volume, db_volume_mode, user: User ): - settings_dal.create( - Settings( - plexamp_host='localhost', - snapserver_host='localhost', - features={features.VOLUME_KEY: features.FF_VOLUME_PERC_OR_DB}, - ) - ) Page().load() await user.open('/') # Floor is 0% (displayed as MIN_DB in dB mode); dragging there mutes via Snapcast. @@ -358,19 +336,11 @@ async def test_players_tab_volume_db_slider_floor_mutes_via_snapcast( user.find(kind=ui.slider).elements.pop().value = 0 await asyncio.sleep(0.1) assert mock_snapserver_volume.get('set_client_volume') == ('abc123', 0, True) - assert dsp_dal.get('abc123').volume == 0 async def test_players_tab_volume_db_slider_is_percent_scaled( - audera_home, mock_snapserver_with_client, mock_camilladsp, user: User + audera_home, mock_snapserver_with_client, mock_camilladsp, db_volume_mode, user: User ): - settings_dal.create( - Settings( - plexamp_host='localhost', - snapserver_host='localhost', - features={features.VOLUME_KEY: features.FF_VOLUME_PERC_OR_DB}, - ) - ) Page().load() await user.open('/') # dB mode keeps the percent (0-100) scale so the handle position matches percent mode. @@ -401,47 +371,10 @@ async def test_reset_snap_volume_button(audera_home, mock_snapserver_with_client await user.should_see('Snapcast Volume') -async def test_settings_dialog_shows_loudness_controls(audera_home, mock_snapserver_with_client, user: User): +async def test_settings_dialog_no_longer_shows_loudness(audera_home, mock_snapserver_with_client, user: User): Page().load() await user.open('/') user.find(marker='player-settings').click() - await user.should_see('Loudness') - await user.should_see('Reference level (dB)') - - -async def test_loudness_toggle_enables_and_persists( - audera_home, - mock_snapserver_with_client, - mock_camilladsp_config, - user: User, -): - Page().load() - await user.open('/') - user.find(marker='player-settings').click() - user.find(kind=ui.switch).click() - await asyncio.sleep(0.1) - assert mock_camilladsp_config.get('set_config') is None - user.find('Save').click() - await asyncio.sleep(0.1) - assert mock_camilladsp_config.get('set_config') is not None - assert dsp_dal.get(mock_snapserver_with_client.id).loudness_enabled is True - - -async def test_loudness_toggle_passes_reference_level_to_config( - audera_home, - mock_snapserver_with_client, - mock_camilladsp_config, - user: User, -): - Page().load() - await user.open('/') - user.find(marker='player-settings').click() - user.find(kind=ui.switch).click() - await asyncio.sleep(0.1) - assert mock_camilladsp_config.get('set_config') is None - user.find('Save').click() - await asyncio.sleep(0.1) - config = mock_camilladsp_config.get('set_config') - assert config is not None - params = config['filters']['audera_loudness']['parameters'] - assert params['reference_level'] == -25.0 + await user.should_see('Snapcast Volume') + await user.should_not_see('Loudness') + await user.should_not_see('Reference level (dB)') From 1836613c614d9ba0c1e88f3e0c7f06709e9d3804 Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Tue, 7 Jul 2026 21:00:14 -0500 Subject: [PATCH 02/18] =?UTF-8?q?feat:=20add=20DSP=20domain=20package=20?= =?UTF-8?q?=E2=80=94=20compiler,=20headroom,=20presets=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS-2 lands the pure computation layer behind its own import seam (`audera/domains/dsp/`), importing only `models` so it is unit-testable with zero I/O (no disk, daemon, or Docker). - compiler — `compile_pipeline(current_config, config)` compiles `preamp_db + bands` into a CamillaDSP config: deep-copies the base, strips managed `audera_`-prefixed filters/steps, re-adds one preamp Gain + one Biquad per band (own stereo `channels: [0, 1]` step, `bypassed = not band.enabled`). Foreign filters/devices survive; idempotent by construction. - headroom — `response_peak_db(config)` sums each enabled band's `eval_filter` magnitude element-wise over the shared grid + scalar preamp, matching the daemon's math to drive the headroom guard. - presets — `loudness_preset()` mints two editable shelf bands. Adds `camilladsp-plot @ v3.0.2` as a core dep (numpy-free at runtime); hatchling needs `allow-direct-references` for the git ref. Tests: 19 pure, Docker-free tests in `tests/domains/dsp/`. Co-Authored-By: Claude Opus 4.8 --- audera/domains/__init__.py | 0 audera/domains/dsp/__init__.py | 13 +++ audera/domains/dsp/compiler.py | 96 +++++++++++++++++++++ audera/domains/dsp/headroom.py | 47 +++++++++++ audera/domains/dsp/presets.py | 26 ++++++ pyproject.toml | 4 + tests/domains/__init__.py | 0 tests/domains/dsp/__init__.py | 0 tests/domains/dsp/test_compiler.py | 131 +++++++++++++++++++++++++++++ tests/domains/dsp/test_headroom.py | 46 ++++++++++ tests/domains/dsp/test_presets.py | 24 ++++++ uv.lock | 76 ++++++++++++++++- 12 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 audera/domains/__init__.py create mode 100644 audera/domains/dsp/__init__.py create mode 100644 audera/domains/dsp/compiler.py create mode 100644 audera/domains/dsp/headroom.py create mode 100644 audera/domains/dsp/presets.py create mode 100644 tests/domains/__init__.py create mode 100644 tests/domains/dsp/__init__.py create mode 100644 tests/domains/dsp/test_compiler.py create mode 100644 tests/domains/dsp/test_headroom.py create mode 100644 tests/domains/dsp/test_presets.py diff --git a/audera/domains/__init__.py b/audera/domains/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/audera/domains/dsp/__init__.py b/audera/domains/dsp/__init__.py new file mode 100644 index 0000000..7695d0b --- /dev/null +++ b/audera/domains/dsp/__init__.py @@ -0,0 +1,13 @@ +"""Pure DSP computation layer. + +Bands are the source of truth; this package compiles them into a CamillaDSP +pipeline (`compiler`), computes the combined magnitude peak for headroom safety +(`headroom`), and mints editable preset bands (`presets`). It imports only +`audera.models` (never `dal`/`clients`), so it is unit-testable with zero I/O. +""" + +from audera.domains.dsp.compiler import compile_pipeline +from audera.domains.dsp.headroom import response_peak_db +from audera.domains.dsp.presets import loudness_preset + +__all__ = ['compile_pipeline', 'response_peak_db', 'loudness_preset'] diff --git a/audera/domains/dsp/compiler.py b/audera/domains/dsp/compiler.py new file mode 100644 index 0000000..d061241 --- /dev/null +++ b/audera/domains/dsp/compiler.py @@ -0,0 +1,96 @@ +"""Compile `preamp_db` + `bands` into a CamillaDSP pipeline configuration. + +Bands are the source of truth; the CamillaDSP config is a derived artifact. The +compiler strips every previously-managed (`audera_`-prefixed) filter and pipeline +step, then re-adds a pre-amp Gain followed by one Biquad per band — preserving +foreign filters/steps and never mutating the caller's dict. Strip-then-re-add +with stable ordering makes it idempotent by construction. +""" + +import copy + +from audera.models.dsp import Band, DSPConfig + +_MANAGED_PREFIX = 'audera_' +_PREAMP_KEY = 'audera_preamp' +_PEQ_PREFIX = 'audera_peq_' + +# Model literals use camel-cased shelf names; CamillaDSP expects lower-cased. +_TYPE_MAP = { + 'Peaking': 'Peaking', + 'LowShelf': 'Lowshelf', + 'HighShelf': 'Highshelf', + 'Lowpass': 'Lowpass', + 'Highpass': 'Highpass', +} + +# Band types that carry a gain; pass filters (`Lowpass`/`Highpass`) omit it. +_GAIN_TYPES = frozenset({'Peaking', 'LowShelf', 'HighShelf'}) + + +def _band_to_biquad(band: Band) -> dict: + """Returns a CamillaDSP Biquad filter `dict` for a single `Band`. + + Shared by the compiler and the headroom evaluator so that the shape compiled + into the pipeline and the shape evaluated for the magnitude peak can never + drift apart. + + Parameters + ---------- + band: `audera.models.dsp.Band` + An instance of an `audera.models.dsp.Band` object. + """ + parameters = { + 'type': _TYPE_MAP[band.type], + 'freq': band.freq, + 'q': band.q, + } + if band.type in _GAIN_TYPES: + parameters['gain'] = band.gain + return {'type': 'Biquad', 'parameters': parameters} + + +def _is_managed_step(step: dict) -> bool: + """Returns `True` when a pipeline step references any managed filter. + + Parameters + ---------- + step: `dict` + A CamillaDSP pipeline step. + """ + return any(name.startswith(_MANAGED_PREFIX) for name in step.get('names', [])) + + +def compile_pipeline(current_config: dict, config: DSPConfig) -> dict: + """Returns a new CamillaDSP config compiled from `preamp_db` + `bands`. + + The returned `dict` is a deep copy of `current_config` with every managed + (`audera_`-prefixed) filter and pipeline step replaced by a fresh pre-amp Gain + followed by one Biquad per band, in `bands` order. Foreign filters, foreign + pipeline steps, and device/resampler settings are preserved untouched. The + caller's `current_config` is never mutated. + + Parameters + ---------- + current_config: `dict` + The current CamillaDSP pipeline configuration to compile into. + config: `audera.models.dsp.DSPConfig` + An instance of an `audera.models.dsp.DSPConfig` object. + """ + compiled = copy.deepcopy(current_config) + + # Strip previously-managed filters and steps; keep everything foreign. + filters = {name: filter_ for name, filter_ in compiled.get('filters', {}).items() if not name.startswith(_MANAGED_PREFIX)} + pipeline = [step for step in compiled.get('pipeline', []) if not _is_managed_step(step)] + + # Re-add the pre-amp Gain, then one Biquad per band, in a stable order. + filters[_PREAMP_KEY] = {'type': 'Gain', 'parameters': {'gain': config.preamp_db}} + pipeline.append({'type': 'Filter', 'channels': [0, 1], 'names': [_PREAMP_KEY], 'bypassed': False}) + for band in config.bands: + name = _PEQ_PREFIX + band.id + filters[name] = _band_to_biquad(band) + pipeline.append({'type': 'Filter', 'channels': [0, 1], 'names': [name], 'bypassed': not band.enabled}) + + compiled['filters'] = filters + compiled['pipeline'] = pipeline + return compiled diff --git a/audera/domains/dsp/headroom.py b/audera/domains/dsp/headroom.py new file mode 100644 index 0000000..08eea37 --- /dev/null +++ b/audera/domains/dsp/headroom.py @@ -0,0 +1,47 @@ +"""Compute the true combined magnitude peak (dB) of a DSP configuration. + +Drives the "protect headroom" guard: the suggested pre-amp attenuation is +`-response_peak_db`. Each enabled band's magnitude response is evaluated with +CamillaDSP's own `eval_filter` over a shared `logspace` grid (dependent only on +`samplerate`/`npoints`), summed element-wise, and offset by the flat pre-amp +Gain — matching the daemon's math so the guard can't drift from playback. +""" + +from camilladsp_plot import eval_filter + +from audera.domains.dsp.compiler import _band_to_biquad +from audera.models.dsp import DSPConfig + +_SAMPLERATE = 48000 + + +def response_peak_db(config: DSPConfig, samplerate: int = _SAMPLERATE) -> float: + """Returns the combined magnitude peak in dB for a 0 dBFS input. + + Sums, element-wise over the shared frequency grid, the dB magnitude of every + enabled band, then adds the scalar pre-amp Gain. The grid is identical across + bands (it depends only on `samplerate`/`npoints`), so element-wise addition is + valid. With no enabled bands the response is flat 0 dB, so the pre-amp value is + returned directly. + + Parameters + ---------- + config: `audera.models.dsp.DSPConfig` + An instance of an `audera.models.dsp.DSPConfig` object. + samplerate: `int` + The sample rate in Hz for the magnitude evaluation (default 48000, matching + the daemon/container config). + """ + summed: list[float] = [] + for band in config.bands: + if not band.enabled: + continue + magnitude = eval_filter(_band_to_biquad(band), samplerate=samplerate)['magnitude'] + if not summed: + summed = list(magnitude) + else: + summed = [a + b for a, b in zip(summed, magnitude)] + + if not summed: + return config.preamp_db + return max(summed) + config.preamp_db diff --git a/audera/domains/dsp/presets.py b/audera/domains/dsp/presets.py new file mode 100644 index 0000000..608bb8e --- /dev/null +++ b/audera/domains/dsp/presets.py @@ -0,0 +1,26 @@ +"""Editable band presets. + +Loudness is now a static preset — two editable shelf bands — rather than a dynamic +filter. The page (WS-4/5) pairs this with the headroom guard to suggest a +protective pre-amp; this module only mints the bands. +""" + +import uuid + +from audera.models.dsp import Band + +_LOUDNESS_LOW_BOOST = 10.0 +_LOUDNESS_HIGH_BOOST = 6.0 + + +def loudness_preset() -> list[Band]: + """Returns two fresh, editable loudness shelf bands. + + A `LowShelf` at 90 Hz (+10 dB) and a `HighShelf` at 8000 Hz (+6 dB), each with + `q=0.7`, a unique id, and enabled — mirroring the retired dynamic-loudness + boosts. + """ + return [ + Band(id=uuid.uuid4().hex, type='LowShelf', freq=90.0, gain=_LOUDNESS_LOW_BOOST, q=0.7), + Band(id=uuid.uuid4().hex, type='HighShelf', freq=8000.0, gain=_LOUDNESS_HIGH_BOOST, q=0.7), + ] diff --git a/pyproject.toml b/pyproject.toml index 24d9e11..15d2aff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "python-dotenv>=1.2.2", "httpx>=0.28.1", "websockets>=16.0", + "camilladsp-plot @ git+https://github.com/HEnquist/pycamilladsp-plot@v3.0.2", ] [project.optional-dependencies] @@ -43,6 +44,9 @@ Releases = "https://github.com/Eleff-org/audera/releases" [project.scripts] audera = "audera.cli.audera:main" +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.build.targets.wheel] packages = ["audera"] diff --git a/tests/domains/__init__.py b/tests/domains/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/domains/dsp/__init__.py b/tests/domains/dsp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/domains/dsp/test_compiler.py b/tests/domains/dsp/test_compiler.py new file mode 100644 index 0000000..7b4dc54 --- /dev/null +++ b/tests/domains/dsp/test_compiler.py @@ -0,0 +1,131 @@ +from audera.domains.dsp import compile_pipeline +from audera.domains.dsp.compiler import _PEQ_PREFIX, _PREAMP_KEY +from audera.models.dsp import Band, DSPConfig + + +def _empty_config() -> dict: + return {'filters': {}, 'pipeline': []} + + +def _foreign_config() -> dict: + """A base config carrying a foreign filter, its step, and device settings.""" + return { + 'devices': {'samplerate': 48000}, + 'filters': {'user_filter': {'type': 'Gain', 'parameters': {'gain': -2.0}}}, + 'pipeline': [{'type': 'Filter', 'channels': [0, 1], 'names': ['user_filter']}], + } + + +def test_preamp_and_peq_filters_exist(): + config = DSPConfig( + id='x', + preamp_db=-6.0, + bands=[ + Band(id='b1', type='Peaking', freq=1000.0, gain=3.0), + Band(id='b2', type='LowShelf', freq=90.0, gain=10.0), + ], + ) + compiled = compile_pipeline(_empty_config(), config) + assert _PREAMP_KEY in compiled['filters'] + assert _PEQ_PREFIX + 'b1' in compiled['filters'] + assert _PEQ_PREFIX + 'b2' in compiled['filters'] + assert compiled['filters'][_PREAMP_KEY] == {'type': 'Gain', 'parameters': {'gain': -6.0}} + + +def test_each_managed_filter_has_own_stereo_step(): + config = DSPConfig( + id='x', + bands=[ + Band(id='b1', type='Peaking', freq=1000.0, gain=3.0), + Band(id='b2', type='Peaking', freq=2000.0, gain=3.0), + ], + ) + compiled = compile_pipeline(_empty_config(), config) + steps = compiled['pipeline'] + assert len(steps) == 3 # preamp + one per band + for step in steps: + assert step['type'] == 'Filter' + assert step['channels'] == [0, 1] + assert len(step['names']) == 1 + assert steps[0]['names'] == [_PREAMP_KEY] + assert steps[1]['names'] == [_PEQ_PREFIX + 'b1'] + assert steps[2]['names'] == [_PEQ_PREFIX + 'b2'] + + +def test_shelf_casing_is_lowercased(): + config = DSPConfig( + id='x', + bands=[ + Band(id='low', type='LowShelf', freq=90.0, gain=10.0), + Band(id='high', type='HighShelf', freq=8000.0, gain=6.0), + ], + ) + compiled = compile_pipeline(_empty_config(), config) + assert compiled['filters'][_PEQ_PREFIX + 'low']['parameters']['type'] == 'Lowshelf' + assert compiled['filters'][_PEQ_PREFIX + 'high']['parameters']['type'] == 'Highshelf' + + +def test_disabled_band_step_is_bypassed(): + config = DSPConfig( + id='x', + bands=[ + Band(id='on', type='Peaking', freq=1000.0, gain=3.0, enabled=True), + Band(id='off', type='Peaking', freq=2000.0, gain=3.0, enabled=False), + ], + ) + compiled = compile_pipeline(_empty_config(), config) + by_name = {step['names'][0]: step for step in compiled['pipeline']} + assert by_name[_PEQ_PREFIX + 'on']['bypassed'] is False + assert by_name[_PEQ_PREFIX + 'off']['bypassed'] is True + assert by_name[_PREAMP_KEY]['bypassed'] is False + + +def test_pass_filters_omit_gain_shelves_include_it(): + config = DSPConfig( + id='x', + bands=[ + Band(id='lp', type='Lowpass', freq=12000.0, q=0.7), + Band(id='hp', type='Highpass', freq=40.0, q=0.7), + Band(id='pk', type='Peaking', freq=1000.0, gain=3.0), + ], + ) + compiled = compile_pipeline(_empty_config(), config) + assert 'gain' not in compiled['filters'][_PEQ_PREFIX + 'lp']['parameters'] + assert 'gain' not in compiled['filters'][_PEQ_PREFIX + 'hp']['parameters'] + assert compiled['filters'][_PEQ_PREFIX + 'pk']['parameters']['gain'] == 3.0 + + +def test_foreign_filters_and_steps_are_preserved(): + config = DSPConfig(id='x', bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=3.0)]) + compiled = compile_pipeline(_foreign_config(), config) + assert compiled['devices'] == {'samplerate': 48000} + assert 'user_filter' in compiled['filters'] + assert {'type': 'Filter', 'channels': [0, 1], 'names': ['user_filter']} in compiled['pipeline'] + + +def test_does_not_mutate_caller_config(): + base = _empty_config() + config = DSPConfig(id='x', bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=3.0)]) + compile_pipeline(base, config) + assert base == {'filters': {}, 'pipeline': []} + + +def test_idempotency(): + config = DSPConfig( + id='x', + preamp_db=-4.0, + bands=[ + Band(id='b1', type='LowShelf', freq=90.0, gain=10.0), + Band(id='b2', type='Peaking', freq=1000.0, gain=3.0, enabled=False), + ], + ) + once = compile_pipeline(_foreign_config(), config) + twice = compile_pipeline(once, config) + assert twice == once + + +def test_empty_base_config_compiles_cleanly(): + config = DSPConfig(id='x') + compiled = compile_pipeline(_empty_config(), config) + assert compiled['filters'] == {_PREAMP_KEY: {'type': 'Gain', 'parameters': {'gain': 0.0}}} + assert compiled['pipeline'] == [{'type': 'Filter', 'channels': [0, 1], 'names': [_PREAMP_KEY], 'bypassed': False}] diff --git a/tests/domains/dsp/test_headroom.py b/tests/domains/dsp/test_headroom.py new file mode 100644 index 0000000..d29840e --- /dev/null +++ b/tests/domains/dsp/test_headroom.py @@ -0,0 +1,46 @@ +import pytest +from camilladsp_plot import eval_filter + +from audera.domains.dsp import response_peak_db +from audera.domains.dsp.compiler import _band_to_biquad +from audera.models.dsp import Band, DSPConfig + + +def test_single_band_matches_eval_filter_math(): + band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) + config = DSPConfig(id='x', preamp_db=-3.0, bands=[band]) + expected = max(eval_filter(_band_to_biquad(band), samplerate=48000)['magnitude']) + config.preamp_db + assert response_peak_db(config) == pytest.approx(expected) + + +def test_single_peaking_band_peaks_near_gain(): + band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) + config = DSPConfig(id='x', bands=[band]) + assert response_peak_db(config) == pytest.approx(6.0, abs=0.1) + + +@pytest.mark.parametrize('filter_type', ['Lowpass', 'Highpass']) +def test_high_q_pass_filter_resonates_above_unity(filter_type): + band = Band(id='b1', type=filter_type, freq=1000.0, q=4.0) + config = DSPConfig(id='x', bands=[band]) + assert response_peak_db(config) > 0.0 + + +def test_preamp_shifts_peak_by_scalar(): + band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) + flat = response_peak_db(DSPConfig(id='x', preamp_db=0.0, bands=[band])) + shifted = response_peak_db(DSPConfig(id='x', preamp_db=-4.0, bands=[band])) + assert shifted == pytest.approx(flat - 4.0) + + +def test_no_enabled_bands_returns_preamp(): + config = DSPConfig( + id='x', + preamp_db=-5.0, + bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, enabled=False)], + ) + assert response_peak_db(config) == pytest.approx(-5.0) + + +def test_empty_config_returns_preamp(): + assert response_peak_db(DSPConfig(id='x', preamp_db=-2.0)) == pytest.approx(-2.0) diff --git a/tests/domains/dsp/test_presets.py b/tests/domains/dsp/test_presets.py new file mode 100644 index 0000000..4adb3cf --- /dev/null +++ b/tests/domains/dsp/test_presets.py @@ -0,0 +1,24 @@ +from audera.domains.dsp import loudness_preset, response_peak_db +from audera.models.dsp import DSPConfig + + +def test_loudness_preset_shape(): + bands = loudness_preset() + assert len(bands) == 2 + + low, high = bands + assert (low.type, low.freq, low.gain, low.q) == ('LowShelf', 90.0, 10.0, 0.7) + assert (high.type, high.freq, high.gain, high.q) == ('HighShelf', 8000.0, 6.0, 0.7) + assert all(band.enabled for band in bands) + + +def test_loudness_preset_ids_are_unique(): + bands = loudness_preset() + assert bands[0].id != bands[1].id + # Fresh ids on every call — presets are editable, not shared singletons. + assert bands[0].id != loudness_preset()[0].id + + +def test_loudness_preset_needs_headroom(): + config = DSPConfig(id='x', bands=loudness_preset()) + assert response_peak_db(config) > 0.0 diff --git a/uv.lock b/uv.lock index b55a6dd..5dea2ca 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = "==3.13.*" [options] -exclude-newer = "2026-05-16T23:09:27.774948Z" +exclude-newer = "2026-07-01T01:50:51.1229439Z" exclude-newer-span = "P1W" [[package]] @@ -114,6 +114,7 @@ name = "audera" version = "0.1.0b1" source = { editable = "." } dependencies = [ + { name = "camilladsp-plot" }, { name = "duckdb" }, { name = "httpx" }, { name = "netifaces" }, @@ -136,6 +137,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "camilladsp-plot", git = "https://github.com/HEnquist/pycamilladsp-plot?rev=v3.0.2" }, { name = "duckdb", specifier = ">=1.5.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "netifaces", specifier = ">=0.11.0" }, @@ -162,6 +164,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, ] +[[package]] +name = "camilladsp-plot" +version = "3.0.2" +source = { git = "https://github.com/HEnquist/pycamilladsp-plot?rev=v3.0.2#dfec8e5acc4446421c8da7281b0625fc12b4162e" } +dependencies = [ + { name = "jsonschema" }, + { name = "pyyaml" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -448,6 +459,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "lxml" version = "6.1.0" @@ -897,6 +935,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -924,6 +975,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, +] + [[package]] name = "ruff" version = "0.15.12" From 75b7a8069980eba215b0fb13b71be86f21689475 Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Tue, 7 Jul 2026 21:12:11 -0500 Subject: [PATCH 03/18] feat: add CamillaDSP validate_config + clipped-sample counters (#48) WS-3: add three v3 websocket methods to CamillaDSPClient so Save can be gated on validation and runtime clipping surfaced in the editor footer. - validate_config(config) -> None: sends the config as a JSON-as-YAML string via the v3 ValidateConfig command (v4-only ValidateConfigJson avoided); raises RuntimeError on any non-Ok result, stricter than _call's literal-Error check since v3 reports failures as a message. - get_clipped_samples() -> int: unwraps the {result, value} count, defaulting to 0 (mirrors get_volume's defensive default). - reset_clipped_samples() -> None: zeroes the daemon's clip counter. Docker-gated tests against the real v3.0.1 daemon: valid config validates + round-trips with filters/steps intact; invalid config raises (parameterized over missing-filter-name and invalid-biquad-type); clipped-sample read/reset. Co-Authored-By: Claude Opus 4.8 --- audera/clients/camilladsp.py | 39 +++++++++++++++++++++++++ tests/clients/test_camilladsp.py | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/audera/clients/camilladsp.py b/audera/clients/camilladsp.py index 8a65999..53402a2 100644 --- a/audera/clients/camilladsp.py +++ b/audera/clients/camilladsp.py @@ -9,6 +9,9 @@ _CMD_GET_CONFIG_JSON = 'GetConfigJson' _CMD_SET_CONFIG_JSON = 'SetConfigJson' +_CMD_VALIDATE_CONFIG = 'ValidateConfig' +_CMD_GET_CLIPPED_SAMPLES = 'GetClippedSamples' +_CMD_RESET_CLIPPED_SAMPLES = 'ResetClippedSamples' class CamillaDSPClient: @@ -73,6 +76,42 @@ def set_config(self, config: dict): # SetConfigJson expects the config as a JSON string, not a dict self._call(_CMD_SET_CONFIG_JSON, json.dumps(config)) + def validate_config(self, config: dict) -> None: + """Validates a CamillaDSP pipeline configuration without applying it. + + Gates every Save: the compiled pipeline is checked by the daemon before it is + pushed via `set_config`, so an invalid config never reaches the running graph. + Raises `RuntimeError` when the daemon reports any non-`Ok` result. + + Parameters + ---------- + config: `dict` + The CamillaDSP pipeline configuration. + """ + # ValidateConfig expects a config string; JSON is a subset of YAML, so a JSON + # dict validates directly (ValidateConfigJson is v4+ and unavailable in v3.0.1). + response = self._call(_CMD_VALIDATE_CONFIG, json.dumps(config)) + inner = response.get(_CMD_VALIDATE_CONFIG, response) if isinstance(response, dict) else response + # `_call` only raises on a literal `result == 'Error'`, but any non-`Ok` result + # (e.g. a validation message / ConfigValidationError) means the config is invalid. + if isinstance(inner, dict) and inner.get('result') != 'Ok': + raise RuntimeError('CamillaDSP validation failed [%s]: %s' % (_CMD_VALIDATE_CONFIG, inner)) + + def get_clipped_samples(self) -> int: + """Returns the number of clipped samples since the last reset.""" + response = self._call(_CMD_GET_CLIPPED_SAMPLES) + if isinstance(response, dict): + val = response.get(_CMD_GET_CLIPPED_SAMPLES) + if isinstance(val, dict): + val = val.get('value', 0) + if isinstance(val, (int, float)): + return int(val) + return 0 + + def reset_clipped_samples(self) -> None: + """Zeroes the daemon's clipped-samples counter.""" + self._call(_CMD_RESET_CLIPPED_SAMPLES) + def get_volume(self) -> float: """Returns the current CamillaDSP volume level in dB.""" response = self._call('GetVolume') diff --git a/tests/clients/test_camilladsp.py b/tests/clients/test_camilladsp.py index 37561cd..9adfdc3 100644 --- a/tests/clients/test_camilladsp.py +++ b/tests/clients/test_camilladsp.py @@ -73,3 +73,52 @@ def test_set_percent_volume(client): def test_get_percent_volume(client): client.set_volume(-6.0) assert client.get_percent_volume() == 50 + + +def test_validate_config_valid(client): + config = { + **client.get_config(), + 'filters': { + 'peak_1': { + 'type': 'Biquad', + 'parameters': {'type': 'Peaking', 'freq': 1000, 'q': 1.0, 'gain': 3.0}, + } + }, + 'pipeline': [{'type': 'Filter', 'channels': [0, 1], 'names': ['peak_1']}], + } + assert client.validate_config(config) is None + client.set_config(config) + result = client.get_config() + assert result['filters']['peak_1']['parameters']['type'] == 'Peaking' + assert result['pipeline'][0]['names'] == ['peak_1'] + assert result['pipeline'][0]['channels'] == [0, 1] + + +@pytest.mark.parametrize( + 'overrides', + [ + # a pipeline step referencing a filter name absent from `filters` + {'filters': {}, 'pipeline': [{'type': 'Filter', 'channels': [0, 1], 'names': ['does_not_exist']}]}, + # a Biquad with an invalid `parameters.type` + { + 'filters': {'bad_1': {'type': 'Biquad', 'parameters': {'type': 'NotARealType', 'freq': 1000}}}, + 'pipeline': [{'type': 'Filter', 'channels': [0, 1], 'names': ['bad_1']}], + }, + ], + ids=['missing-filter-name', 'invalid-biquad-type'], +) +def test_validate_config_invalid_raises(client, overrides): + config = {**client.get_config(), **overrides} + with pytest.raises(RuntimeError): + client.validate_config(config) + + +def test_get_clipped_samples(client): + clipped = client.get_clipped_samples() + assert isinstance(clipped, int) + assert clipped >= 0 + + +def test_reset_clipped_samples(client): + assert client.reset_clipped_samples() is None + assert client.get_clipped_samples() == 0 From de66afa7951b857e8c9f8a88dfb5f4bd4ce5cb12 Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Wed, 8 Jul 2026 08:00:42 -0500 Subject: [PATCH 04/18] feat: add Advanced DSP EQ editor page + Save orchestration (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS-4 · EQ editor page at /player/{id}/dsp — pre-amp, band table (On/Type/Freq/Gain/Q), Loudness/Flat presets, live headroom read-out, Reset. Per-page staged state lives in closures; scalar edits mutate in place while structural changes refresh the band table. WS-5 · Save orchestration (Pattern B, live apply): get_config → compile_pipeline → validate_config (gate) → set_config → persist via dsp_dal.update → reset_clipped_samples. Adds a "protect headroom" guard and a 3s clip poll. Recovers the persisted dsp_id before resolving so opening the page never mints an orphan config. Co-Authored-By: Claude Opus 4.8 --- audera/ui/streamer/pages.py | 239 +++++++++++++++++++++++++++++++++++- tests/ui/test_streamer.py | 121 ++++++++++++++++++ 2 files changed, 359 insertions(+), 1 deletion(-) diff --git a/audera/ui/streamer/pages.py b/audera/ui/streamer/pages.py index 506dbc2..7d738bc 100644 --- a/audera/ui/streamer/pages.py +++ b/audera/ui/streamer/pages.py @@ -6,7 +6,7 @@ import subprocess import uuid from importlib.metadata import version as _pkg_version -from typing import Literal, Optional +from typing import Literal, Optional, get_args import httpx from dotenv import load_dotenv @@ -14,12 +14,21 @@ import audera from audera.clients import CamillaDSPClient, SnapserverClient +from audera.dal import dsp as dsp_dal +from audera.dal import players as players_dal from audera.dal import settings as settings_dal +from audera.domains.dsp import compile_pipeline, loudness_preset, response_peak_db +from audera.models.dsp import Band from audera.models.settings import Settings from audera.ui import components, features load_dotenv() +# Derived from the model literal so the editor's type choices can never drift from +# `audera.models.dsp.Band`. Pass filters carry no gain, so their gain field is disabled. +_BAND_TYPES = list(get_args(Band.model_fields['type'].annotation)) +_PASS_TYPES = {'Lowpass', 'Highpass'} + _PLEX_CLIENT_ID = str(uuid.uuid4()) _PLEX_HEADERS = { 'X-Plex-Product': audera.NAME, @@ -129,6 +138,7 @@ def __init__(self): def load(self) -> None: """Registers page routes.""" ui.page('/')(self.index) + ui.page('/player/{player_id}/dsp')(self.dsp) def index(self) -> None: """Renders the main dashboard page.""" @@ -153,6 +163,224 @@ def _maybe_refresh(): ui.timer(10.0, _maybe_refresh) + def dsp(self, player_id: str) -> None: + """Renders the full-page parametric-EQ editor for a single player. + + Bands are the source of truth; they are compiled to a live CamillaDSP pipeline + on Save. Per-page edit state lives in closures (not on `self`, which is shared + across every connected client): `state['saved']` mirrors the persisted config and + `state['staged']` is the working copy that is compiled, validated, and pushed on + Save. Scalar field edits mutate a band in place and only recompute the dirty + indicator + headroom; structural changes (add/delete/type/preset/reset) refresh + the band table. + """ + components.header.render(audera.NAME, 'Streamer') + + snap = _snapserver(self.settings) + try: + clients = snap.get_clients() + except Exception: + clients = [] + live = next((client for client in clients if client.id == player_id), None) + + if live is None: + with ui.column().classes('w-full gap-2 p-4'): + ui.link('‹ Players', '/') + ui.label('Player not found or unreachable.').classes('text-gray-500') + return + + # `SnapserverClient.get_clients` always reports `dsp_id=''` (it has no view of the + # persisted FK), so recover the link from the players DAL before resolving — + # otherwise `resolve_for_player` would re-mint an orphan config on every open. + persisted = players_dal.get(player_id) if players_dal.exists(player_id) else live + saved = dsp_dal.resolve_for_player(persisted) + state = {'saved': saved, 'staged': saved.model_copy(deep=True)} + + def _dirty() -> bool: + return state['staged'] != state['saved'] + + def _mark_changed() -> None: + """Recomputes the dirty indicator, headroom read-out, and band count.""" + dirty_label.set_visibility(_dirty()) + peak = response_peak_db(state['staged']) + if peak <= 0: + headroom_label.set_text(f'headroom ok · {abs(peak):.1f} dB to spare') + headroom_label.classes(replace='text-xs text-green-500') + protect_btn.set_visibility(False) + else: + headroom_label.set_text(f'clipping risk · {peak:.1f} dB over 0 dBFS') + headroom_label.classes(replace='text-xs text-amber-500') + protect_btn.set_visibility(True) + count_label.set_text(f'Bands ({len(state["staged"].bands)})') + + def _on_preamp(e) -> None: + if e.value is not None: + state['staged'].preamp_db = float(e.value) + _mark_changed() + + def _on_enabled(band: Band, value: bool) -> None: + band.enabled = bool(value) + _mark_changed() + + def _on_type(band: Band, value: str) -> None: + # `value` is constrained to `_BAND_TYPES` by the select; the assignment is + # unvalidated (Band sets no `validate_assignment`), so the literal narrows fine. + band.type = value # type: ignore + _band_table.refresh() # the gain field's enabled state depends on the type + _mark_changed() + + def _on_freq(band: Band, value) -> None: + if value is not None: + band.freq = float(value) + _mark_changed() + + def _on_gain(band: Band, value) -> None: + if value is not None: + band.gain = float(value) + _mark_changed() + + def _on_q(band: Band, value) -> None: + if value is not None: + band.q = float(value) + _mark_changed() + + def _add_band() -> None: + state['staged'].bands.append(Band(id=uuid.uuid4().hex, type='Peaking', freq=1000.0, gain=0.0, q=0.707)) + _band_table.refresh() + _mark_changed() + + def _remove_band(band: Band) -> None: + state['staged'].bands = [b for b in state['staged'].bands if b.id != band.id] + _band_table.refresh() + _mark_changed() + + def _apply_preset(kind: Literal['loudness', 'flat']) -> None: + if kind == 'loudness': + state['staged'].bands.extend(loudness_preset()) + else: + state['staged'].bands = [] + _band_table.refresh() + _mark_changed() + + def _on_reset() -> None: + state['staged'] = state['saved'].model_copy(deep=True) + preamp_field.value = state['staged'].preamp_db + _band_table.refresh() + _mark_changed() + + def _on_protect() -> None: + # Attenuate the pre-amp by the combined peak, driving the response to ~0 dBFS. + state['staged'].preamp_db -= response_peak_db(state['staged']) + preamp_field.value = state['staged'].preamp_db + _mark_changed() + + async def _on_save() -> None: + """Compiles → validates → pushes the live pipeline, then persists the config. + + Pattern B (live apply, no restart): the daemon owns volume and `SetConfigJson` + leaves the fader untouched, so no volume snapshot/restore is needed. The + `dsp_id` FK was already persisted by `resolve_for_player` at page load, so Save + only updates the config file. + """ + camilla = _camilladsp(live.host) + try: + current = await asyncio.to_thread(camilla.get_config) + compiled = compile_pipeline(current, state['staged']) + await asyncio.to_thread(camilla.validate_config, compiled) # gate; raises on invalid + await asyncio.to_thread(camilla.set_config, compiled) + except Exception as exc: + ui.notify(f'Save failed: {exc}', type='negative', position='top-right') + return + await asyncio.to_thread(dsp_dal.update, state['staged']) + try: + await asyncio.to_thread(camilla.reset_clipped_samples) # start the clip watch fresh + except Exception: + pass + state['saved'] = state['staged'].model_copy(deep=True) + _mark_changed() + ui.notify('Saved', type='positive', position='top-right') + + async def _poll_clips() -> None: + try: + count = await asyncio.to_thread(_camilladsp(live.host).get_clipped_samples) + except Exception: + return + if count: + clip_label.set_text(f'⚠ {count} clipped samples') + clip_label.set_visibility(True) + else: + clip_label.set_visibility(False) + + @ui.refreshable + def _band_table() -> None: + with ui.row(wrap=False).classes('items-center gap-2 w-full text-xs text-gray-500'): + ui.label('On').classes('w-10 text-center') + ui.label('Type').classes('w-32') + ui.label('Freq (Hz)').classes('w-24') + ui.label('Gain (dB)').classes('w-24') + ui.label('Q').classes('w-20') + ui.label('').classes('w-10') + for band in state['staged'].bands: + with ui.row(wrap=False).classes('items-center gap-2 w-full'): + ui.checkbox(value=band.enabled, on_change=lambda e, b=band: _on_enabled(b, e.value)).classes('w-10') + ui.select(_BAND_TYPES, value=band.type, on_change=lambda e, b=band: _on_type(b, e.value)).props( + 'dense outlined' + ).classes('w-32') + ui.number(value=band.freq, step=1, format='%.0f', on_change=lambda e, b=band: _on_freq(b, e.value)).props( + 'dense outlined' + ).classes('w-24') + gain_field = ( + ui.number(value=band.gain, step=0.1, format='%.1f', on_change=lambda e, b=band: _on_gain(b, e.value)) + .props('dense outlined') + .classes('w-24') + ) + gain_field.set_enabled(band.type not in _PASS_TYPES) + ui.number(value=band.q, step=0.001, format='%.3f', on_change=lambda e, b=band: _on_q(b, e.value)).props( + 'dense outlined' + ).classes('w-20') + ui.button(icon='delete', on_click=lambda b=band: _remove_band(b)).props('flat dense round size=sm').classes( + 'text-gray-400' + ) + ui.button('+ Add band', on_click=_add_band).props('flat dense').classes('mt-2') + + with ui.row().classes('items-center justify-between w-full'): + ui.label(f'{live.name} · Advanced DSP').classes('text-lg font-medium') + ui.link('‹ Players', '/') + + with ui.column().classes('w-full gap-3'): + with ui.row().classes('items-center gap-4 w-full'): + preamp_field = ( + ui.number('Pre-amp (dB)', value=state['staged'].preamp_db, step=0.1, format='%.1f', on_change=_on_preamp) + .props('dense outlined') + .classes('w-40') + ) + with ui.button('Presets', icon='tune').props('flat dense'): + with ui.menu(): + ui.menu_item('Loudness (seed bands)', on_click=lambda: _apply_preset('loudness')).mark('preset-loudness') + ui.menu_item('Flat / clear all bands', on_click=lambda: _apply_preset('flat')).mark('preset-flat') + ui.space() + protect_btn = ( + ui.button('protect headroom', icon='shield', on_click=_on_protect) + .props('flat dense') + .classes('text-amber-600') + ) + ui.button('Reset', on_click=_on_reset).props('flat dense') + ui.button('Save', on_click=_on_save).props('dense').classes('bg-gray-800 text-white') + + headroom_label = ui.label().classes('text-xs') + + _band_table() + + with ui.row().classes('items-center gap-4 w-full mt-2 text-xs text-gray-500'): + count_label = ui.label() + ui.label('IIR biquads · ~0% CPU') + dirty_label = ui.label('Unsaved changes ●').classes('text-amber-500') + clip_label = ui.label('').classes('text-red-500') + clip_label.set_visibility(False) # hidden until the clip poll reports a nonzero count + + _mark_changed() + ui.timer(3.0, _poll_clips) + @ui.refreshable def _build_services_tab(self) -> None: """Renders the Services tab — shows PlexAmp status and a browser-based OAuth claiming flow.""" @@ -294,6 +522,15 @@ def _build_players_tab(self) -> None: # Disable the settings icon for a disabled player, matching the intent of "disable". if minimized: settings_btn.set_enabled(False) + dsp_btn = ( + ui.button(on_click=lambda c=client: ui.navigate.to(f'/player/{c.id}/dsp')) + .props('icon=equalizer flat dense round size=sm') + .classes('text-gray-400') + .mark('player-dsp') + ) + # A disabled player has no live pipeline to edit, so gray out its EQ icon too. + if minimized: + dsp_btn.set_enabled(False) if minimized: continue diff --git a/tests/ui/test_streamer.py b/tests/ui/test_streamer.py index cdf5c84..8b4722b 100644 --- a/tests/ui/test_streamer.py +++ b/tests/ui/test_streamer.py @@ -9,6 +9,8 @@ import audera.ui.streamer.pages as streamer_pages from audera.clients import CamillaDSPClient, SnapserverClient +from audera.dal import dsp as dsp_dal +from audera.dal import players as players_dal from audera.dal import settings as settings_dal from audera.models.player import Player from audera.models.settings import Settings @@ -65,6 +67,42 @@ def _set_volume(self, level: float) -> None: return calls +@pytest.fixture +def mock_camilladsp_dsp(monkeypatch): + """Mocks the CamillaDSP client for the Advanced DSP editor page. + + Records the Save choreography (get/validate/set config, reset clipped samples) and + keeps the last-set config so a re-open would see the compiled pipeline. Keeps the + tests daemon-free while `response_peak_db` still runs the real `camilladsp_plot`. + """ + calls = {} + state = {'config': {'devices': {'samplerate': 48000}, 'filters': {}, 'pipeline': []}} + + def _get_config(self) -> dict: + calls['get_config'] = True + return state['config'] + + def _validate_config(self, config: dict) -> None: + calls['validate_config'] = config + + def _set_config(self, config: dict) -> None: + calls['set_config'] = config + state['config'] = config + + def _get_clipped_samples(self) -> int: + return 0 + + def _reset_clipped_samples(self) -> None: + calls['reset_clipped_samples'] = True + + monkeypatch.setattr(CamillaDSPClient, 'get_config', _get_config) + monkeypatch.setattr(CamillaDSPClient, 'validate_config', _validate_config) + monkeypatch.setattr(CamillaDSPClient, 'set_config', _set_config) + monkeypatch.setattr(CamillaDSPClient, 'get_clipped_samples', _get_clipped_samples) + monkeypatch.setattr(CamillaDSPClient, 'reset_clipped_samples', _reset_clipped_samples) + return calls + + @pytest.fixture def mock_snapserver_volume(monkeypatch): calls = {} @@ -378,3 +416,86 @@ async def test_settings_dialog_no_longer_shows_loudness(audera_home, mock_snapse await user.should_see('Snapcast Volume') await user.should_not_see('Loudness') await user.should_not_see('Reference level (dB)') + + +# --- Advanced DSP editor (WS-4 / WS-5) --------------------------------------------------- + + +async def test_players_tab_shows_dsp_icon(audera_home, mock_snapserver_with_client, mock_camilladsp, user: User): + Page().load() + await user.open('/') + await user.should_see(marker='player-dsp') + + +async def test_dsp_page_renders(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + Page().load() + await user.open('/player/abc123/dsp') + await user.should_see('Advanced DSP') + await user.should_see('Pre-amp (dB)') + await user.should_see('Presets') + await user.should_see('Save') + await user.should_see('Reset') + await user.should_see('Bands (0)') + + +async def test_dsp_page_unknown_player_shows_message(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + Page().load() + await user.open('/player/nope/dsp') + await user.should_see('Player not found or unreachable.') + + +@pytest.mark.parametrize( + 'steps', + [ + # Each step is (find-kwargs, expected footer label), applied in order. The + # intermediate `Bands (2)` on the flat/reset cases is load-bearing: their end state + # (0 bands) equals the initial state, so without it a silently no-op loudness click + # would let the test pass vacuously. + pytest.param([({'marker': 'preset-loudness'}, 'Bands (2)')], id='loudness-seeds-two'), + pytest.param( + [({'marker': 'preset-loudness'}, 'Bands (2)'), ({'marker': 'preset-flat'}, 'Bands (0)')], + id='flat-clears', + ), + pytest.param([({'content': '+ Add band'}, 'Bands (1)')], id='add-appends-one'), + pytest.param( + [({'marker': 'preset-loudness'}, 'Bands (2)'), ({'content': 'Reset'}, 'Bands (0)')], + id='reset-discards', + ), + ], +) +async def test_dsp_band_count_reflects_actions(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User, steps): + Page().load() + await user.open('/player/abc123/dsp') + for find_kwargs, expected in steps: + user.find(**find_kwargs).click() + await user.should_see(expected) + + +async def test_dsp_headroom_guard_protects_clipping(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + Page().load() + await user.open('/player/abc123/dsp') + user.find(marker='preset-loudness').click() + await user.should_see('clipping risk') + await user.should_see('protect headroom') + user.find('protect headroom').click() + await user.should_see('headroom ok') + + +async def test_dsp_save_applies_and_persists(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + Page().load() + await user.open('/player/abc123/dsp') + user.find(marker='preset-loudness').click() + await user.should_see('Bands (2)') + user.find('Save').click() + await user.should_see('Saved') + + # The compiled pipeline is both validated and pushed, and carries `audera_peq_*` filters. + assert 'validate_config' in mock_camilladsp_dsp + compiled = mock_camilladsp_dsp['set_config'] + assert any(name.startswith('audera_peq_') for name in compiled['filters']) + assert 'reset_clipped_samples' in mock_camilladsp_dsp + + # The config is persisted with the two bands and the player is linked to it. + config_id = players_dal.get('abc123').dsp_id + assert config_id + assert len(dsp_dal.get(config_id).bands) == 2 From 15e60ef736225768cdd1cdb541fc43e33fe031b4 Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Wed, 8 Jul 2026 09:21:11 -0500 Subject: [PATCH 05/18] fix: drop redundant --gain seed that crashed camilladsp on boot (#48) The camilladsp systemd unit passed `--gain -12.04` to seed a cold-boot node at 25%. clap parses the leading `-` of the space-separated `-12.04` as short flags, so camilladsp exited (`unexpected argument '-1'`) before binding the websocket port; `Restart=always` just crash-looped it. The DSP editor's Save then hit `ws://:1234` with nothing listening and failed with `[Errno 111] Connection refused`. The daemon already persists volume via `--statefile`, so the `--gain` seed was redundant. Remove it: camilladsp now starts, binds 1234, and self-creates the statefile (fader restored across restarts). Verified against the real CamillaDSP v3.0.1 container. Co-Authored-By: Claude Opus 4.8 --- os/dietpi/lib/common.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/os/dietpi/lib/common.sh b/os/dietpi/lib/common.sh index 1a8077c..62e91d8 100644 --- a/os/dietpi/lib/common.sh +++ b/os/dietpi/lib/common.sh @@ -50,7 +50,8 @@ install_camilladsp() { } # Writes the camilladsp systemd service unit, captures from ALSA loopback, plays to -# physical DAC (hw:0) +# physical DAC (hw:0). Volume is persisted by the daemon via --statefile, so no --gain +# seed is passed; the fader is restored from the statefile across restarts. write_camilladsp_service() { local config_path="$1" local statefile_path="$2" @@ -61,7 +62,7 @@ After=sound.target snapclient.service StartLimitIntervalSec=0 [Service] -ExecStart=/usr/local/bin/camilladsp $config_path --statefile $statefile_path --gain -12.04 -p 1234 --address 0.0.0.0 +ExecStart=/usr/local/bin/camilladsp $config_path --statefile $statefile_path -p 1234 --address 0.0.0.0 Restart=always RestartSec=5 From 1822a903a86cb30167c1c5a14d659fafbe560969 Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Wed, 8 Jul 2026 17:49:35 -0500 Subject: [PATCH 06/18] feat: add live frequency-response chart + auto-protected pre-amp (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS-6 of the advanced DSP v2 plan. Split the response curve from the peak in the headroom domain, add an auto-protecting pre-amp ceiling, and surface the combined EQ curve as a live ECharts line in the editor. - domains/dsp/headroom: factor `_summed_magnitude`; add `response_curve` (npoints=400) and `auto_preamp_db(bands, ...)`; keep `response_peak_db` numerically identical (delegates at npoints=1000) - ui/components/response_plot: new module — log x-axis 20 Hz-20 kHz, fixed -18..+18 dB box, single clipped accent line - ui/streamer/pages: draw the chart above the band table with a band-less info empty-state; clamp pre-amp to the clip-safe ceiling on load and in `_mark_changed`; drop the manual protect button, `_on_protect`, and the clip-warning branch; debounce band number inputs - tests: extend headroom + streamer suites for the curve, auto ceiling, and chart/empty-state visibility Co-Authored-By: Claude Opus 4.8 --- audera/domains/dsp/__init__.py | 11 +-- audera/domains/dsp/headroom.py | 123 ++++++++++++++++++++------ audera/ui/components/__init__.py | 4 +- audera/ui/components/response_plot.py | 63 +++++++++++++ audera/ui/streamer/pages.py | 64 ++++++++------ tests/domains/dsp/test_headroom.py | 52 ++++++++++- tests/ui/test_streamer.py | 73 +++++++++++++-- 7 files changed, 321 insertions(+), 69 deletions(-) create mode 100644 audera/ui/components/response_plot.py diff --git a/audera/domains/dsp/__init__.py b/audera/domains/dsp/__init__.py index 7695d0b..5369d45 100644 --- a/audera/domains/dsp/__init__.py +++ b/audera/domains/dsp/__init__.py @@ -1,13 +1,14 @@ """Pure DSP computation layer. Bands are the source of truth; this package compiles them into a CamillaDSP -pipeline (`compiler`), computes the combined magnitude peak for headroom safety -(`headroom`), and mints editable preset bands (`presets`). It imports only -`audera.models` (never `dal`/`clients`), so it is unit-testable with zero I/O. +pipeline (`compiler`), derives the combined magnitude response, its peak, and the +clip-safe pre-amp ceiling for headroom safety (`headroom`), and mints editable +preset bands (`presets`). It imports only `audera.models` (never `dal`/`clients`), +so it is unit-testable with zero I/O. """ from audera.domains.dsp.compiler import compile_pipeline -from audera.domains.dsp.headroom import response_peak_db +from audera.domains.dsp.headroom import auto_preamp_db, response_curve, response_peak_db from audera.domains.dsp.presets import loudness_preset -__all__ = ['compile_pipeline', 'response_peak_db', 'loudness_preset'] +__all__ = ['compile_pipeline', 'auto_preamp_db', 'response_curve', 'response_peak_db', 'loudness_preset'] diff --git a/audera/domains/dsp/headroom.py b/audera/domains/dsp/headroom.py index 08eea37..1c1d650 100644 --- a/audera/domains/dsp/headroom.py +++ b/audera/domains/dsp/headroom.py @@ -1,47 +1,116 @@ -"""Compute the true combined magnitude peak (dB) of a DSP configuration. +"""Compute the combined magnitude response and clip-safe pre-amp of a DSP configuration. -Drives the "protect headroom" guard: the suggested pre-amp attenuation is -`-response_peak_db`. Each enabled band's magnitude response is evaluated with -CamillaDSP's own `eval_filter` over a shared `logspace` grid (dependent only on -`samplerate`/`npoints`), summed element-wise, and offset by the flat pre-amp -Gain — matching the daemon's math so the guard can't drift from playback. +Bands are the source of truth; this module derives everything downstream from the same +element-wise sum. Each enabled band's magnitude response is evaluated with CamillaDSP's +own `eval_filter` over a shared `logspace` grid (dependent only on `samplerate`/`npoints`), +summed, and offset by the flat pre-amp Gain — matching the daemon's math so the live chart, +the automatic pre-amp ceiling, the headroom peak, and the compiled pipeline can never drift +apart. `logspace` is not re-exported from the package root, so it is imported from the +submodule that also backs `eval_filter`. """ from camilladsp_plot import eval_filter +from camilladsp_plot.eval_filterconfig import logspace from audera.domains.dsp.compiler import _band_to_biquad -from audera.models.dsp import DSPConfig +from audera.models.dsp import Band, DSPConfig _SAMPLERATE = 48000 +_CHART_NPOINTS = 400 +_SAFETY_NPOINTS = 1000 + + +def _summed_magnitude(bands: list[Band], samplerate: int, npoints: int) -> list[float]: + """Returns the element-wise dB-magnitude sum of every enabled band over the grid. + + The grid is identical across bands (it depends only on `samplerate`/`npoints`), so + element-wise addition is valid. A full-length list of zeros is returned when no band + is enabled, so the response axis exists with no band firing. + + Parameters + ---------- + bands: `list[audera.models.dsp.Band]` + The parametric-EQ bands to sum. + samplerate: `int` + The sample rate in Hz for the magnitude evaluation. + npoints: `int` + The number of points on the shared frequency grid. + """ + summed = [0.0] * npoints + for band in bands: + if not band.enabled: + continue + magnitude = eval_filter(_band_to_biquad(band), samplerate=samplerate, npoints=npoints)['magnitude'] + summed = [a + b for a, b in zip(summed, magnitude)] + return summed + + +def response_curve( + config: DSPConfig, + samplerate: int = _SAMPLERATE, + npoints: int = _CHART_NPOINTS, +) -> tuple[list[float], list[float]]: + """Returns the combined frequency-response curve `(frequencies, magnitudes)` in dB. + + `frequencies` is bit-identical to `eval_filter`'s `'f'` vector (same `logspace` call), + so the grid holds even with no band enabled. The scalar pre-amp Gain is added onto the + summed band magnitude, matching the daemon's flat pre-amp filter. + + Parameters + ---------- + config: `audera.models.dsp.DSPConfig` + An instance of an `audera.models.dsp.DSPConfig` object. + samplerate: `int` + The sample rate in Hz for the magnitude evaluation (default 48000, matching the + daemon/container config). + npoints: `int` + The number of points on the frequency grid (default 400 — cheap and smooth for the + chart). + """ + frequencies = logspace(1.0, samplerate * 0.95 / 2.0, npoints) + summed = _summed_magnitude(config.bands, samplerate, npoints) + return frequencies, [config.preamp_db + m for m in summed] def response_peak_db(config: DSPConfig, samplerate: int = _SAMPLERATE) -> float: """Returns the combined magnitude peak in dB for a 0 dBFS input. - Sums, element-wise over the shared frequency grid, the dB magnitude of every - enabled band, then adds the scalar pre-amp Gain. The grid is identical across - bands (it depends only on `samplerate`/`npoints`), so element-wise addition is - valid. With no enabled bands the response is flat 0 dB, so the pre-amp value is - returned directly. + Delegates to `response_curve` at the historical fine grid (`npoints=1000`), so the + numeric result is identical to the pre-split implementation. With no enabled bands the + response is flat, so the peak equals the pre-amp value. Parameters ---------- config: `audera.models.dsp.DSPConfig` An instance of an `audera.models.dsp.DSPConfig` object. samplerate: `int` - The sample rate in Hz for the magnitude evaluation (default 48000, matching - the daemon/container config). + The sample rate in Hz for the magnitude evaluation (default 48000, matching the + daemon/container config). """ - summed: list[float] = [] - for band in config.bands: - if not band.enabled: - continue - magnitude = eval_filter(_band_to_biquad(band), samplerate=samplerate)['magnitude'] - if not summed: - summed = list(magnitude) - else: - summed = [a + b for a, b in zip(summed, magnitude)] - - if not summed: - return config.preamp_db - return max(summed) + config.preamp_db + return max(response_curve(config, samplerate, npoints=_SAFETY_NPOINTS)[1]) + + +def auto_preamp_db(bands: list[Band], samplerate: int = _SAMPLERATE, margin_db: float = 0.0) -> float: + """Returns the clip-safe pre-amp ceiling in dB for a set of bands. + + The ceiling is `-(max(0, bands_peak) + margin_db)`: it attenuates by the combined boost + so the response never exceeds 0 dBFS, and is `0.0` for all-cut or no-enabled bands (a + net-cut response needs no attenuation). Evaluated on the fine 1000-point grid — a coarse + grid could under-sample a high-Q peak and over-optimize the ceiling. + + Takes `bands` rather than a `DSPConfig` because it never reads the pre-amp: it derives a + ceiling from the band shapes alone (a future preset that stores no pre-amp can do the + same). + + Parameters + ---------- + bands: `list[audera.models.dsp.Band]` + The parametric-EQ bands to derive the ceiling from. + samplerate: `int` + The sample rate in Hz for the magnitude evaluation (default 48000, matching the + daemon/container config). + margin_db: `float` + Extra headroom in dB reserved below 0 dBFS (default 0.0). + """ + summed = _summed_magnitude(bands, samplerate, npoints=_SAFETY_NPOINTS) + return -(max(0.0, max(summed)) + margin_db) diff --git a/audera/ui/components/__init__.py b/audera/ui/components/__init__.py index 57f4f18..125be52 100644 --- a/audera/ui/components/__init__.py +++ b/audera/ui/components/__init__.py @@ -1,5 +1,5 @@ """Shared NiceGUI UI primitives""" -from audera.ui.components import header, theme +from audera.ui.components import header, response_plot, theme -__all__ = ['header', 'theme'] +__all__ = ['header', 'response_plot', 'theme'] diff --git a/audera/ui/components/response_plot.py b/audera/ui/components/response_plot.py new file mode 100644 index 0000000..79c301f --- /dev/null +++ b/audera/ui/components/response_plot.py @@ -0,0 +1,63 @@ +"""Live combined frequency-response chart for the parametric-EQ editor. + +Renders the curve `audera.domains.dsp.response_curve` already computes as an ECharts +line: a fixed −18…+18 dB box over a 20 Hz–20 kHz log frequency axis. The series is +clipped (ECharts default) so a hot trace stays inside the box rather than rescaling it. +UI→domain imports are allowed; `theme` is imported as a submodule so this module is +import-order-independent of the `components` package init. +""" + +from nicegui import ui + +from audera.domains.dsp import response_curve +from audera.models.dsp import DSPConfig +from audera.ui.components import theme + + +def options(config: DSPConfig) -> dict: + """Returns the ECharts option `dict` for a DSP configuration's response curve. + + Parameters + ---------- + config: `audera.models.dsp.DSPConfig` + An instance of an `audera.models.dsp.DSPConfig` object. + """ + frequencies, magnitudes = response_curve(config) + return { + 'grid': {'left': 44, 'right': 16, 'top': 16, 'bottom': 32}, + 'tooltip': {'trigger': 'axis'}, + 'xAxis': { + 'type': 'log', + 'min': 20, + 'max': 20000, + 'axisLabel': {'formatter': '{value} Hz'}, + 'splitLine': {'lineStyle': {'color': '#eeeeee'}}, + }, + 'yAxis': { + 'type': 'value', + 'min': -18, + 'max': 18, + 'axisLabel': {'formatter': '{value} dB'}, + 'splitLine': {'lineStyle': {'color': '#eeeeee'}}, + }, + 'series': [ + { + 'type': 'line', + 'data': [[f, m] for f, m in zip(frequencies, magnitudes)], + 'showSymbol': False, + 'smooth': True, + 'lineStyle': {'color': theme.ACCENT, 'width': 2}, + } + ], + } + + +def render(config: DSPConfig) -> ui.echart: + """Returns a full-width ECharts line element for a DSP configuration's response curve. + + Parameters + ---------- + config: `audera.models.dsp.DSPConfig` + An instance of an `audera.models.dsp.DSPConfig` object. + """ + return ui.echart(options(config)).classes('w-full h-64') diff --git a/audera/ui/streamer/pages.py b/audera/ui/streamer/pages.py index 7d738bc..fe1cf3c 100644 --- a/audera/ui/streamer/pages.py +++ b/audera/ui/streamer/pages.py @@ -17,7 +17,7 @@ from audera.dal import dsp as dsp_dal from audera.dal import players as players_dal from audera.dal import settings as settings_dal -from audera.domains.dsp import compile_pipeline, loudness_preset, response_peak_db +from audera.domains.dsp import auto_preamp_db, compile_pipeline, loudness_preset from audera.models.dsp import Band from audera.models.settings import Settings from audera.ui import components, features @@ -171,8 +171,8 @@ def dsp(self, player_id: str) -> None: across every connected client): `state['saved']` mirrors the persisted config and `state['staged']` is the working copy that is compiled, validated, and pushed on Save. Scalar field edits mutate a band in place and only recompute the dirty - indicator + headroom; structural changes (add/delete/type/preset/reset) refresh - the band table. + indicator, clip-safe pre-amp clamp, and live response chart; structural changes + (add/delete/type/preset/reset) refresh the band table. """ components.header.render(audera.NAME, 'Streamer') @@ -194,23 +194,29 @@ def dsp(self, player_id: str) -> None: # otherwise `resolve_for_player` would re-mint an orphan config on every open. persisted = players_dal.get(player_id) if players_dal.exists(player_id) else live saved = dsp_dal.resolve_for_player(persisted) + # Clamp the baseline once so an over-hot legacy config opens clean, not falsely dirty. + saved.preamp_db = min(saved.preamp_db, auto_preamp_db(saved.bands)) state = {'saved': saved, 'staged': saved.model_copy(deep=True)} def _dirty() -> bool: return state['staged'] != state['saved'] def _mark_changed() -> None: - """Recomputes the dirty indicator, headroom read-out, and band count.""" + """Clamps pre-amp to the clip-safe ceiling, then refreshes dirty state, chart, count.""" + clamped = min(state['staged'].preamp_db, auto_preamp_db(state['staged'].bands)) + if clamped != state['staged'].preamp_db: # min is a fixpoint — converges in one pass + state['staged'].preamp_db = clamped + preamp_field.value = clamped dirty_label.set_visibility(_dirty()) - peak = response_peak_db(state['staged']) - if peak <= 0: - headroom_label.set_text(f'headroom ok · {abs(peak):.1f} dB to spare') - headroom_label.classes(replace='text-xs text-green-500') - protect_btn.set_visibility(False) - else: - headroom_label.set_text(f'clipping risk · {peak:.1f} dB over 0 dBFS') - headroom_label.classes(replace='text-xs text-amber-500') - protect_btn.set_visibility(True) + has_bands = bool(state['staged'].bands) # chart only once a band exists + chart.set_visibility(has_bands) + chart_message.set_visibility(not has_bands) + if has_bands: + # `EChart.options` is a read-only view onto the live props dict; swap its + # contents in place (the documented "change the options" push) and redraw. + chart.options.clear() + chart.options.update(components.response_plot.options(state['staged'])) + chart.update() count_label.set_text(f'Bands ({len(state["staged"].bands)})') def _on_preamp(e) -> None: @@ -268,12 +274,6 @@ def _on_reset() -> None: _band_table.refresh() _mark_changed() - def _on_protect() -> None: - # Attenuate the pre-amp by the combined peak, driving the response to ~0 dBFS. - state['staged'].preamp_db -= response_peak_db(state['staged']) - preamp_field.value = state['staged'].preamp_db - _mark_changed() - async def _on_save() -> None: """Compiles → validates → pushes the live pipeline, then persists the config. @@ -327,16 +327,16 @@ def _band_table() -> None: 'dense outlined' ).classes('w-32') ui.number(value=band.freq, step=1, format='%.0f', on_change=lambda e, b=band: _on_freq(b, e.value)).props( - 'dense outlined' + 'dense outlined debounce=200' ).classes('w-24') gain_field = ( ui.number(value=band.gain, step=0.1, format='%.1f', on_change=lambda e, b=band: _on_gain(b, e.value)) - .props('dense outlined') + .props('dense outlined debounce=200') .classes('w-24') ) gain_field.set_enabled(band.type not in _PASS_TYPES) ui.number(value=band.q, step=0.001, format='%.3f', on_change=lambda e, b=band: _on_q(b, e.value)).props( - 'dense outlined' + 'dense outlined debounce=200' ).classes('w-20') ui.button(icon='delete', on_click=lambda b=band: _remove_band(b)).props('flat dense round size=sm').classes( 'text-gray-400' @@ -350,7 +350,13 @@ def _band_table() -> None: with ui.column().classes('w-full gap-3'): with ui.row().classes('items-center gap-4 w-full'): preamp_field = ( - ui.number('Pre-amp (dB)', value=state['staged'].preamp_db, step=0.1, format='%.1f', on_change=_on_preamp) + ui.number( + 'Pre-amp (dB) · auto-protected', + value=state['staged'].preamp_db, + step=0.1, + format='%.1f', + on_change=_on_preamp, + ) .props('dense outlined') .classes('w-40') ) @@ -359,15 +365,15 @@ def _band_table() -> None: ui.menu_item('Loudness (seed bands)', on_click=lambda: _apply_preset('loudness')).mark('preset-loudness') ui.menu_item('Flat / clear all bands', on_click=lambda: _apply_preset('flat')).mark('preset-flat') ui.space() - protect_btn = ( - ui.button('protect headroom', icon='shield', on_click=_on_protect) - .props('flat dense') - .classes('text-amber-600') - ) ui.button('Reset', on_click=_on_reset).props('flat dense') ui.button('Save', on_click=_on_save).props('dense').classes('bg-gray-800 text-white') - headroom_label = ui.label().classes('text-xs') + # Persistent handle + empty-state message, both toggled by the forward-closure + # `_mark_changed`: the chart shows only once a band exists, the message otherwise. + chart = components.response_plot.render(state['staged']) + chart_message = ui.label( + 'Add a band to see the live frequency-response curve — start from Presets ▾, or + Add band below.' + ).classes('text-sm text-gray-500 p-4') _band_table() diff --git a/tests/domains/dsp/test_headroom.py b/tests/domains/dsp/test_headroom.py index d29840e..d4eadf4 100644 --- a/tests/domains/dsp/test_headroom.py +++ b/tests/domains/dsp/test_headroom.py @@ -1,7 +1,8 @@ import pytest from camilladsp_plot import eval_filter +from camilladsp_plot.eval_filterconfig import logspace -from audera.domains.dsp import response_peak_db +from audera.domains.dsp import auto_preamp_db, response_curve, response_peak_db from audera.domains.dsp.compiler import _band_to_biquad from audera.models.dsp import Band, DSPConfig @@ -44,3 +45,52 @@ def test_no_enabled_bands_returns_preamp(): def test_empty_config_returns_preamp(): assert response_peak_db(DSPConfig(id='x', preamp_db=-2.0)) == pytest.approx(-2.0) + + +def test_response_curve_lengths_match_and_frequency_is_strictly_increasing(): + band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) + frequencies, magnitudes = response_curve(DSPConfig(id='x', bands=[band])) + assert len(frequencies) == len(magnitudes) + assert all(later > earlier for earlier, later in zip(frequencies, frequencies[1:])) + + +def test_response_curve_no_enabled_bands_synthesizes_full_flat_line(): + # The axis is synthesized from `logspace`, not borrowed from a band's `eval_filter` + # result, so the full grid + flat pre-amp line exists with no band firing. + config = DSPConfig(id='x', preamp_db=-3.0, bands=[Band(id='b1', freq=1000.0, gain=6.0, enabled=False)]) + frequencies, magnitudes = response_curve(config, npoints=400) + assert frequencies == logspace(1.0, 48000 * 0.95 / 2.0, 400) + assert len(magnitudes) == 400 + assert magnitudes == pytest.approx([-3.0] * 400) + + +def test_response_peak_db_equals_curve_max_on_fine_grid(): + band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) + config = DSPConfig(id='x', preamp_db=-2.0, bands=[band]) + assert response_peak_db(config) == pytest.approx(max(response_curve(config, npoints=1000)[1])) + + +def test_auto_preamp_db_zero_for_no_enabled_bands(): + assert auto_preamp_db([]) == pytest.approx(0.0) + assert auto_preamp_db([Band(id='b1', freq=1000.0, gain=6.0, enabled=False)]) == pytest.approx(0.0) + + +def test_auto_preamp_db_zero_for_all_cut(): + # A net cut never exceeds 0 dBFS, so no attenuation is needed. + band = Band(id='b1', type='Peaking', freq=1000.0, gain=-6.0, q=1.0) + assert auto_preamp_db([band]) == pytest.approx(0.0) + + +def test_auto_preamp_db_cancels_boost_peak_so_clamped_config_never_clips(): + band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) + ceiling = auto_preamp_db([band]) + assert ceiling == pytest.approx(-6.0, abs=0.1) + clamped = DSPConfig(id='x', preamp_db=ceiling, bands=[band]) + assert response_peak_db(clamped) <= 1e-6 + + +def test_auto_preamp_db_honours_margin(): + band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) + no_margin = auto_preamp_db([band]) + with_margin = auto_preamp_db([band], margin_db=3.0) + assert with_margin == pytest.approx(no_margin - 3.0) diff --git a/tests/ui/test_streamer.py b/tests/ui/test_streamer.py index 8b4722b..4cff45d 100644 --- a/tests/ui/test_streamer.py +++ b/tests/ui/test_streamer.py @@ -12,6 +12,7 @@ from audera.dal import dsp as dsp_dal from audera.dal import players as players_dal from audera.dal import settings as settings_dal +from audera.models.dsp import Band, DSPConfig from audera.models.player import Player from audera.models.settings import Settings from audera.ui import components, features @@ -471,14 +472,76 @@ async def test_dsp_band_count_reflects_actions(audera_home, mock_snapserver_with await user.should_see(expected) -async def test_dsp_headroom_guard_protects_clipping(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): +def _seed_linked_dsp(config: DSPConfig) -> None: + """Persists a DSP config and links player 'abc123' to it (the page's load path). + + The editor recovers the config via `players_dal` → `resolve_for_player`, so a linked + player record is required for the seeded config to open instead of a fresh empty one. + """ + dsp_dal.create(config) + players_dal.create(Player(id='abc123', host='192.168.1.50', port=1704, connected=True, dsp_id=config.id)) + + +async def test_dsp_bandless_shows_chart_message_and_hides_chart( + audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User +): + Page().load() + await user.open('/player/abc123/dsp') + await user.should_see('Add a band') + await user.should_not_see(kind=ui.echart) + + +async def test_dsp_adding_band_reveals_chart(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + Page().load() + await user.open('/player/abc123/dsp') + await user.should_not_see(kind=ui.echart) + user.find(content='+ Add band').click() + await user.should_see(kind=ui.echart) + + +async def test_dsp_preamp_clamp_keeps_below_ceiling_value( + audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User +): + # A +6 dB boost sets the ceiling at ~-6 dB; -10 is below it, so the clamp leaves it. + _seed_linked_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + Page().load() + await user.open('/player/abc123/dsp') + with user: + user.find(kind=ui.number, content='auto-protected').elements.pop().value = -10.0 + await asyncio.sleep(0.1) + assert user.find(kind=ui.number, content='auto-protected').elements.pop().value == pytest.approx(-10.0) + + +async def test_dsp_preamp_clamp_pulls_above_ceiling_value_down( + audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User +): + # Raising the pre-amp to -2 dB over a +6 dB boost would clip, so the clamp snaps it back + # down to the ~-6 dB clip-safe ceiling. + _seed_linked_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + Page().load() + await user.open('/player/abc123/dsp') + with user: + user.find(kind=ui.number, content='auto-protected').elements.pop().value = -2.0 + await asyncio.sleep(0.1) + assert user.find(kind=ui.number, content='auto-protected').elements.pop().value == pytest.approx(-6.0, abs=0.2) + + +async def test_dsp_over_hot_saved_config_opens_clean(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + # Saved with a hot 0 dB pre-amp above the +6 dB boost ceiling; the load-time baseline + # clamp pulls it down so the editor opens without a false "Unsaved changes" flag. + _seed_linked_dsp(DSPConfig(id='cfg1', preamp_db=0.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + Page().load() + await user.open('/player/abc123/dsp') + await user.should_see(kind=ui.echart) + await user.should_not_see('Unsaved changes') + + +async def test_dsp_protect_button_removed(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): Page().load() await user.open('/player/abc123/dsp') user.find(marker='preset-loudness').click() - await user.should_see('clipping risk') - await user.should_see('protect headroom') - user.find('protect headroom').click() - await user.should_see('headroom ok') + await user.should_see('Bands (2)') + await user.should_not_see('protect headroom') async def test_dsp_save_applies_and_persists(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): From 3634e8dc4c966091e5d24a86d2d115453ab1a71f Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Wed, 8 Jul 2026 19:09:12 -0500 Subject: [PATCH 07/18] =?UTF-8?q?feat:=20add=20REW=20paste=20import=20+=20?= =?UTF-8?q?export=20via=20Config=20=E2=96=BE=20menu=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import and export are inverses over bands, so they ship together. A new pure-domain rew.py adds parse_rew/format_rew (mapped to a RewImport model) that round-trip modulo fresh ids and float-format precision. The editor gains a Config ▾ menu beside Presets: Import REW… appends parsed bands and re-clamps the auto pre-amp; Export… surfaces the saved config as re-importable REW text with Copy/Download and an unsaved-changes banner. Pre-amp is deliberately not round-tripped — the auto-ceiling owns it on import. Note: ui.clipboard.write is synchronous in NiceGUI 3.11.1 (not async as the plan assumed), so the Copy handler is a plain sync function. Co-Authored-By: Claude Opus 4.8 --- audera/domains/dsp/__init__.py | 11 ++- audera/domains/dsp/rew.py | 150 ++++++++++++++++++++++++++++++++ audera/ui/streamer/pages.py | 76 ++++++++++++++++- tests/domains/dsp/test_rew.py | 151 +++++++++++++++++++++++++++++++++ tests/ui/test_streamer.py | 65 ++++++++++++++ 5 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 audera/domains/dsp/rew.py create mode 100644 tests/domains/dsp/test_rew.py diff --git a/audera/domains/dsp/__init__.py b/audera/domains/dsp/__init__.py index 5369d45..2800269 100644 --- a/audera/domains/dsp/__init__.py +++ b/audera/domains/dsp/__init__.py @@ -10,5 +10,14 @@ from audera.domains.dsp.compiler import compile_pipeline from audera.domains.dsp.headroom import auto_preamp_db, response_curve, response_peak_db from audera.domains.dsp.presets import loudness_preset +from audera.domains.dsp.rew import format_rew, parse_rew -__all__ = ['compile_pipeline', 'auto_preamp_db', 'response_curve', 'response_peak_db', 'loudness_preset'] +__all__ = [ + 'compile_pipeline', + 'auto_preamp_db', + 'response_curve', + 'response_peak_db', + 'loudness_preset', + 'format_rew', + 'parse_rew', +] diff --git a/audera/domains/dsp/rew.py b/audera/domains/dsp/rew.py new file mode 100644 index 0000000..e85826b --- /dev/null +++ b/audera/domains/dsp/rew.py @@ -0,0 +1,150 @@ +"""Import/export parametric-EQ bands as REW / Equalizer APO filter text. + +`parse_rew` and `format_rew` are band-inverses: the bands emitted by `format_rew` +round-trip back through `parse_rew` modulo fresh ids and float-format precision. Both +are pure — they import only `audera.models` (+ stdlib `re`/`uuid` and pydantic), so they +are Docker-free and unit-testable with zero I/O. + +Pre-amp is intentionally *not* round-tripped: `parse_rew` recognizes a `Preamp:` line but +never applies it (the editor's auto-ceiling owns pre-amp on import), and `format_rew` emits +the honest saved pre-amp only so the exported text stays safe to paste into another tool. +""" + +import re +import uuid + +from pydantic import BaseModel, Field + +from audera.models.dsp import Band + +# REW/Equalizer APO filter kinds → `Band.type`. `LSC`/`HSC` are REW's "corner" shelf +# variants, which we treat as ordinary shelves. Unsupported kinds (`NO` notch, `AP` +# allpass, `BP` bandpass) are deliberately absent so their lines land in `skipped`. +_PARSE_TYPES = { + 'PK': 'Peaking', + 'LS': 'LowShelf', + 'LSC': 'LowShelf', + 'HS': 'HighShelf', + 'HSC': 'HighShelf', + 'LP': 'Lowpass', + 'HP': 'Highpass', +} + +# `Band.type` → REW filter kind (the inverse of `_PARSE_TYPES`, collapsing the shelf +# variants onto the plain shelf codes). +_FORMAT_TYPES = { + 'Peaking': 'PK', + 'LowShelf': 'LS', + 'HighShelf': 'HS', + 'Lowpass': 'LP', + 'Highpass': 'HP', +} + +# Pass filters carry no gain, so `format_rew` omits `Gain` for them (matching the compiler +# and the editor's disabled gain field) while still emitting `Q`. +_PASS_TYPES = {'Lowpass', 'Highpass'} + +_DEFAULT_Q = 0.707 + +_NUMBER = r'[-+]?\d*\.?\d+' + +# `Preamp: -6.0 dB` — case-insensitive; recognized so it is never mistaken for garbage. +_PREAMP_RE = re.compile(r'^Preamp\s*:', re.IGNORECASE) + +# `Filter [N]: ON|OFF Fc Hz [Gain dB] [Q ]`, whitespace-tolerant. The +# leading index is optional so Equalizer APO's `Filter: …` lines parse too. `Fc`/`Gain`/`Q` +# are each optional captures so a supported kind that is missing `Fc` can be routed to +# `skipped` rather than silently defaulted. +_FILTER_RE = re.compile( + r'^Filter\s*\d*\s*:\s*' + r'(?PON|OFF)\s+' + r'(?P[A-Za-z]+)' + rf'(?:\s+Fc\s+(?P{_NUMBER})\s*Hz)?' + rf'(?:\s+Gain\s+(?P{_NUMBER})\s*dB)?' + rf'(?:\s+Q\s+(?P{_NUMBER}))?', + re.IGNORECASE, +) + + +class RewImport(BaseModel): + """A `class` that represents the result of parsing a REW filter export. + + Attributes + ---------- + bands: `list[audera.models.dsp.Band]` + The parsed bands, each with a fresh id, ready to append to a configuration. + skipped: `list[str]` + The raw lines that could not be parsed (unsupported kind, missing `Fc`, or + non-filter text), surfaced so nothing is dropped silently. + """ + + bands: list[Band] = Field(default_factory=list) + skipped: list[str] = Field(default_factory=list) + + +def parse_rew(text: str) -> RewImport: + """Returns the bands parsed from a REW / Equalizer APO filter export. + + Parsed line-by-line and never silently dropping: blank lines are ignored, a `Preamp:` + line is recognized but neither applied nor skipped (the editor's auto-ceiling owns + pre-amp), a supported filter line becomes a fresh-id `Band`, and everything else — + unsupported kinds, filter lines missing `Fc`, or non-filter text — is collected in + `skipped`. + + Parameters + ---------- + text: `str` + The REW / Equalizer APO filter export to parse. + """ + bands: list[Band] = [] + skipped: list[str] = [] + for raw in text.splitlines(): + line = raw.strip() + if not line: + continue + if _PREAMP_RE.match(line): + continue # recognized, not applied, not skipped + match = _FILTER_RE.match(line) + mapped = _PARSE_TYPES.get(match.group('kind').upper()) if match else None + fc = match.group('fc') if match else None + if match is None or mapped is None or fc is None: + skipped.append(line) + continue + gain = match.group('gain') + q = match.group('q') + bands.append( + Band( + id=uuid.uuid4().hex, + type=mapped, # type: ignore + freq=float(fc), + gain=float(gain) if gain is not None else 0.0, + q=float(q) if q is not None else _DEFAULT_Q, + enabled=match.group('state').upper() == 'ON', + ) + ) + return RewImport(bands=bands, skipped=skipped) + + +def format_rew(preamp_db: float, bands: list[Band]) -> str: + """Returns a REW-format filter export for a pre-amp and a set of bands. + + The first line is `Preamp: {preamp_db:.1f} dB` (the honest value — for a saved config + this is the real auto-clamped pre-amp, so the text applies safely in another tool), + followed by one 1-indexed `Filter` line per band. Pass filters omit `Gain` but still + emit `Q`, so a pass band's `q` survives the round-trip. + + Parameters + ---------- + preamp_db: `float` + The pre-amp attenuation in dB, emitted for fidelity (`parse_rew` does not re-apply + it). + bands: `list[audera.models.dsp.Band]` + The parametric-EQ bands to format. + """ + lines = [f'Preamp: {preamp_db:.1f} dB'] + for index, band in enumerate(bands, start=1): + kind = _FORMAT_TYPES[band.type] + state = 'ON' if band.enabled else 'OFF' + gain = '' if band.type in _PASS_TYPES else f' Gain {band.gain:.2f} dB' + lines.append(f'Filter {index}: {state} {kind} Fc {band.freq:.1f} Hz{gain} Q {band.q:.3f}') + return '\n'.join(lines) diff --git a/audera/ui/streamer/pages.py b/audera/ui/streamer/pages.py index fe1cf3c..695cc38 100644 --- a/audera/ui/streamer/pages.py +++ b/audera/ui/streamer/pages.py @@ -17,7 +17,7 @@ from audera.dal import dsp as dsp_dal from audera.dal import players as players_dal from audera.dal import settings as settings_dal -from audera.domains.dsp import auto_preamp_db, compile_pipeline, loudness_preset +from audera.domains.dsp import auto_preamp_db, compile_pipeline, format_rew, loudness_preset, parse_rew from audera.models.dsp import Band from audera.models.settings import Settings from audera.ui import components, features @@ -268,6 +268,76 @@ def _apply_preset(kind: Literal['loudness', 'flat']) -> None: _band_table.refresh() _mark_changed() + def _open_import_dialog() -> None: + """Opens a paste-import dialog that appends REW / Equalizer APO filters as bands. + + Import is append-only (it never replaces existing bands) and routes through + `_mark_changed`, so the auto-ceiling re-clamps the pre-amp and the chart redraws + over the merged band set. Unparseable lines are surfaced in the notification + rather than dropped. + """ + with ui.dialog() as dialog, ui.card().classes('w-96'): + ui.label('Import REW filters').classes('font-medium text-lg mb-1') + ui.label( + 'Paste a REW or Equalizer APO filter export. Bands are appended to the current configuration; ' + 'the pre-amp stays auto-protected.' + ).classes('text-xs text-gray-500 mb-2') + text = ( + ui.textarea(placeholder='Filter 1: ON PK Fc 1000 Hz Gain -3.0 dB Q 1.41') + .props('outlined autogrow') + .classes('w-full font-mono') + ) + + def _on_import() -> None: + result = parse_rew(text.value or '') + state['staged'].bands.extend(result.bands) + _band_table.refresh() + _mark_changed() # re-clamps pre-amp + redraws chart over the merged bands + message = f'Imported {len(result.bands)} band(s)' + if result.skipped: + message += f', skipped {len(result.skipped)} line(s)' + ui.notify(message, type='positive', position='top-right') + dialog.close() + + with ui.row().classes('justify-between w-full mt-2'): + ui.button('Cancel', on_click=dialog.close).props('flat dense') + ui.button('Import', on_click=_on_import).props('dense').classes('bg-gray-800 text-white').mark( + 'config-import-run' + ) + + dialog.open() + + def _open_export_dialog() -> None: + """Opens a dialog showing the *saved* configuration as re-importable REW text. + + The text is always the saved config (never the staged edits): a partial edit + would export a config that never clip-guarded, so Save is the gate. When the + editor is dirty a banner spells this out; Copy and Download surface the same text. + """ + rew_text = format_rew(state['saved'].preamp_db, state['saved'].bands) + with ui.dialog() as dialog, ui.card().classes('w-96'): + ui.label('Export REW filters').classes('font-medium text-lg mb-1') + if _dirty(): + ui.label( + 'There are unsaved changes. Export downloads only the saved configuration — ' + 'Save first to export the current editor contents.' + ).classes('text-xs text-amber-500 mb-2').mark('export-unsaved-banner') + ui.textarea(value=rew_text).props('outlined readonly autogrow').classes('w-full font-mono') + + def _on_copy() -> None: + ui.clipboard.write(rew_text) # synchronous in NiceGUI 3.11.1 + ui.notify('Copied to clipboard', type='positive', position='top-right') + + def _on_download() -> None: + ui.download.content(rew_text, f'{live.name}-dsp.txt') + + with ui.row().classes('justify-between w-full mt-2'): + ui.button('Close', on_click=dialog.close).props('flat dense') + ui.button('Copy', on_click=_on_copy).props('dense') + ui.button('Download .txt', on_click=_on_download).props('dense').classes('bg-gray-800 text-white') + + dialog.open() + def _on_reset() -> None: state['staged'] = state['saved'].model_copy(deep=True) preamp_field.value = state['staged'].preamp_db @@ -364,6 +434,10 @@ def _band_table() -> None: with ui.menu(): ui.menu_item('Loudness (seed bands)', on_click=lambda: _apply_preset('loudness')).mark('preset-loudness') ui.menu_item('Flat / clear all bands', on_click=lambda: _apply_preset('flat')).mark('preset-flat') + with ui.button('Config', icon='import_export').props('flat dense'): + with ui.menu(): + ui.menu_item('Import REW…', on_click=_open_import_dialog).mark('config-import') + ui.menu_item('Export…', on_click=_open_export_dialog).mark('config-export') ui.space() ui.button('Reset', on_click=_on_reset).props('flat dense') ui.button('Save', on_click=_on_save).props('dense').classes('bg-gray-800 text-white') diff --git a/tests/domains/dsp/test_rew.py b/tests/domains/dsp/test_rew.py new file mode 100644 index 0000000..c36c01e --- /dev/null +++ b/tests/domains/dsp/test_rew.py @@ -0,0 +1,151 @@ +import pytest + +from audera.domains.dsp import format_rew, parse_rew +from audera.models.dsp import Band, DSPConfig + + +@pytest.mark.parametrize( + 'kind, expected', + [ + ('PK', 'Peaking'), + ('LS', 'LowShelf'), + ('LSC', 'LowShelf'), + ('HS', 'HighShelf'), + ('HSC', 'HighShelf'), + ('LP', 'Lowpass'), + ('HP', 'Highpass'), + ], +) +def test_parse_maps_supported_kinds(kind, expected): + result = parse_rew(f'Filter 1: ON {kind} Fc 1000 Hz Gain 3.0 dB Q 1.0') + assert [band.type for band in result.bands] == [expected] + assert result.skipped == [] + + +def test_parse_reads_numeric_fields(): + result = parse_rew('Filter 1: ON PK Fc 1000 Hz Gain -3.5 dB Q 1.41') + (band,) = result.bands + assert band.freq == pytest.approx(1000.0) + assert band.gain == pytest.approx(-3.5) + assert band.q == pytest.approx(1.41) + + +@pytest.mark.parametrize('state, enabled', [('ON', True), ('OFF', False)]) +def test_parse_on_off_sets_enabled(state, enabled): + (band,) = parse_rew(f'Filter 1: {state} PK Fc 1000 Hz Gain 3.0 dB Q 1.0').bands + assert band.enabled is enabled + + +def test_parse_accepts_equalizer_apo_lines_without_index(): + (band,) = parse_rew('Filter: ON PK Fc 1000 Hz Gain 3.0 dB Q 1.0').bands + assert band.type == 'Peaking' + + +@pytest.mark.parametrize('kind', ['NO', 'AP', 'BP']) +def test_parse_skips_unsupported_kinds(kind): + result = parse_rew(f'Filter 1: ON {kind} Fc 1000 Hz Gain 3.0 dB Q 1.0') + assert result.bands == [] + assert len(result.skipped) == 1 + + +def test_parse_skips_supported_kind_missing_fc(): + result = parse_rew('Filter 1: ON PK Gain 3.0 dB Q 1.0') + assert result.bands == [] + assert len(result.skipped) == 1 + + +def test_parse_skips_garbage_line(): + result = parse_rew('this is not a filter line') + assert result.bands == [] + assert result.skipped == ['this is not a filter line'] + + +def test_parse_ignores_blank_lines(): + result = parse_rew('\n \n\t\n') + assert result.bands == [] + assert result.skipped == [] + + +def test_parse_recognizes_preamp_without_band_or_skip(): + result = parse_rew('Preamp: -6.5 dB') + assert result.bands == [] + assert result.skipped == [] + + +def test_parse_preamp_plus_filter_yields_one_band(): + result = parse_rew('Preamp: -6.5 dB\nFilter 1: ON PK Fc 1000 Hz Gain 3.0 dB Q 1.0') + assert len(result.bands) == 1 + assert result.skipped == [] + + +@pytest.mark.parametrize('kind, expected', [('LP', 'Lowpass'), ('HP', 'Highpass')]) +def test_parse_pass_filter_without_q_defaults(kind, expected): + (band,) = parse_rew(f'Filter 1: ON {kind} Fc 1000 Hz').bands + assert band.type == expected + assert band.q == pytest.approx(0.707) + + +def test_parse_mints_unique_ids(): + result = parse_rew('Filter 1: ON PK Fc 1000 Hz Gain 3.0 dB Q 1.0\nFilter 2: ON PK Fc 2000 Hz Gain 3.0 dB Q 1.0') + ids = [band.id for band in result.bands] + assert len(ids) == len(set(ids)) == 2 + + +def test_format_emits_preamp_and_filter_lines(): + text = format_rew(-6.0, [Band(id='b1', type='Peaking', freq=1000.0, gain=3.0, q=1.0)]) + lines = text.splitlines() + assert lines[0] == 'Preamp: -6.0 dB' + assert lines[1].startswith('Filter 1:') + assert 'PK' in lines[1] + + +def test_format_pass_filter_omits_gain_keeps_q(): + text = format_rew(0.0, [Band(id='b1', type='Lowpass', freq=12000.0, q=0.8)]) + (_, filter_line) = text.splitlines() + assert 'Gain' not in filter_line + assert 'Q 0.800' in filter_line + + +def test_format_disabled_band_emits_off(): + text = format_rew(0.0, [Band(id='b1', type='Peaking', freq=1000.0, gain=3.0, enabled=False)]) + assert ' OFF PK ' in text.splitlines()[1] + + +def _bands_equal(a: Band, b: Band) -> bool: + return ( + a.type == b.type + and a.freq == pytest.approx(b.freq) + and a.gain == pytest.approx(b.gain) + and a.q == pytest.approx(b.q) + and a.enabled == b.enabled + ) + + +def test_round_trip_over_bands_ignoring_ids(): + config = DSPConfig( + id='x', + preamp_db=-6.3, + bands=[ + Band(id='b1', type='Peaking', freq=1000.0, gain=-3.5, q=1.41), + Band(id='b2', type='LowShelf', freq=90.0, gain=4.0, q=0.7), + Band(id='b3', type='HighShelf', freq=8000.0, gain=6.0, q=0.71, enabled=False), + Band(id='b4', type='Lowpass', freq=12000.0, q=0.8), + Band(id='b5', type='Highpass', freq=40.0, q=0.9), + ], + ) + reparsed = parse_rew(format_rew(config.preamp_db, config.bands)).bands + assert len(reparsed) == len(config.bands) + assert all(_bands_equal(got, expected) for got, expected in zip(reparsed, config.bands)) + # Fresh ids on re-import — bands round-trip modulo id. + assert all(got.id != expected.id for got, expected in zip(reparsed, config.bands)) + + +def test_round_trip_does_not_reapply_preamp(): + # `format_rew` emits the pre-amp for fidelity, but `parse_rew` recognizes it without + # producing a band or a skip — the auto-ceiling owns pre-amp on import. + config = DSPConfig(id='x', preamp_db=-9.9, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=3.0, q=1.0)]) + text = format_rew(config.preamp_db, config.bands) + assert 'Preamp: -9.9 dB' in text + result = parse_rew(text) + assert len(result.bands) == 1 + assert result.skipped == [] diff --git a/tests/ui/test_streamer.py b/tests/ui/test_streamer.py index 4cff45d..aa59bdf 100644 --- a/tests/ui/test_streamer.py +++ b/tests/ui/test_streamer.py @@ -562,3 +562,68 @@ async def test_dsp_save_applies_and_persists(audera_home, mock_snapserver_with_c config_id = players_dal.get('abc123').dsp_id assert config_id assert len(dsp_dal.get(config_id).bands) == 2 + + +# --- REW import/export via Config ▾ (WS-7) ----------------------------------------------- + + +async def test_dsp_config_menu_exposes_import_and_export( + audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User +): + Page().load() + await user.open('/player/abc123/dsp') + assert user.find(marker='config-import').elements + assert user.find(marker='config-export').elements + + +async def test_dsp_import_appends_bands(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + Page().load() + await user.open('/player/abc123/dsp') + await user.should_see('Bands (0)') + user.find(marker='config-import').click() + rew = 'Filter 1: ON PK Fc 1000 Hz Gain -3.0 dB Q 1.41\nFilter 2: ON LS Fc 90 Hz Gain 4.0 dB Q 0.7' + with user: + user.find(kind=ui.textarea).elements.pop().value = rew + user.find(marker='config-import-run').click() + await user.should_see('Bands (2)') + await user.should_see('Imported 2 band(s)') + + +async def test_dsp_import_notifies_skipped_lines(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + Page().load() + await user.open('/player/abc123/dsp') + user.find(marker='config-import').click() + rew = 'Filter 1: ON PK Fc 1000 Hz Gain -3.0 dB Q 1.41\nFilter 2: ON NO Fc 60 Hz Gain 0 dB Q 5.0' + with user: + user.find(kind=ui.textarea).elements.pop().value = rew + user.find(marker='config-import-run').click() + await user.should_see('Bands (1)') + await user.should_see('Imported 1 band(s), skipped 1 line(s)') + + +async def test_dsp_export_renders_saved_config_text(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + _seed_linked_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + Page().load() + await user.open('/player/abc123/dsp') + user.find(marker='config-export').click() + await user.should_see('Preamp:') + await user.should_see('Filter 1:') + + +async def test_dsp_export_banner_absent_on_clean_open(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + _seed_linked_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + Page().load() + await user.open('/player/abc123/dsp') + user.find(marker='config-export').click() + await user.should_see('Filter 1:') + await user.should_not_see(marker='export-unsaved-banner') + + +async def test_dsp_export_banner_shown_after_edit(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + _seed_linked_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + Page().load() + await user.open('/player/abc123/dsp') + user.find(content='+ Add band').click() # stage an edit so staged ≠ saved + await user.should_see('Bands (2)') + user.find(marker='config-export').click() + await user.should_see(marker='export-unsaved-banner') From e55613febb803945bc22dec176a37bc432a20c0a Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Wed, 8 Jul 2026 22:24:23 -0500 Subject: [PATCH 08/18] =?UTF-8?q?feat:=20add=20named=20user=20presets,=20s?= =?UTF-8?q?ave-as=20+=20append=20via=20Presets=20=E2=96=BE=20menu=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS-8 (v2 plan) lets a user save the current EQ's bands as a named, reusable preset and append it to any player. Apply = append + clone (never replace); cloned bands get fresh uuids so audera_peq_ filter names can't collide within a config. - models/dsp — new Preset BaseModel (id, name, bands); serializes via pydantic model_dump/model_validate (no hand-written to_dict). - domains/dsp — pure clone_bands (deep copy + fresh uuids), reused by both apply and save; exported from domains/dsp/__init__.py. - dal/presets — own namespace ~/.audera/dsp/presets/{id}.json, plain json + glob: get_all_presets (missing-dir → [], skip-malformed, name-sorted), save_preset (wrapped {'preset': ...}), delete_preset. Namespace-isolated from player configs; registered in dal/__init__.py. - ui/streamer/pages — refreshable Presets ▾ menu with save-as dialog, append-on-click, and per-row delete (click.stop). Actions row stays at four buttons; DSPConfig schema and the dsp_id load path untouched. Docs: AGENTS.md notes the dsp models are pydantic BaseModel; Preset serializes via model_dump/model_validate. Tests: Preset round-trip, clone_bands independence/unique-ids, DAL round-trip + name-sort + skip-malformed + namespace isolation, and two streamer tests (seeded preset appears + appends; save-current persists + notifies). Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 2 + audera/dal/__init__.py | 4 +- audera/dal/presets.py | 66 +++++++++++++++++++++++++ audera/domains/dsp/__init__.py | 3 +- audera/domains/dsp/presets.py | 10 ++++ audera/models/dsp.py | 23 +++++++++ audera/ui/streamer/pages.py | 78 ++++++++++++++++++++++++++++-- tests/conftest.py | 1 + tests/dal/test_presets.py | 80 +++++++++++++++++++++++++++++++ tests/domains/dsp/test_presets.py | 43 ++++++++++++++++- tests/models/test_dsp.py | 26 +++++++++- tests/ui/test_streamer.py | 42 +++++++++++++++- 12 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 audera/dal/presets.py create mode 100644 tests/dal/test_presets.py diff --git a/AGENTS.md b/AGENTS.md index dc78bd5..dc89eb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,8 @@ All models are `@dataclass` with `from_dict()` / `from_config()` / `to_dict()` / `Player.group_id` and `Group.stream_id` are empty strings (not `None`) when unassigned — required by the pytensils `'str'` DTYPES constraint. +The `dsp` models (`Band`/`DSPConfig`/`Preset`) are pydantic `BaseModel` (the `@dataclass` convention has drifted here); `Preset` (`id, name, bands`) serializes via `model_dump()`/`model_validate` — no hand-written `to_dict`. + ## DAL - `players`, `groups`, `streams` use `pytensils.config.Handler` + DuckDB for bulk queries via `read_json_auto`. diff --git a/audera/dal/__init__.py b/audera/dal/__init__.py index 277d12d..f673c6d 100644 --- a/audera/dal/__init__.py +++ b/audera/dal/__init__.py @@ -1,5 +1,5 @@ """Data-access layer""" -from audera.dal import dsp, groups, players, settings, streams +from audera.dal import dsp, groups, players, presets, settings, streams -__all__ = ['players', 'groups', 'streams', 'dsp', 'settings'] +__all__ = ['players', 'groups', 'streams', 'dsp', 'presets', 'settings'] diff --git a/audera/dal/presets.py b/audera/dal/presets.py new file mode 100644 index 0000000..ba74c9b --- /dev/null +++ b/audera/dal/presets.py @@ -0,0 +1,66 @@ +"""Named user-preset configuration-layer. + +Presets live in their own namespace nested under `dsp/` (`~/.audera/dsp/presets/`), +glob-listed and wrapped `{'preset': {...}}`, so a corrupt player config can't break +the preset menu and vice-versa. Uses plain `json` + `glob` (consistent with `dsp`'s +plain-json storage; the bands dict is out of `read_json_auto` by design). +""" + +import glob +import json +import os +from typing import Union + +from audera.dal import path +from audera.models import dsp + +PATH: Union[str, os.PathLike] = os.path.join(path.HOME, 'dsp', 'presets') + + +def get_all_presets() -> list[dsp.Preset]: + """Returns every saved preset, name-sorted (case-insensitive) for a stable menu. + + Returns `[]` when the namespace directory is missing. Malformed preset files are + skipped-and-continued so a single bad file can't hide the rest. + """ + if not os.path.isdir(PATH): + return [] + presets: list[dsp.Preset] = [] + for file_path in glob.glob(os.path.join(PATH, '*.json')): + try: + with open(file_path, 'r') as f: + data = json.load(f) + presets.append(dsp.Preset.model_validate(data['preset'])) + except Exception: + continue + return sorted(presets, key=lambda preset: preset.name.lower()) + + +def save_preset(preset: dsp.Preset) -> dsp.Preset: + """Saves the preset to `~/.audera/dsp/presets/{id}.json` and returns it. + + Parameters + ---------- + preset: `audera.models.dsp.Preset` + An instance of an `audera.models.dsp.Preset` object. + """ + os.makedirs(PATH, exist_ok=True) + file_path = os.path.join(PATH, '.'.join([preset.id, 'json'])) + with open(file_path, 'w') as f: + json.dump({'preset': preset.model_dump()}, f, indent=2) + return preset + + +def delete_preset(id: str) -> None: + """Deletes the preset file if it exists. + + A preset has no inbound FK, so deleting one can never orphan a player config. + + Parameters + ---------- + id: `str` + The preset identifier. + """ + file_path = os.path.join(PATH, '.'.join([id, 'json'])) + if os.path.isfile(file_path): + os.remove(file_path) diff --git a/audera/domains/dsp/__init__.py b/audera/domains/dsp/__init__.py index 2800269..732ea30 100644 --- a/audera/domains/dsp/__init__.py +++ b/audera/domains/dsp/__init__.py @@ -9,7 +9,7 @@ from audera.domains.dsp.compiler import compile_pipeline from audera.domains.dsp.headroom import auto_preamp_db, response_curve, response_peak_db -from audera.domains.dsp.presets import loudness_preset +from audera.domains.dsp.presets import clone_bands, loudness_preset from audera.domains.dsp.rew import format_rew, parse_rew __all__ = [ @@ -17,6 +17,7 @@ 'auto_preamp_db', 'response_curve', 'response_peak_db', + 'clone_bands', 'loudness_preset', 'format_rew', 'parse_rew', diff --git a/audera/domains/dsp/presets.py b/audera/domains/dsp/presets.py index 608bb8e..031038e 100644 --- a/audera/domains/dsp/presets.py +++ b/audera/domains/dsp/presets.py @@ -24,3 +24,13 @@ def loudness_preset() -> list[Band]: Band(id=uuid.uuid4().hex, type='LowShelf', freq=90.0, gain=_LOUDNESS_LOW_BOOST, q=0.7), Band(id=uuid.uuid4().hex, type='HighShelf', freq=8000.0, gain=_LOUDNESS_HIGH_BOOST, q=0.7), ] + + +def clone_bands(bands: list[Band]) -> list[Band]: + """Returns deep copies of `bands`, each with a fresh unique id. + + Reused by both applying a saved preset (append onto the current config) and saving + one (capture the current bands). The fresh ids keep `audera_peq_` filter names + from colliding within a single compiled config. + """ + return [band.model_copy(deep=True, update={'id': uuid.uuid4().hex}) for band in bands] diff --git a/audera/models/dsp.py b/audera/models/dsp.py index 7fcecf6..deacd03 100644 --- a/audera/models/dsp.py +++ b/audera/models/dsp.py @@ -95,3 +95,26 @@ def to_dict(self) -> dict: def __repr__(self) -> str: """Returns a `DSPConfig` object as a json-formatted `str`.""" return json.dumps(self.to_dict(), indent=2) + + +class Preset(BaseModel): + """A `class` that represents a named, reusable set of parametric-EQ bands. + + A preset is its own entity (not a flavored `DSPConfig`): a display name plus a + list of bands that can be cloned and appended onto any player's configuration. + Unlike `Band`/`DSPConfig`, it serializes via pydantic directly + (`model_dump()`/`model_validate`) — there is no legacy on-disk shape to preserve. + + Attributes + ---------- + id: `str` + The preset identifier (uuid; identity + file key). + name: `str` + The display name (not identity — duplicate names are allowed). + bands: `list[Band]` + The parametric-EQ bands captured by the preset. + """ + + id: str + name: str + bands: list[Band] = Field(default_factory=list) diff --git a/audera/ui/streamer/pages.py b/audera/ui/streamer/pages.py index 695cc38..2ea942e 100644 --- a/audera/ui/streamer/pages.py +++ b/audera/ui/streamer/pages.py @@ -16,9 +16,10 @@ from audera.clients import CamillaDSPClient, SnapserverClient from audera.dal import dsp as dsp_dal from audera.dal import players as players_dal +from audera.dal import presets as presets_dal from audera.dal import settings as settings_dal -from audera.domains.dsp import auto_preamp_db, compile_pipeline, format_rew, loudness_preset, parse_rew -from audera.models.dsp import Band +from audera.domains.dsp import auto_preamp_db, clone_bands, compile_pipeline, format_rew, loudness_preset, parse_rew +from audera.models.dsp import Band, Preset from audera.models.settings import Settings from audera.ui import components, features @@ -268,6 +269,76 @@ def _apply_preset(kind: Literal['loudness', 'flat']) -> None: _band_table.refresh() _mark_changed() + def _apply_saved_preset(preset: Preset) -> None: + """Appends fresh clones of a saved preset's bands onto the staged config. + + Apply = append + clone (never replace); the clones carry fresh ids so their + `audera_peq_` filter names can't collide. Routing through `_mark_changed` + re-clamps the pre-amp and redraws the chart over the merged set — same wiring + as REW import. + """ + state['staged'].bands.extend(clone_bands(preset.bands)) + _band_table.refresh() + _mark_changed() + + def _delete_preset(preset: Preset) -> None: + presets_dal.delete_preset(preset.id) + _presets_menu.refresh() + ui.notify(f'Deleted preset "{preset.name}"', type='positive', position='top-right') + + def _open_save_preset_dialog() -> None: + """Opens a dialog to capture the current bands as a named, reusable preset.""" + with ui.dialog() as dialog, ui.card().classes('w-96'): + ui.label('Save preset').classes('font-medium text-lg mb-1') + ui.label('Capture the current bands as a reusable preset you can append to any player.').classes( + 'text-xs text-gray-500 mb-2' + ) + name_field = ( + ui.input('Preset name', placeholder='My preset') + .props('outlined dense') + .classes('w-full') + .mark('preset-save-name') + ) + + def _on_save_preset() -> None: + preset = Preset( + id=uuid.uuid4().hex, + name=(name_field.value or '').strip() or 'Untitled', + bands=clone_bands(state['staged'].bands), + ) + presets_dal.save_preset(preset) + _presets_menu.refresh() + ui.notify(f'Saved preset "{preset.name}"', type='positive', position='top-right') + dialog.close() + + with ui.row().classes('justify-between w-full mt-2'): + ui.button('Cancel', on_click=dialog.close).props('flat dense') + ui.button('Save', on_click=_on_save_preset).props('dense').classes('bg-gray-800 text-white').mark( + 'preset-save-run' + ) + + dialog.open() + + @ui.refreshable + def _presets_menu() -> None: + ui.menu_item('Loudness (seed bands)', on_click=lambda: _apply_preset('loudness')).mark('preset-loudness') + ui.menu_item('Flat / clear all bands', on_click=lambda: _apply_preset('flat')).mark('preset-flat') + saved = presets_dal.get_all_presets() + if saved: + ui.separator() + for preset in saved: + with ui.menu_item(on_click=lambda p=preset: _apply_saved_preset(p)).mark('preset-saved'): + with ui.row().classes('items-center justify-between w-full'): + ui.label(preset.name) + ( + ui.button(icon='delete', on_click=lambda p=preset: _delete_preset(p)) + .props('flat dense round size=sm') + .classes('text-gray-400') + .on('click.stop') # .stop: delete doesn't also fire append + ) + ui.separator() + ui.menu_item('Save current as preset…', on_click=_open_save_preset_dialog).mark('preset-save-as') + def _open_import_dialog() -> None: """Opens a paste-import dialog that appends REW / Equalizer APO filters as bands. @@ -432,8 +503,7 @@ def _band_table() -> None: ) with ui.button('Presets', icon='tune').props('flat dense'): with ui.menu(): - ui.menu_item('Loudness (seed bands)', on_click=lambda: _apply_preset('loudness')).mark('preset-loudness') - ui.menu_item('Flat / clear all bands', on_click=lambda: _apply_preset('flat')).mark('preset-flat') + _presets_menu() with ui.button('Config', icon='import_export').props('flat dense'): with ui.menu(): ui.menu_item('Import REW…', on_click=_open_import_dialog).mark('config-import') diff --git a/tests/conftest.py b/tests/conftest.py index 6357514..19639e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,6 +47,7 @@ def audera_home(tmp_path, monkeypatch): ('audera.dal.groups', 'groups'), ('audera.dal.streams', 'streams'), ('audera.dal.dsp', 'dsp'), + ('audera.dal.presets', 'dsp/presets'), ('audera.dal.settings', 'settings'), ]: dest = str(tmp_path / subdir) diff --git a/tests/dal/test_presets.py b/tests/dal/test_presets.py new file mode 100644 index 0000000..00e5134 --- /dev/null +++ b/tests/dal/test_presets.py @@ -0,0 +1,80 @@ +import json +import os + +import audera.dal.dsp as dsp_dal +import audera.dal.presets as presets_dal +from audera.models.dsp import Band, DSPConfig, Preset + + +def _make_preset(id='p1', name='My preset') -> Preset: + return Preset( + id=id, + name=name, + bands=[ + Band(id='b1', type='LowShelf', freq=90.0, gain=10.0, q=0.7), + Band(id='b2', type='HighShelf', freq=8000.0, gain=6.0, q=0.7), + ], + ) + + +def test_save_and_get_round_trip(audera_home): + preset = _make_preset() + presets_dal.save_preset(preset) + + result = presets_dal.get_all_presets() + assert result == [preset] + assert result[0].bands[0].type == 'LowShelf' + + +def test_delete_removes_preset(audera_home): + preset = _make_preset() + presets_dal.save_preset(preset) + presets_dal.delete_preset(preset.id) + assert presets_dal.get_all_presets() == [] + + +def test_delete_missing_is_noop(audera_home): + # No inbound FK — deleting a non-existent preset can never orphan anything. + presets_dal.delete_preset('does-not-exist') + assert presets_dal.get_all_presets() == [] + + +def test_get_all_presets_is_name_sorted(audera_home): + presets_dal.save_preset(_make_preset(id='p1', name='Zeta')) + presets_dal.save_preset(_make_preset(id='p2', name='alpha')) + presets_dal.save_preset(_make_preset(id='p3', name='Mike')) + + names = [preset.name for preset in presets_dal.get_all_presets()] + assert names == ['alpha', 'Mike', 'Zeta'] # case-insensitive + + +def test_malformed_preset_is_skipped(audera_home): + good = _make_preset(id='good', name='Good') + presets_dal.save_preset(good) + with open(os.path.join(presets_dal.PATH, 'bad.json'), 'w') as f: + f.write('{ not valid json') + + result = presets_dal.get_all_presets() + assert result == [good] # the malformed file is skipped, the good one still loads + + +def test_player_config_is_never_returned(audera_home): + # A player config written to dsp/*.json lives one directory up from dsp/presets/, + # so the preset namespace can never surface it. + dsp_dal.create(DSPConfig(id='cfg1', bands=[Band(id='b1', freq=1000.0)])) + assert presets_dal.get_all_presets() == [] + + +def test_missing_dir_returns_empty(audera_home, monkeypatch): + monkeypatch.setattr(presets_dal, 'PATH', os.path.join(str(audera_home), 'dsp', 'nonexistent')) + assert presets_dal.get_all_presets() == [] + + +def test_save_writes_wrapped_shape(audera_home): + preset = _make_preset() + presets_dal.save_preset(preset) + with open(os.path.join(presets_dal.PATH, 'p1.json'), 'r') as f: + data = json.load(f) + assert set(data.keys()) == {'preset'} + assert data['preset']['id'] == 'p1' + assert data['preset']['name'] == 'My preset' diff --git a/tests/domains/dsp/test_presets.py b/tests/domains/dsp/test_presets.py index 4adb3cf..8781b35 100644 --- a/tests/domains/dsp/test_presets.py +++ b/tests/domains/dsp/test_presets.py @@ -1,5 +1,5 @@ -from audera.domains.dsp import loudness_preset, response_peak_db -from audera.models.dsp import DSPConfig +from audera.domains.dsp import clone_bands, loudness_preset, response_peak_db +from audera.models.dsp import Band, DSPConfig def test_loudness_preset_shape(): @@ -22,3 +22,42 @@ def test_loudness_preset_ids_are_unique(): def test_loudness_preset_needs_headroom(): config = DSPConfig(id='x', bands=loudness_preset()) assert response_peak_db(config) > 0.0 + + +def _source_bands() -> list[Band]: + return [ + Band(id='b1', type='LowShelf', freq=90.0, gain=10.0, q=0.7), + Band(id='b2', type='Peaking', freq=1000.0, gain=-3.0, q=2.0, enabled=False), + ] + + +def test_clone_bands_preserves_parameters(): + source = _source_bands() + clones = clone_bands(source) + assert len(clones) == 2 + for original, clone in zip(source, clones): + assert (clone.type, clone.freq, clone.gain, clone.q, clone.enabled) == ( + original.type, + original.freq, + original.gain, + original.q, + original.enabled, + ) + + +def test_clone_bands_mints_fresh_unique_ids(): + source = _source_bands() + clones = clone_bands(source) + # Each clone gets a new id, distinct from its source... + for original, clone in zip(source, clones): + assert clone.id != original.id + # ...and mutually unique across the clones. + ids = [clone.id for clone in clones] + assert len(set(ids)) == len(ids) + + +def test_clone_bands_is_independent(): + source = _source_bands() + clones = clone_bands(source) + clones[0].gain = 99.0 + assert source[0].gain == 10.0 # mutating a clone doesn't touch the source diff --git a/tests/models/test_dsp.py b/tests/models/test_dsp.py index 56e728f..f3d3e42 100644 --- a/tests/models/test_dsp.py +++ b/tests/models/test_dsp.py @@ -1,4 +1,4 @@ -from audera.models.dsp import Band, DSPConfig +from audera.models.dsp import Band, DSPConfig, Preset def test_band_defaults(): @@ -75,3 +75,27 @@ def test_dsp_config_id_is_identity(): b = DSPConfig(id='same', preamp_db=-3.0) assert a == b assert a != DSPConfig(id='other', preamp_db=-3.0) + + +def test_preset_defaults(): + preset = Preset(id='p1', name='My preset') + assert preset.bands == [] + + +def test_preset_round_trip(): + preset = Preset( + id='p1', + name='Loudness', + bands=[ + Band(id='b1', type='LowShelf', freq=90.0, gain=10.0, q=0.7), + Band(id='b2', type='HighShelf', freq=8000.0, gain=6.0, q=0.7), + ], + ) + result = Preset.model_validate(preset.model_dump()) + assert result == preset + assert result.id == 'p1' + assert result.name == 'Loudness' + # Bands reconstruct as real `Band`s, not raw dicts. + assert all(isinstance(band, Band) for band in result.bands) + assert result.bands[0].type == 'LowShelf' + assert result.bands[1].freq == 8000.0 diff --git a/tests/ui/test_streamer.py b/tests/ui/test_streamer.py index aa59bdf..55cb241 100644 --- a/tests/ui/test_streamer.py +++ b/tests/ui/test_streamer.py @@ -11,8 +11,9 @@ from audera.clients import CamillaDSPClient, SnapserverClient from audera.dal import dsp as dsp_dal from audera.dal import players as players_dal +from audera.dal import presets as presets_dal from audera.dal import settings as settings_dal -from audera.models.dsp import Band, DSPConfig +from audera.models.dsp import Band, DSPConfig, Preset from audera.models.player import Player from audera.models.settings import Settings from audera.ui import components, features @@ -627,3 +628,42 @@ async def test_dsp_export_banner_shown_after_edit(audera_home, mock_snapserver_w await user.should_see('Bands (2)') user.find(marker='config-export').click() await user.should_see(marker='export-unsaved-banner') + + +# --- Named user presets (WS-8) ----------------------------------------------------------- + + +async def test_dsp_saved_preset_appears_and_appends(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + presets_dal.save_preset( + Preset( + id='p1', + name='Bass Boost', + bands=[ + Band(id='b1', type='LowShelf', freq=90.0, gain=6.0, q=0.7), + Band(id='b2', type='Peaking', freq=1000.0, gain=-3.0, q=2.0), + ], + ) + ) + Page().load() + await user.open('/player/abc123/dsp') + await user.should_see('Bands (0)') + await user.should_see('Bass Boost') # the saved preset lists in the menu by name + user.find(marker='preset-saved').click() + await user.should_see('Bands (2)') # apply = append cloned bands + + +async def test_dsp_save_current_as_preset_persists(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + Page().load() + await user.open('/player/abc123/dsp') + user.find(marker='preset-loudness').click() # stage two bands to capture + await user.should_see('Bands (2)') + user.find(marker='preset-save-as').click() + with user: + user.find(kind=ui.input).elements.pop().value = 'My Loudness' # the dialog's only input + user.find(marker='preset-save-run').click() + await user.should_see('Saved preset') + + saved = presets_dal.get_all_presets() + assert len(saved) == 1 + assert saved[0].name == 'My Loudness' + assert len(saved[0].bands) == 2 From a241cc92ca03bfeeca0f0dcb4844d4ba63a1456b Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Wed, 8 Jul 2026 22:46:58 -0500 Subject: [PATCH 09/18] refactor: key DSP config by Snapcast id, drop dsp_id FK, retire players DAL (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player is now a live-only DTO. A player's DSPConfig is keyed by the player's own Snapcast id (dsp/{player_id}.json) — the filename is the link — so the dsp_id FK, the write-once-never-read player shadow file, the page-load recovery hack, and the entire dal/players.py module (FK + three unused DuckDB queries) are removed. resolve_for_player reduces to a keyed get-or-create; the page resolves straight from the live player id. Co-Authored-By: Claude Opus 4.8 --- audera/dal/__init__.py | 4 +- audera/dal/dsp.py | 18 ++--- audera/dal/players.py | 149 ------------------------------------ audera/models/player.py | 29 +------ audera/ui/streamer/pages.py | 14 ++-- tests/conftest.py | 1 - tests/dal/test_dsp.py | 46 +++++++---- tests/dal/test_players.py | 146 ----------------------------------- tests/models/test_player.py | 41 ++++------ tests/ui/test_streamer.py | 31 ++++---- 10 files changed, 75 insertions(+), 404 deletions(-) delete mode 100644 audera/dal/players.py delete mode 100644 tests/dal/test_players.py diff --git a/audera/dal/__init__.py b/audera/dal/__init__.py index f673c6d..1fc7a8e 100644 --- a/audera/dal/__init__.py +++ b/audera/dal/__init__.py @@ -1,5 +1,5 @@ """Data-access layer""" -from audera.dal import dsp, groups, players, presets, settings, streams +from audera.dal import dsp, groups, presets, settings, streams -__all__ = ['players', 'groups', 'streams', 'dsp', 'presets', 'settings'] +__all__ = ['groups', 'streams', 'dsp', 'presets', 'settings'] diff --git a/audera/dal/dsp.py b/audera/dal/dsp.py index 4d75f2f..82ee6cc 100644 --- a/audera/dal/dsp.py +++ b/audera/dal/dsp.py @@ -2,10 +2,9 @@ import json import os -import uuid from typing import Union -from audera.dal import path, players +from audera.dal import path from audera.models import dsp, player PATH: Union[str, os.PathLike] = os.path.join(path.HOME, 'dsp') @@ -62,23 +61,18 @@ def get_or_create(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: def resolve_for_player(player_: player.Player) -> dsp.DSPConfig: - """Returns the player's editable `DSPConfig`, minting and linking one if unassigned. + """Returns the player's editable `DSPConfig`, keyed by the player's own Snapcast id. - If `player_.dsp_id` is set, the referenced config is returned. Otherwise a fresh, - empty `DSPConfig` is minted, saved, and linked to the player (via `players.update` - persisting the `dsp_id`). This is a one-time create-and-link — it reads no legacy - file. + The config file *is* the link: it lives at `dsp/{player_.id}.json`, so no FK or player + shadow file is needed. A fresh empty config is created on first open and re-read on + every open thereafter. Parameters ---------- player_: `audera.models.player.Player` An instance of an `audera.models.player.Player` object. """ - if player_.dsp_id: - return get(player_.dsp_id) - dsp_config = save(dsp.DSPConfig(id=uuid.uuid4().hex)) - players.update(player_.model_copy(update={'dsp_id': dsp_config.id})) - return dsp_config + return get_or_create(dsp.DSPConfig(id=player_.id)) def save(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: diff --git a/audera/dal/players.py b/audera/dal/players.py deleted file mode 100644 index 81016bc..0000000 --- a/audera/dal/players.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Player configuration-layer""" - -import json -import os -from typing import List, Optional, Union - -import duckdb - -from audera.dal import path -from audera.models import player - -PATH: Union[str, os.PathLike] = os.path.join(path.HOME, 'players') - - -def exists(id: str) -> bool: - """Returns `True` when the player configuration file exists. - - Parameters - ---------- - id: `str` - The Snapcast client identifier. - """ - return os.path.isfile(os.path.abspath(os.path.join(PATH, '.'.join([id, 'json'])))) - - -def create(player_: player.Player) -> player.Player: - """Creates the player configuration file and returns the `Player` object. - - Parameters - ---------- - player_: `audera.models.player.Player` - An instance of an `audera.models.player.Player` object. - """ - return save(player_) - - -def get(id: str) -> player.Player: - """Returns the player configuration as a `Player` object. - - Parameters - ---------- - id: `str` - The Snapcast client identifier. - """ - file_path = os.path.join(PATH, '.'.join([id, 'json'])) - with open(file_path, 'r') as f: - data = json.load(f) - return player.Player.from_dict(data['player']) - - -def get_or_create(player_: player.Player) -> player.Player: - """Creates or reads the player configuration file and returns the `Player` object. - - Parameters - ---------- - player_: `audera.models.player.Player` - An instance of an `audera.models.player.Player` object. - """ - if exists(player_.id): - return get(player_.id) - else: - return create(player_) - - -def save(player_: player.Player) -> player.Player: - """Saves the player configuration to `~/.audera/players/{player_.id}.json`. - - Parameters - ---------- - player_: `audera.models.player.Player` - An instance of an `audera.models.player.Player` object. - """ - if not os.path.isdir(PATH): - os.makedirs(PATH) - file_path = os.path.join(PATH, '.'.join([player_.id, 'json'])) - with open(file_path, 'w') as f: - json.dump({'player': player_.to_dict()}, f, indent=2) - return player_ - - -def update(new: player.Player) -> player.Player: - """Updates the player configuration file `~/.audera/players/{player_.id}.json`. - - Parameters - ---------- - new: `audera.models.player.Player` - An instance of an `audera.models.player.Player` object. - """ - existing = get_or_create(new) - if not existing == new: - return save(new) - else: - return existing - - -def delete(id: str): - """Deletes the configuration file for a player. - - Parameters - ---------- - id: `str` - The Snapcast client identifier. - """ - if exists(id): - os.remove(os.path.join(PATH, '.'.join([id, 'json']))) - - -def connection() -> duckdb.DuckDBPyConnection: - return duckdb.connect().execute( - 'CREATE TABLE players AS SELECT player.* FROM read_json_auto(?)', (os.path.join(PATH, '*.json'),) - ) - - -def query_to_players(cursor: duckdb.DuckDBPyConnection) -> List[player.Player]: - columns = [desc[0] for desc in cursor.description] - return [player.Player.from_dict(dict(zip(columns, row))) for row in cursor.fetchall()] - - -def get_player_by_host(host: str) -> Optional[player.Player]: - """Returns the player with the given host address. - - Parameters - ---------- - host: `str` - The ip-address of the Snapcast client. - """ - try: - with connection() as conn: - return query_to_players(conn.execute("SELECT * FROM players WHERE host = '%s'" % str(host)))[0] - except (duckdb.IOException, IndexError): - return None - - -def get_all_players() -> List[player.Player]: - """Returns all players as a list of `audera.models.player.Player` objects.""" - try: - with connection() as conn: - return query_to_players(conn.execute('SELECT * FROM players')) - except duckdb.IOException: - return [] - - -def get_all_connected_players() -> List[player.Player]: - """Returns all connected players as a list of `audera.models.player.Player` objects.""" - try: - with connection() as conn: - return query_to_players(conn.execute('SELECT * FROM players WHERE connected = True')) - except duckdb.IOException: - return [] diff --git a/audera/models/player.py b/audera/models/player.py index 4c5c2fe..24f8332 100644 --- a/audera/models/player.py +++ b/audera/models/player.py @@ -5,7 +5,7 @@ import json from typing import List -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field class Player(BaseModel): @@ -27,8 +27,6 @@ class Player(BaseModel): Whether the client is muted. group_id: `str` The identifier of the Snapcast group this client belongs to. - dsp_id: `str` - The identifier of the `DSPConfig` this client references; '' = unassigned. name: `str` The display name from Snapcast responses; not persisted. """ @@ -40,38 +38,14 @@ class Player(BaseModel): volume: int = 100 muted: bool = False group_id: str = '' - dsp_id: str = '' name: str = Field(default='', exclude=True) latency_ms: int = Field(default=0, ge=-500, le=500, exclude=True) - @field_validator('dsp_id', mode='before') - @classmethod - def _coerce_dsp_id(cls, v) -> str: - """Coerces a NULL `dsp_id` (from `read_json_auto` on old files) to ''.""" - return '' if v is None else v - @classmethod def from_dict(cls, dict_object: dict) -> 'Player': """Returns a `Player` object from a `dict`.""" return cls.model_validate(dict_object) - def to_dict(self) -> dict: - """Returns a `Player` object as a `dict`.""" - return { - 'id': self.id, - 'host': self.host, - 'port': self.port, - 'connected': self.connected, - 'volume': self.volume, - 'muted': self.muted, - 'group_id': self.group_id, - 'dsp_id': self.dsp_id, - } - - def __repr__(self) -> str: - """Returns a `Player` object as a json-formatted `str`.""" - return json.dumps(self.to_dict(), indent=2) - def __eq__(self, compare) -> bool: """Returns `True` when compare is an instance of self, excluding `name`.""" if isinstance(compare, Player): @@ -84,7 +58,6 @@ def __eq__(self, compare) -> bool: and self.muted == compare.muted and self.group_id == compare.group_id and self.latency_ms == compare.latency_ms - and self.dsp_id == compare.dsp_id ) return False diff --git a/audera/ui/streamer/pages.py b/audera/ui/streamer/pages.py index 2ea942e..773ee65 100644 --- a/audera/ui/streamer/pages.py +++ b/audera/ui/streamer/pages.py @@ -15,7 +15,6 @@ import audera from audera.clients import CamillaDSPClient, SnapserverClient from audera.dal import dsp as dsp_dal -from audera.dal import players as players_dal from audera.dal import presets as presets_dal from audera.dal import settings as settings_dal from audera.domains.dsp import auto_preamp_db, clone_bands, compile_pipeline, format_rew, loudness_preset, parse_rew @@ -190,11 +189,9 @@ def dsp(self, player_id: str) -> None: ui.label('Player not found or unreachable.').classes('text-gray-500') return - # `SnapserverClient.get_clients` always reports `dsp_id=''` (it has no view of the - # persisted FK), so recover the link from the players DAL before resolving — - # otherwise `resolve_for_player` would re-mint an orphan config on every open. - persisted = players_dal.get(player_id) if players_dal.exists(player_id) else live - saved = dsp_dal.resolve_for_player(persisted) + # The config is keyed by the player's own id (`dsp/{id}.json` is the link), so + # `live.id` is all that's needed to resolve it. + saved = dsp_dal.resolve_for_player(live) # Clamp the baseline once so an over-hot legacy config opens clean, not falsely dirty. saved.preamp_db = min(saved.preamp_db, auto_preamp_db(saved.bands)) state = {'saved': saved, 'staged': saved.model_copy(deep=True)} @@ -419,9 +416,8 @@ async def _on_save() -> None: """Compiles → validates → pushes the live pipeline, then persists the config. Pattern B (live apply, no restart): the daemon owns volume and `SetConfigJson` - leaves the fader untouched, so no volume snapshot/restore is needed. The - `dsp_id` FK was already persisted by `resolve_for_player` at page load, so Save - only updates the config file. + leaves the fader untouched, so no volume snapshot/restore is needed. The config + is keyed by the player id (`dsp/{id}.json`), so Save only updates that one file. """ camilla = _camilladsp(live.host) try: diff --git a/tests/conftest.py b/tests/conftest.py index 19639e1..ab94944 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,7 +43,6 @@ def _wait_for_client(host: str, port: int, timeout: float = 90) -> None: @pytest.fixture def audera_home(tmp_path, monkeypatch): for module, subdir in [ - ('audera.dal.players', 'players'), ('audera.dal.groups', 'groups'), ('audera.dal.streams', 'streams'), ('audera.dal.dsp', 'dsp'), diff --git a/tests/dal/test_dsp.py b/tests/dal/test_dsp.py index c03ab4b..1bb7255 100644 --- a/tests/dal/test_dsp.py +++ b/tests/dal/test_dsp.py @@ -1,6 +1,6 @@ import audera.dal.dsp as dsp_dal -import audera.dal.players as players_dal -from audera.models.dsp import Band, DSPConfig +import audera.dal.presets as presets_dal +from audera.models.dsp import Band, DSPConfig, Preset from audera.models.player import Player @@ -13,8 +13,8 @@ def _make_dsp(id='dsp-1') -> DSPConfig: ) -def _make_player(id='abc123', dsp_id='') -> Player: - return Player(id=id, host='192.168.1.50', port=1704, connected=True, dsp_id=dsp_id) +def _make_player(id='abc123') -> Player: + return Player(id=id, host='192.168.1.50', port=1704, connected=True) def test_dsp_create(audera_home): @@ -82,27 +82,43 @@ def test_dsp_get_or_create_reads(audera_home): assert result == config -def test_resolve_for_player_mints_and_links(audera_home): - player = _make_player(dsp_id='') - players_dal.create(player) +def test_resolve_for_player_creates_keyed_by_player_id(audera_home): + player = _make_player() config = dsp_dal.resolve_for_player(player) - # The minted config is saved, keyed by its own id - assert dsp_dal.exists(config.id) + # The config is created keyed by the player's own id — the filename is the link. + assert config.id == player.id + assert dsp_dal.exists(player.id) assert config.bands == [] assert config.preamp_db == 0.0 - # The player is linked (dsp_id persisted) and the returned config re-reads - assert players_dal.get(player.id).dsp_id == config.id - assert dsp_dal.get(config.id) == config + +def test_resolve_for_player_idempotent_after_edit(audera_home): + player = _make_player() + dsp_dal.resolve_for_player(player) # first open creates an empty config + + edited = _make_dsp(id=player.id) + dsp_dal.update(edited) + + # A second open re-reads the edited config — it never re-mints an empty one. + assert dsp_dal.resolve_for_player(player) == edited def test_resolve_for_player_returns_existing(audera_home): - existing = _make_dsp(id='dsp-existing') + existing = _make_dsp(id='abc123') dsp_dal.create(existing) - player = _make_player(dsp_id='dsp-existing') - players_dal.create(player) + player = _make_player(id='abc123') config = dsp_dal.resolve_for_player(player) assert config == existing + + +def test_resolve_for_player_ignores_preset_namespace(audera_home): + # A preset keyed with the same id lives under `dsp/presets/`, not `dsp/`. + presets_dal.save_preset(Preset(id='abc123', name='Bass', bands=[Band(id='b1', type='LowShelf', freq=90.0, gain=6.0)])) + + config = dsp_dal.resolve_for_player(_make_player(id='abc123')) + + # The preset is never returned — the player config is a fresh, empty one. + assert config.bands == [] diff --git a/tests/dal/test_players.py b/tests/dal/test_players.py deleted file mode 100644 index 54575fe..0000000 --- a/tests/dal/test_players.py +++ /dev/null @@ -1,146 +0,0 @@ -import json -import os - -import audera.dal.players as players -from audera.models.player import Player - - -def _write_player_file(id: str, data: dict) -> None: - """Writes a hand-crafted player config file directly to the players DAL path.""" - os.makedirs(players.PATH, exist_ok=True) - with open(os.path.join(players.PATH, f'{id}.json'), 'w') as f: - json.dump({'player': data}, f) - - -def _make_player(id='abc123', host='192.168.1.50', connected=True) -> Player: - return Player( - id=id, - host=host, - port=1704, - connected=connected, - volume=80, - muted=False, - group_id='', - ) - - -def test_player_create(audera_home): - player = _make_player() - players.create(player) - assert players.exists(player.id) - - -def test_player_get(audera_home): - player = _make_player() - players.create(player) - - result = players.get(player.id) - assert result == player - - -def test_player_update(audera_home): - player = _make_player() - players.create(player) - - updated = Player( - id=player.id, - host=player.host, - port=player.port, - connected=False, - volume=50, - muted=True, - group_id='group-1', - ) - players.update(updated) - - result = players.get(player.id) - assert result.volume == 50 - assert result.muted is True - assert result.group_id == 'group-1' - - -def test_player_delete(audera_home): - player = _make_player() - players.create(player) - players.delete(player.id) - assert not players.exists(player.id) - - -def test_get_all_players(audera_home): - p1 = _make_player(id='p1', host='192.168.1.1') - p2 = _make_player(id='p2', host='192.168.1.2') - players.create(p1) - players.create(p2) - - result = players.get_all_players() - ids = {p.id for p in result} - assert ids == {'p1', 'p2'} - - -def test_get_all_players_empty(audera_home): - result = players.get_all_players() - assert result == [] - - -def test_get_player_by_host(audera_home): - player = _make_player(id='find-me', host='10.0.0.5') - players.create(player) - - result = players.get_player_by_host('10.0.0.5') - assert result is not None - assert result.id == 'find-me' - - -def test_get_player_by_host_missing(audera_home): - result = players.get_player_by_host('10.0.0.99') - assert result is None - - -def test_get_all_connected_players(audera_home): - p_on = _make_player(id='on', host='192.168.1.10', connected=True) - p_off = _make_player(id='off', host='192.168.1.11', connected=False) - players.create(p_on) - players.create(p_off) - - result = players.get_all_connected_players() - assert len(result) == 1 - assert result[0].id == 'on' - - -def test_get_all_players_unions_mixed_old_and_new_files(audera_home): - """Guards the DuckDB read_json_auto schema-union path across mixed old/new files. - - An old player file predates the `dsp_id` column; a new one carries it. Reading both - back must not raise, and the old row's `dsp_id` must surface as '' (coerced from the - NULL DuckDB fills in for the missing column). - """ - _write_player_file( - 'old', - { - 'id': 'old', - 'host': '192.168.1.1', - 'port': 1704, - 'connected': True, - 'volume': 80, - 'muted': False, - 'group_id': '', - }, - ) - _write_player_file( - 'new', - { - 'id': 'new', - 'host': '192.168.1.2', - 'port': 1704, - 'connected': True, - 'volume': 80, - 'muted': False, - 'group_id': '', - 'dsp_id': 'dsp-1', - }, - ) - - result = {p.id: p for p in players.get_all_players()} - assert set(result) == {'old', 'new'} - assert result['old'].dsp_id == '' - assert result['new'].dsp_id == 'dsp-1' diff --git a/tests/models/test_player.py b/tests/models/test_player.py index 55998e9..2df5d5a 100644 --- a/tests/models/test_player.py +++ b/tests/models/test_player.py @@ -1,28 +1,19 @@ from audera.models.player import Player -def _make_player(dsp_id: str = '') -> Player: - return Player(id='abc123', host='192.168.1.50', port=1704, dsp_id=dsp_id) - - -def test_dsp_id_defaults_to_empty_string(): - player = _make_player() - assert player.dsp_id == '' - - -def test_dsp_id_round_trips_through_to_dict(): - player = _make_player(dsp_id='dsp-1') - assert player.to_dict()['dsp_id'] == 'dsp-1' - assert Player.from_dict(player.to_dict()) == player - - -def test_dsp_id_participates_in_eq(): - a = _make_player(dsp_id='dsp-1') - b = _make_player(dsp_id='dsp-2') - assert a != b - assert a == _make_player(dsp_id='dsp-1') - - -def test_dsp_id_none_coerces_to_empty_string(): - player = Player.from_dict({'id': 'abc123', 'host': '192.168.1.50', 'port': 1704, 'dsp_id': None}) - assert player.dsp_id == '' +def test_player_is_live_only_dto_without_dsp_id(): + # `Player` is a pure live DTO built from a `get_clients()`-shaped payload; it carries + # no persisted `dsp_id` FK, and pydantic ignores any unknown keys in the payload. + player = Player.from_dict( + { + 'id': 'abc123', + 'host': '192.168.1.50', + 'port': 1704, + 'connected': True, + 'volume': 80, + 'muted': False, + 'group_id': '', + 'dsp_id': 'stale', + } + ) + assert not hasattr(player, 'dsp_id') diff --git a/tests/ui/test_streamer.py b/tests/ui/test_streamer.py index 55cb241..b4e5e94 100644 --- a/tests/ui/test_streamer.py +++ b/tests/ui/test_streamer.py @@ -10,7 +10,6 @@ import audera.ui.streamer.pages as streamer_pages from audera.clients import CamillaDSPClient, SnapserverClient from audera.dal import dsp as dsp_dal -from audera.dal import players as players_dal from audera.dal import presets as presets_dal from audera.dal import settings as settings_dal from audera.models.dsp import Band, DSPConfig, Preset @@ -473,14 +472,13 @@ async def test_dsp_band_count_reflects_actions(audera_home, mock_snapserver_with await user.should_see(expected) -def _seed_linked_dsp(config: DSPConfig) -> None: - """Persists a DSP config and links player 'abc123' to it (the page's load path). +def _seed_dsp(config: DSPConfig) -> None: + """Persists a DSP config keyed by player 'abc123' (the page's load path). - The editor recovers the config via `players_dal` → `resolve_for_player`, so a linked - player record is required for the seeded config to open instead of a fresh empty one. + The config is keyed by the player id (`dsp/abc123.json` is the link), so persisting it + under that key is all `resolve_for_player` needs to open it instead of a fresh empty one. """ - dsp_dal.create(config) - players_dal.create(Player(id='abc123', host='192.168.1.50', port=1704, connected=True, dsp_id=config.id)) + dsp_dal.save(config.model_copy(update={'id': 'abc123'})) async def test_dsp_bandless_shows_chart_message_and_hides_chart( @@ -504,7 +502,7 @@ async def test_dsp_preamp_clamp_keeps_below_ceiling_value( audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User ): # A +6 dB boost sets the ceiling at ~-6 dB; -10 is below it, so the clamp leaves it. - _seed_linked_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + _seed_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') with user: @@ -518,7 +516,7 @@ async def test_dsp_preamp_clamp_pulls_above_ceiling_value_down( ): # Raising the pre-amp to -2 dB over a +6 dB boost would clip, so the clamp snaps it back # down to the ~-6 dB clip-safe ceiling. - _seed_linked_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + _seed_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') with user: @@ -530,7 +528,7 @@ async def test_dsp_preamp_clamp_pulls_above_ceiling_value_down( async def test_dsp_over_hot_saved_config_opens_clean(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): # Saved with a hot 0 dB pre-amp above the +6 dB boost ceiling; the load-time baseline # clamp pulls it down so the editor opens without a false "Unsaved changes" flag. - _seed_linked_dsp(DSPConfig(id='cfg1', preamp_db=0.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + _seed_dsp(DSPConfig(id='cfg1', preamp_db=0.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') await user.should_see(kind=ui.echart) @@ -559,10 +557,9 @@ async def test_dsp_save_applies_and_persists(audera_home, mock_snapserver_with_c assert any(name.startswith('audera_peq_') for name in compiled['filters']) assert 'reset_clipped_samples' in mock_camilladsp_dsp - # The config is persisted with the two bands and the player is linked to it. - config_id = players_dal.get('abc123').dsp_id - assert config_id - assert len(dsp_dal.get(config_id).bands) == 2 + # The config is persisted keyed by the player id, carrying the two bands. + assert dsp_dal.exists('abc123') + assert len(dsp_dal.get('abc123').bands) == 2 # --- REW import/export via Config ▾ (WS-7) ----------------------------------------------- @@ -603,7 +600,7 @@ async def test_dsp_import_notifies_skipped_lines(audera_home, mock_snapserver_wi async def test_dsp_export_renders_saved_config_text(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): - _seed_linked_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + _seed_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') user.find(marker='config-export').click() @@ -612,7 +609,7 @@ async def test_dsp_export_renders_saved_config_text(audera_home, mock_snapserver async def test_dsp_export_banner_absent_on_clean_open(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): - _seed_linked_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + _seed_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') user.find(marker='config-export').click() @@ -621,7 +618,7 @@ async def test_dsp_export_banner_absent_on_clean_open(audera_home, mock_snapserv async def test_dsp_export_banner_shown_after_edit(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): - _seed_linked_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + _seed_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') user.find(content='+ Add band').click() # stage an edit so staged ≠ saved From cca23e3dee3c46fc1e219326f64823d7c6b33adb Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Wed, 8 Jul 2026 22:56:15 -0500 Subject: [PATCH 10/18] refactor: retire dead groups/streams DALs, re-home model round-trips (#50) Delete the unused groups/streams persistence (zero production callers) and re-home the Group/Stream to_dict/from_dict round-trips under tests/models/, preserving the only model coverage the DAL tests held. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 5 +- audera/dal/__init__.py | 4 +- audera/dal/groups.py | 176 ------------------------------------ audera/dal/streams.py | 125 ------------------------- tests/conftest.py | 2 - tests/dal/test_groups.py | 109 ---------------------- tests/dal/test_streams.py | 83 ----------------- tests/models/test_player.py | 16 +++- tests/models/test_stream.py | 17 ++++ 9 files changed, 36 insertions(+), 501 deletions(-) delete mode 100644 audera/dal/groups.py delete mode 100644 audera/dal/streams.py delete mode 100644 tests/dal/test_groups.py delete mode 100644 tests/dal/test_streams.py create mode 100644 tests/models/test_stream.py diff --git a/AGENTS.md b/AGENTS.md index dc89eb4..8a445a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,9 +50,8 @@ The `dsp` models (`Band`/`DSPConfig`/`Preset`) are pydantic `BaseModel` (the `@d ## DAL -- `players`, `groups`, `streams` use `pytensils.config.Handler` + DuckDB for bulk queries via `read_json_auto`. -- `dsp` uses plain `json` — the pipeline dict is too complex for DTYPES validation. -- Config files: `~/.audera/{players,groups,streams,dsp}/{id}.json` +- `dsp`, `presets`, and `settings` persist via plain `json` — the pipeline/bands dicts are too complex for DTYPES validation. +- Config files: `~/.audera/{dsp,dsp/presets,settings}/{id}.json` ## Code style diff --git a/audera/dal/__init__.py b/audera/dal/__init__.py index 1fc7a8e..4172f59 100644 --- a/audera/dal/__init__.py +++ b/audera/dal/__init__.py @@ -1,5 +1,5 @@ """Data-access layer""" -from audera.dal import dsp, groups, presets, settings, streams +from audera.dal import dsp, presets, settings -__all__ = ['groups', 'streams', 'dsp', 'presets', 'settings'] +__all__ = ['dsp', 'presets', 'settings'] diff --git a/audera/dal/groups.py b/audera/dal/groups.py deleted file mode 100644 index 3784db2..0000000 --- a/audera/dal/groups.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Group configuration-layer""" - -import json -import os -from typing import List, Union - -import duckdb - -from audera.dal import path -from audera.models import player - -PATH: Union[str, os.PathLike] = os.path.join(path.HOME, 'groups') - - -def exists(id: str) -> bool: - """Returns `True` when the group configuration file exists. - - Parameters - ---------- - id: `str` - The Snapcast group identifier. - """ - return os.path.isfile(os.path.abspath(os.path.join(PATH, '.'.join([id, 'json'])))) - - -def create(group: player.Group) -> player.Group: - """Creates the group configuration file and returns the `Group` object. - - Parameters - ---------- - group: `audera.models.player.Group` - An instance of an `audera.models.player.Group` object. - """ - return save(group) - - -def get(id: str) -> player.Group: - """Returns the group configuration as a `Group` object. - - Parameters - ---------- - id: `str` - The Snapcast group identifier. - """ - file_path = os.path.join(PATH, '.'.join([id, 'json'])) - with open(file_path, 'r') as f: - data = json.load(f) - return player.Group.from_dict(data['group']) - - -def get_or_create(group: player.Group) -> player.Group: - """Creates or reads the group configuration file and returns the `Group` object. - - Parameters - ---------- - group: `audera.models.player.Group` - An instance of an `audera.models.player.Group` object. - """ - if exists(group.id): - return get(group.id) - else: - return create(group) - - -def save(group: player.Group) -> player.Group: - """Saves the group configuration to `~/.audera/groups/{group.id}.json`. - - Parameters - ---------- - group: `audera.models.player.Group` - An instance of an `audera.models.player.Group` object. - """ - if not os.path.isdir(PATH): - os.makedirs(PATH) - file_path = os.path.join(PATH, '.'.join([group.id, 'json'])) - with open(file_path, 'w') as f: - json.dump({'group': group.to_dict()}, f, indent=2) - return group - - -def update(new: player.Group) -> player.Group: - """Updates the group configuration file `~/.audera/groups/{group.id}.json`. - - Parameters - ---------- - new: `audera.models.player.Group` - An instance of an `audera.models.player.Group` object. - """ - existing = get_or_create(new) - if not existing == new: - return save(new) - else: - return existing - - -def delete(id: str): - """Deletes the configuration file for a group. - - Parameters - ---------- - id: `str` - The Snapcast group identifier. - """ - if exists(id): - os.remove(os.path.join(PATH, '.'.join([id, 'json']))) - - -def attach_client(group_id: str, client_id: str) -> player.Group: - """Attaches a Snapcast client to a group. - - Parameters - ---------- - group_id: `str` - The Snapcast group identifier. - client_id: `str` - The Snapcast client identifier. - """ - group = get(group_id) - if client_id in group.client_ids: - return group - group.client_ids.append(client_id) - return update(group) - - -def detach_client(group_id: str, client_id: str) -> player.Group: - """Detaches a Snapcast client from a group. - - Parameters - ---------- - group_id: `str` - The Snapcast group identifier. - client_id: `str` - The Snapcast client identifier. - """ - group = get(group_id) - if client_id not in group.client_ids: - return group - group.client_ids.remove(client_id) - return update(group) - - -def assign_stream(group_id: str, stream_id: str) -> player.Group: - """Assigns a stream to a group. - - Parameters - ---------- - group_id: `str` - The Snapcast group identifier. - stream_id: `str` - The Snapcast stream identifier. - """ - group = get(group_id) - if group.stream_id == stream_id: - return group - group.stream_id = stream_id - return update(group) - - -def connection() -> duckdb.DuckDBPyConnection: - return duckdb.connect().execute( - 'CREATE TABLE groups AS SELECT "group".* FROM read_json_auto(?)', (os.path.join(PATH, '*.json'),) - ) - - -def query_to_groups(cursor: duckdb.DuckDBPyConnection) -> List[player.Group]: - columns = [desc[0] for desc in cursor.description] - return [player.Group.from_dict(dict(zip(columns, row))) for row in cursor.fetchall()] - - -def get_all_groups() -> List[player.Group]: - """Returns all groups as a list of `audera.models.player.Group` objects.""" - try: - with connection() as conn: - return query_to_groups(conn.execute('SELECT * FROM groups')) - except duckdb.IOException: - return [] diff --git a/audera/dal/streams.py b/audera/dal/streams.py deleted file mode 100644 index 2bd9a1e..0000000 --- a/audera/dal/streams.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Stream configuration-layer""" - -import json -import os -from typing import List, Union - -import duckdb - -from audera.dal import path -from audera.models import stream - -PATH: Union[str, os.PathLike] = os.path.join(path.HOME, 'streams') - - -def exists(id: str) -> bool: - """Returns `True` when the stream configuration file exists. - - Parameters - ---------- - id: `str` - The stream identifier. - """ - return os.path.isfile(os.path.abspath(os.path.join(PATH, '.'.join([id, 'json'])))) - - -def create(stream_: stream.Stream) -> stream.Stream: - """Creates the stream configuration file and returns the `Stream` object. - - Parameters - ---------- - stream_: `audera.models.stream.Stream` - An instance of an `audera.models.stream.Stream` object. - """ - return save(stream_) - - -def get(id: str) -> stream.Stream: - """Returns the stream configuration as a `Stream` object. - - Parameters - ---------- - id: `str` - The stream identifier. - """ - file_path = os.path.join(PATH, '.'.join([id, 'json'])) - with open(file_path, 'r') as f: - data = json.load(f) - return stream.Stream.from_dict(data['stream']) - - -def get_or_create(stream_: stream.Stream) -> stream.Stream: - """Creates or reads the stream configuration file and returns the `Stream` object. - - Parameters - ---------- - stream_: `audera.models.stream.Stream` - An instance of an `audera.models.stream.Stream` object. - """ - if exists(stream_.id): - return get(stream_.id) - else: - return create(stream_) - - -def save(stream_: stream.Stream) -> stream.Stream: - """Saves the stream configuration to `~/.audera/streams/{stream_.id}.json`. - - Parameters - ---------- - stream_: `audera.models.stream.Stream` - An instance of an `audera.models.stream.Stream` object. - """ - if not os.path.isdir(PATH): - os.makedirs(PATH) - file_path = os.path.join(PATH, '.'.join([stream_.id, 'json'])) - with open(file_path, 'w') as f: - json.dump({'stream': stream_.to_dict()}, f, indent=2) - return stream_ - - -def update(new: stream.Stream) -> stream.Stream: - """Updates the stream configuration file `~/.audera/streams/{stream_.id}.json`. - - Parameters - ---------- - new: `audera.models.stream.Stream` - An instance of an `audera.models.stream.Stream` object. - """ - existing = get_or_create(new) - if not existing == new: - return save(new) - else: - return existing - - -def delete(id: str): - """Deletes the configuration file for a stream. - - Parameters - ---------- - id: `str` - The stream identifier. - """ - if exists(id): - os.remove(os.path.join(PATH, '.'.join([id, 'json']))) - - -def connection() -> duckdb.DuckDBPyConnection: - return duckdb.connect().execute( - 'CREATE TABLE streams AS SELECT "stream".* FROM read_json_auto(?)', (os.path.join(PATH, '*.json'),) - ) - - -def query_to_streams(cursor: duckdb.DuckDBPyConnection) -> List[stream.Stream]: - columns = [desc[0] for desc in cursor.description] - return [stream.Stream.from_dict(dict(zip(columns, row))) for row in cursor.fetchall()] - - -def get_all_streams() -> List[stream.Stream]: - """Returns all streams as a list of `audera.models.stream.Stream` objects.""" - try: - with connection() as conn: - return query_to_streams(conn.execute('SELECT * FROM streams')) - except duckdb.IOException: - return [] diff --git a/tests/conftest.py b/tests/conftest.py index ab94944..d70ef91 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,8 +43,6 @@ def _wait_for_client(host: str, port: int, timeout: float = 90) -> None: @pytest.fixture def audera_home(tmp_path, monkeypatch): for module, subdir in [ - ('audera.dal.groups', 'groups'), - ('audera.dal.streams', 'streams'), ('audera.dal.dsp', 'dsp'), ('audera.dal.presets', 'dsp/presets'), ('audera.dal.settings', 'settings'), diff --git a/tests/dal/test_groups.py b/tests/dal/test_groups.py deleted file mode 100644 index 8e8638a..0000000 --- a/tests/dal/test_groups.py +++ /dev/null @@ -1,109 +0,0 @@ -import audera.dal.groups as groups -from audera.models.player import Group - - -def _make_group(id='grp1', name='Living Room') -> Group: - return Group( - id=id, - name=name, - client_ids=[], - stream_id='', - muted=False, - volume=100, - ) - - -def test_group_create(audera_home): - group = _make_group() - groups.create(group) - assert groups.exists(group.id) - - -def test_group_get(audera_home): - group = _make_group() - groups.create(group) - - result = groups.get(group.id) - assert result == group - - -def test_group_update(audera_home): - group = _make_group() - groups.create(group) - - updated = Group( - id=group.id, - name=group.name, - client_ids=['client-a'], - stream_id='stream-1', - muted=True, - volume=60, - ) - groups.update(updated) - - result = groups.get(group.id) - assert result.volume == 60 - assert result.muted is True - assert result.stream_id == 'stream-1' - - -def test_group_delete(audera_home): - group = _make_group() - groups.create(group) - groups.delete(group.id) - assert not groups.exists(group.id) - - -def test_get_all_groups(audera_home): - g1 = _make_group(id='g1', name='Kitchen') - g2 = _make_group(id='g2', name='Bedroom') - groups.create(g1) - groups.create(g2) - - result = groups.get_all_groups() - ids = {g.id for g in result} - assert ids == {'g1', 'g2'} - - -def test_get_all_groups_empty(audera_home): - result = groups.get_all_groups() - assert result == [] - - -def test_attach_client(audera_home): - group = _make_group() - groups.create(group) - - result = groups.attach_client(group.id, 'client-x') - assert 'client-x' in result.client_ids - - on_disk = groups.get(group.id) - assert 'client-x' in on_disk.client_ids - - -def test_attach_client_idempotent(audera_home): - group = _make_group() - groups.create(group) - groups.attach_client(group.id, 'client-x') - groups.attach_client(group.id, 'client-x') - - result = groups.get(group.id) - assert result.client_ids.count('client-x') == 1 - - -def test_detach_client(audera_home): - group = Group(id='grp1', name='Test', client_ids=['client-x'], stream_id='', muted=False, volume=100) - groups.create(group) - - groups.detach_client(group.id, 'client-x') - result = groups.get(group.id) - assert 'client-x' not in result.client_ids - - -def test_assign_stream(audera_home): - group = _make_group() - groups.create(group) - - groups.assign_stream(group.id, 'stream-abc') - result = groups.get(group.id) - assert result.stream_id == 'stream-abc' diff --git a/tests/dal/test_streams.py b/tests/dal/test_streams.py deleted file mode 100644 index 2d16032..0000000 --- a/tests/dal/test_streams.py +++ /dev/null @@ -1,83 +0,0 @@ -import audera.dal.streams as streams -from audera.models.stream import Stream - - -def _make_stream(id='stream1', name='PlexAmp', status='idle') -> Stream: - return Stream( - id=id, - name=name, - uri='tcp://0.0.0.0:4953', - status=status, - current_track=None, - ) - - -def test_stream_create(audera_home): - stream = _make_stream() - streams.create(stream) - assert streams.exists(stream.id) - - -def test_stream_get(audera_home): - stream = _make_stream() - streams.create(stream) - - result = streams.get(stream.id) - assert result == stream - - -def test_stream_update(audera_home): - stream = _make_stream() - streams.create(stream) - - updated = Stream( - id=stream.id, - name=stream.name, - uri=stream.uri, - status='playing', - current_track='Artist — Song', - ) - streams.update(updated) - - result = streams.get(stream.id) - assert result.status == 'playing' - assert result.current_track == 'Artist — Song' - - -def test_stream_delete(audera_home): - stream = _make_stream() - streams.create(stream) - streams.delete(stream.id) - assert not streams.exists(stream.id) - - -def test_get_all_streams(audera_home): - s1 = _make_stream(id='s1', name='Lounge') - s2 = _make_stream(id='s2', name='Bedroom') - streams.create(s1) - streams.create(s2) - - result = streams.get_all_streams() - ids = {s.id for s in result} - assert ids == {'s1', 's2'} - - -def test_get_all_streams_empty(audera_home): - result = streams.get_all_streams() - assert result == [] - - -def test_stream_current_track_none_roundtrip(audera_home): - stream = _make_stream() - streams.create(stream) - - result = streams.get(stream.id) - assert result.current_track is None - - -def test_stream_current_track_set_roundtrip(audera_home): - stream = Stream(id='s1', name='Test', uri='', status='playing', current_track='Band — Track') - streams.create(stream) - - result = streams.get(stream.id) - assert result.current_track == 'Band — Track' diff --git a/tests/models/test_player.py b/tests/models/test_player.py index 2df5d5a..0d89bdd 100644 --- a/tests/models/test_player.py +++ b/tests/models/test_player.py @@ -1,4 +1,4 @@ -from audera.models.player import Player +from audera.models.player import Group, Player def test_player_is_live_only_dto_without_dsp_id(): @@ -17,3 +17,17 @@ def test_player_is_live_only_dto_without_dsp_id(): } ) assert not hasattr(player, 'dsp_id') + + +def test_group_to_dict_from_dict_roundtrip(): + # `Group` is a live Snapcast-owned DTO with a hand-written `to_dict`; its round-trip + # was previously only asserted by the retired groups DAL tests, so it lives here now. + group = Group( + id='grp1', + name='Living Room', + client_ids=['client-a', 'client-b'], + stream_id='stream-1', + muted=True, + volume=60, + ) + assert Group.from_dict(group.to_dict()) == group diff --git a/tests/models/test_stream.py b/tests/models/test_stream.py new file mode 100644 index 0000000..35610d9 --- /dev/null +++ b/tests/models/test_stream.py @@ -0,0 +1,17 @@ +from audera.models.stream import Stream + + +def test_stream_current_track_set_roundtrip(): + # A set `current_track` survives the `to_dict`/`from_dict` round-trip. Previously only + # the retired streams DAL tests asserted this; it lives here now as a pure model test. + stream = Stream(id='s1', name='PlexAmp', uri='tcp://0.0.0.0:4953', status='playing', current_track='Band — Track') + assert Stream.from_dict(stream.to_dict()) == stream + + +def test_stream_current_track_none_roundtrip(): + # `to_dict()` emits `''` for a `None` track and the `_coerce_current_track` validator + # coerces the falsy `''` back to `None`, so the round-trip lands as `None`. + stream = Stream(id='s1', name='PlexAmp', uri='tcp://0.0.0.0:4953', status='idle', current_track=None) + result = Stream.from_dict(stream.to_dict()) + assert result.current_track is None + assert result == stream From b7f45a7815c225ec133cd4eb40be9aba1b2dc215 Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Thu, 9 Jul 2026 14:56:21 -0500 Subject: [PATCH 11/18] =?UTF-8?q?refactor:=20address=20PR=20#49=20review?= =?UTF-8?q?=20=E2=80=94=20one=20taxonomy,=20YAML=20REW,=20pages/=20split?= =?UTF-8?q?=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land all 12 inline review comments with a net-simpler codebase: - DAL: revert `dsp` to key on `DSPConfig.player_id`, drop `resolve_for_player` and the artifact docstring note (#1, #2). - Models: adopt CamillaDSP casing on `Band.type` (Peaking/Lowshelf/Highshelf/Lowpass/Highpass) and delete `compiler._TYPE_MAP` (#4). - Constants: centralize `DEFAULT_Q` (with Butterworth rationale) and `PASS_TYPES` in `models/dsp.py`; remove the three duplicate copies (#7, #9). - REW: rewrite `domains/dsp/rew.py` to speak CamillaDSP YAML — the only format REW v5.20.14+ round-trips — replacing the regex parser; declare `pyyaml` (#8). - Cleanup: strip `WS-*` plan refs, the over-hot load clamp, "legacy" framing, and the dietpi why-not comment (#6, #10, #12). - Docs: strengthen the presets plain-json rationale in the DAL + AGENTS.md (#3). - Split `streamer/pages.py` into a `pages/` package (`__init__`, `index`, `dsp`, `_plex`, `_clients`); document the pattern in `ui/AGENTS.md` (#11). Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 6 +- audera/dal/dsp.py | 55 +- audera/dal/presets.py | 10 +- audera/domains/dsp/compiler.py | 18 +- audera/domains/dsp/presets.py | 12 +- audera/domains/dsp/rew.py | 228 +++---- audera/models/dsp.py | 28 +- audera/ui/AGENTS.md | 15 + audera/ui/streamer/pages.py | 850 --------------------------- audera/ui/streamer/pages/__init__.py | 49 ++ audera/ui/streamer/pages/_clients.py | 28 + audera/ui/streamer/pages/_plex.py | 89 +++ audera/ui/streamer/pages/dsp.py | 393 +++++++++++++ audera/ui/streamer/pages/index.py | 375 ++++++++++++ os/dietpi/lib/common.sh | 3 +- pyproject.toml | 1 + tests/dal/test_dsp.py | 88 +-- tests/dal/test_presets.py | 8 +- tests/domains/dsp/test_compiler.py | 29 +- tests/domains/dsp/test_headroom.py | 22 +- tests/domains/dsp/test_presets.py | 8 +- tests/domains/dsp/test_rew.py | 189 +++--- tests/models/test_dsp.py | 41 +- tests/ui/test_streamer.py | 73 ++- uv.lock | 4 +- 25 files changed, 1369 insertions(+), 1253 deletions(-) delete mode 100644 audera/ui/streamer/pages.py create mode 100644 audera/ui/streamer/pages/__init__.py create mode 100644 audera/ui/streamer/pages/_clients.py create mode 100644 audera/ui/streamer/pages/_plex.py create mode 100644 audera/ui/streamer/pages/dsp.py create mode 100644 audera/ui/streamer/pages/index.py diff --git a/AGENTS.md b/AGENTS.md index 8a445a3..343a48f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ All models are `@dataclass` with `from_dict()` / `from_config()` / `to_dict()` / - `Player` — Snapcast client: `id, host, port, connected, volume, muted, group_id` - `Group` — Snapcast group: `id, name, client_ids, stream_id, muted, volume` - `Stream` — Plex-Amp stream: `id, name, uri, status, current_track` -- `DSPConfig` — CamillaDSP pipeline: `id, player_id, pipeline (dict), enabled` +- `DSPConfig` — parametric-EQ config: `player_id, preamp_db, bands, enabled` (keyed by `player_id`; the CamillaDSP pipeline is compiled from `preamp_db` + `bands` on Save) `Player.group_id` and `Group.stream_id` are empty strings (not `None`) when unassigned — required by the pytensils `'str'` DTYPES constraint. @@ -50,8 +50,8 @@ The `dsp` models (`Band`/`DSPConfig`/`Preset`) are pydantic `BaseModel` (the `@d ## DAL -- `dsp`, `presets`, and `settings` persist via plain `json` — the pipeline/bands dicts are too complex for DTYPES validation. -- Config files: `~/.audera/{dsp,dsp/presets,settings}/{id}.json` +- `dsp`, `presets`, and `settings` all persist via plain `json` (not duckdb). A `DSPConfig`'s and a `Preset`'s `bands` are nested lists of objects, which duckdb's `read_json_auto` — flat/columnar under the pytensils DTYPES constraint — cannot model. The duckdb-backed DALs were retired, so every surviving DAL is plain-json for the same reason. +- Config files: `~/.audera/{dsp,dsp/presets,settings}/{id}.json` (`dsp` is keyed by `player_id`) ## Code style diff --git a/audera/dal/dsp.py b/audera/dal/dsp.py index 82ee6cc..f1720fa 100644 --- a/audera/dal/dsp.py +++ b/audera/dal/dsp.py @@ -5,20 +5,20 @@ from typing import Union from audera.dal import path -from audera.models import dsp, player +from audera.models import dsp PATH: Union[str, os.PathLike] = os.path.join(path.HOME, 'dsp') -def exists(id: str) -> bool: +def exists(player_id: str) -> bool: """Returns `True` when the DSP configuration file exists. Parameters ---------- - id: `str` - The DSP configuration identifier. + player_id: `str` + The player identifier. """ - return os.path.isfile(os.path.abspath(os.path.join(PATH, '.'.join([id, 'json'])))) + return os.path.isfile(os.path.abspath(os.path.join(PATH, '.'.join([player_id, 'json'])))) def create(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: @@ -32,15 +32,15 @@ def create(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: return save(dsp_config) -def get(id: str) -> dsp.DSPConfig: +def get(player_id: str) -> dsp.DSPConfig: """Returns the DSP configuration as an `audera.models.dsp.DSPConfig` object. Parameters ---------- - id: `str` - The DSP configuration identifier. + player_id: `str` + The player identifier. """ - file_path = os.path.join(PATH, '.'.join([id, 'json'])) + file_path = os.path.join(PATH, '.'.join([player_id, 'json'])) with open(file_path, 'r') as f: data = json.load(f) return dsp.DSPConfig.from_dict(data['dsp']) @@ -54,29 +54,14 @@ def get_or_create(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: dsp_config: `audera.models.dsp.DSPConfig` An instance of an `audera.models.dsp.DSPConfig` object. """ - if exists(dsp_config.id): - return get(dsp_config.id) + if exists(dsp_config.player_id): + return get(dsp_config.player_id) else: return create(dsp_config) -def resolve_for_player(player_: player.Player) -> dsp.DSPConfig: - """Returns the player's editable `DSPConfig`, keyed by the player's own Snapcast id. - - The config file *is* the link: it lives at `dsp/{player_.id}.json`, so no FK or player - shadow file is needed. A fresh empty config is created on first open and re-read on - every open thereafter. - - Parameters - ---------- - player_: `audera.models.player.Player` - An instance of an `audera.models.player.Player` object. - """ - return get_or_create(dsp.DSPConfig(id=player_.id)) - - def save(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: - """Saves the DSP configuration to `~/.audera/dsp/{id}.json`. + """Saves the DSP configuration to `~/.audera/dsp/{player_id}.json`. Parameters ---------- @@ -85,14 +70,14 @@ def save(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: """ if not os.path.isdir(PATH): os.makedirs(PATH) - file_path = os.path.join(PATH, '.'.join([dsp_config.id, 'json'])) + file_path = os.path.join(PATH, '.'.join([dsp_config.player_id, 'json'])) with open(file_path, 'w') as f: json.dump({'dsp': dsp_config.to_dict()}, f, indent=2) return dsp_config def update(new: dsp.DSPConfig) -> dsp.DSPConfig: - """Updates the DSP configuration file `~/.audera/dsp/{id}.json`. + """Updates the DSP configuration file `~/.audera/dsp/{player_id}.json`. Parameters ---------- @@ -106,13 +91,13 @@ def update(new: dsp.DSPConfig) -> dsp.DSPConfig: return existing -def delete(id: str): - """Deletes the DSP configuration file. +def delete(player_id: str): + """Deletes the DSP configuration file for a player. Parameters ---------- - id: `str` - The DSP configuration identifier. + player_id: `str` + The player identifier. """ - if exists(id): - os.remove(os.path.join(PATH, '.'.join([id, 'json']))) + if exists(player_id): + os.remove(os.path.join(PATH, '.'.join([player_id, 'json']))) diff --git a/audera/dal/presets.py b/audera/dal/presets.py index ba74c9b..8422582 100644 --- a/audera/dal/presets.py +++ b/audera/dal/presets.py @@ -1,9 +1,13 @@ """Named user-preset configuration-layer. Presets live in their own namespace nested under `dsp/` (`~/.audera/dsp/presets/`), -glob-listed and wrapped `{'preset': {...}}`, so a corrupt player config can't break -the preset menu and vice-versa. Uses plain `json` + `glob` (consistent with `dsp`'s -plain-json storage; the bands dict is out of `read_json_auto` by design). +glob-listed and wrapped `{'preset': {...}}`, so a corrupt player config can't break the +preset menu and vice-versa. + +Storage is plain `json` + `glob`, not duckdb: a `Preset.bands` is a nested list of band +objects, which duckdb's `read_json_auto` — built for flat, columnar rows under the +pytensils DTYPES constraint — cannot model. With the duckdb-backed DALs retired, every +surviving DAL (`dsp`, `presets`, `settings`) is plain-json for the same reason. """ import glob diff --git a/audera/domains/dsp/compiler.py b/audera/domains/dsp/compiler.py index d061241..6098f6b 100644 --- a/audera/domains/dsp/compiler.py +++ b/audera/domains/dsp/compiler.py @@ -9,24 +9,12 @@ import copy -from audera.models.dsp import Band, DSPConfig +from audera.models.dsp import PASS_TYPES, Band, DSPConfig _MANAGED_PREFIX = 'audera_' _PREAMP_KEY = 'audera_preamp' _PEQ_PREFIX = 'audera_peq_' -# Model literals use camel-cased shelf names; CamillaDSP expects lower-cased. -_TYPE_MAP = { - 'Peaking': 'Peaking', - 'LowShelf': 'Lowshelf', - 'HighShelf': 'Highshelf', - 'Lowpass': 'Lowpass', - 'Highpass': 'Highpass', -} - -# Band types that carry a gain; pass filters (`Lowpass`/`Highpass`) omit it. -_GAIN_TYPES = frozenset({'Peaking', 'LowShelf', 'HighShelf'}) - def _band_to_biquad(band: Band) -> dict: """Returns a CamillaDSP Biquad filter `dict` for a single `Band`. @@ -41,11 +29,11 @@ def _band_to_biquad(band: Band) -> dict: An instance of an `audera.models.dsp.Band` object. """ parameters = { - 'type': _TYPE_MAP[band.type], + 'type': band.type, 'freq': band.freq, 'q': band.q, } - if band.type in _GAIN_TYPES: + if band.type not in PASS_TYPES: parameters['gain'] = band.gain return {'type': 'Biquad', 'parameters': parameters} diff --git a/audera/domains/dsp/presets.py b/audera/domains/dsp/presets.py index 031038e..73296e5 100644 --- a/audera/domains/dsp/presets.py +++ b/audera/domains/dsp/presets.py @@ -1,8 +1,8 @@ """Editable band presets. -Loudness is now a static preset — two editable shelf bands — rather than a dynamic -filter. The page (WS-4/5) pairs this with the headroom guard to suggest a -protective pre-amp; this module only mints the bands. +Loudness is a static preset — two editable shelf bands — rather than a dynamic +filter. The editor pairs this with the headroom guard to suggest a protective +pre-amp; this module only mints the bands. """ import uuid @@ -16,13 +16,13 @@ def loudness_preset() -> list[Band]: """Returns two fresh, editable loudness shelf bands. - A `LowShelf` at 90 Hz (+10 dB) and a `HighShelf` at 8000 Hz (+6 dB), each with + A `Lowshelf` at 90 Hz (+10 dB) and a `Highshelf` at 8000 Hz (+6 dB), each with `q=0.7`, a unique id, and enabled — mirroring the retired dynamic-loudness boosts. """ return [ - Band(id=uuid.uuid4().hex, type='LowShelf', freq=90.0, gain=_LOUDNESS_LOW_BOOST, q=0.7), - Band(id=uuid.uuid4().hex, type='HighShelf', freq=8000.0, gain=_LOUDNESS_HIGH_BOOST, q=0.7), + Band(id=uuid.uuid4().hex, type='Lowshelf', freq=90.0, gain=_LOUDNESS_LOW_BOOST, q=0.7), + Band(id=uuid.uuid4().hex, type='Highshelf', freq=8000.0, gain=_LOUDNESS_HIGH_BOOST, q=0.7), ] diff --git a/audera/domains/dsp/rew.py b/audera/domains/dsp/rew.py index e85826b..662f752 100644 --- a/audera/domains/dsp/rew.py +++ b/audera/domains/dsp/rew.py @@ -1,150 +1,162 @@ -"""Import/export parametric-EQ bands as REW / Equalizer APO filter text. - -`parse_rew` and `format_rew` are band-inverses: the bands emitted by `format_rew` -round-trip back through `parse_rew` modulo fresh ids and float-format precision. Both -are pure — they import only `audera.models` (+ stdlib `re`/`uuid` and pydantic), so they -are Docker-free and unit-testable with zero I/O. - -Pre-amp is intentionally *not* round-tripped: `parse_rew` recognizes a `Preamp:` line but -never applies it (the editor's auto-ceiling owns pre-amp on import), and `format_rew` emits -the honest saved pre-amp only so the exported text stays safe to paste into another tool. +"""Import/export parametric-EQ bands as CamillaDSP YAML (the format REW interops with). + +REW (v5.20.14+) natively round-trips CamillaDSP YAML: it exports the same structured +`filters:`/`pipeline:` shape our compiler emits, and it is the only format REW can +re-import. `parse_rew` and `format_rew` are band-inverses — the bands emitted by +`format_rew` round-trip back through `parse_rew` modulo fresh ids. Both are pure (they +import only `audera.models` + `audera.domains.dsp.compiler`, plus stdlib `uuid`, `yaml`, +and pydantic), so they are Docker-free and unit-testable with zero I/O. + +Pre-amp is intentionally not round-tripped: `format_rew` emits only the band biquads (the +editor's auto-ceiling owns the pre-amp on import), so a pasted export re-derives a +clip-safe pre-amp rather than carrying a stale one. """ -import re import uuid +from typing import Any, Optional, get_args +import yaml from pydantic import BaseModel, Field -from audera.models.dsp import Band - -# REW/Equalizer APO filter kinds → `Band.type`. `LSC`/`HSC` are REW's "corner" shelf -# variants, which we treat as ordinary shelves. Unsupported kinds (`NO` notch, `AP` -# allpass, `BP` bandpass) are deliberately absent so their lines land in `skipped`. -_PARSE_TYPES = { - 'PK': 'Peaking', - 'LS': 'LowShelf', - 'LSC': 'LowShelf', - 'HS': 'HighShelf', - 'HSC': 'HighShelf', - 'LP': 'Lowpass', - 'HP': 'Highpass', -} - -# `Band.type` → REW filter kind (the inverse of `_PARSE_TYPES`, collapsing the shelf -# variants onto the plain shelf codes). -_FORMAT_TYPES = { - 'Peaking': 'PK', - 'LowShelf': 'LS', - 'HighShelf': 'HS', - 'Lowpass': 'LP', - 'Highpass': 'HP', -} - -# Pass filters carry no gain, so `format_rew` omits `Gain` for them (matching the compiler -# and the editor's disabled gain field) while still emitting `Q`. -_PASS_TYPES = {'Lowpass', 'Highpass'} - -_DEFAULT_Q = 0.707 - -_NUMBER = r'[-+]?\d*\.?\d+' - -# `Preamp: -6.0 dB` — case-insensitive; recognized so it is never mistaken for garbage. -_PREAMP_RE = re.compile(r'^Preamp\s*:', re.IGNORECASE) - -# `Filter [N]: ON|OFF Fc Hz [Gain dB] [Q ]`, whitespace-tolerant. The -# leading index is optional so Equalizer APO's `Filter: …` lines parse too. `Fc`/`Gain`/`Q` -# are each optional captures so a supported kind that is missing `Fc` can be routed to -# `skipped` rather than silently defaulted. -_FILTER_RE = re.compile( - r'^Filter\s*\d*\s*:\s*' - r'(?PON|OFF)\s+' - r'(?P[A-Za-z]+)' - rf'(?:\s+Fc\s+(?P{_NUMBER})\s*Hz)?' - rf'(?:\s+Gain\s+(?P{_NUMBER})\s*dB)?' - rf'(?:\s+Q\s+(?P{_NUMBER}))?', - re.IGNORECASE, -) +from audera.domains.dsp.compiler import _band_to_biquad +from audera.models.dsp import DEFAULT_Q, PASS_TYPES, Band + +# CamillaDSP Biquad `parameters.type` values we support, derived from the model literal so +# the parser can never drift from `audera.models.dsp.Band`. Every other biquad subtype +# (Notch, Bandpass, Allpass, the first-order shelves, …) and non-Biquad filter (Conv, +# BiquadCombo, Gain, …) is unsupported and routed to `skipped`, mirroring REW's own +# "unsupported filter types are skipped with a warning". +_SUPPORTED_TYPES = frozenset(get_args(Band.model_fields['type'].annotation)) class RewImport(BaseModel): - """A `class` that represents the result of parsing a REW filter export. + """A `class` that represents the result of parsing a CamillaDSP YAML export. Attributes ---------- bands: `list[audera.models.dsp.Band]` The parsed bands, each with a fresh id, ready to append to a configuration. skipped: `list[str]` - The raw lines that could not be parsed (unsupported kind, missing `Fc`, or - non-filter text), surfaced so nothing is dropped silently. + The names of filters that could not be parsed (unsupported biquad subtype or + non-Biquad filter), or the raw text when the document itself is unparseable — + surfaced so nothing is dropped silently. """ bands: list[Band] = Field(default_factory=list) skipped: list[str] = Field(default_factory=list) +def _bypassed_by_name(pipeline: Any) -> dict[str, bool]: + """Returns a `{filter_name: bypassed}` map from a CamillaDSP pipeline. + + A filter absent from every pipeline step is treated as active (`enabled=True`); a + referencing step's `bypassed` flag (default `False`) sets the band's enabled state. + + Parameters + ---------- + pipeline: `Any` + The `pipeline` value from a parsed CamillaDSP document (a list of steps, or any + other type when the field is malformed or absent). + """ + bypassed: dict[str, bool] = {} + if not isinstance(pipeline, list): + return bypassed + for step in pipeline: + if not isinstance(step, dict) or step.get('type') != 'Filter': + continue + flag = bool(step.get('bypassed', False)) + for name in step.get('names', []) or []: + bypassed[str(name)] = flag + return bypassed + + +def _filter_to_band(filter_: Any, enabled: bool) -> Optional[Band]: + """Returns a fresh-id `Band` for a supported CamillaDSP Biquad filter, else `None`. + + Parameters + ---------- + filter_: `Any` + A single entry from the document's `filters` mapping. + enabled: `bool` + Whether the filter's pipeline step is active (derived from `bypassed`). + """ + if not isinstance(filter_, dict) or filter_.get('type') != 'Biquad': + return None + parameters = filter_.get('parameters') + if not isinstance(parameters, dict): + return None + band_type = parameters.get('type') + freq = parameters.get('freq') + if band_type not in _SUPPORTED_TYPES or freq is None: + return None + return Band( + id=uuid.uuid4().hex, + type=band_type, + freq=float(freq), + gain=0.0 if band_type in PASS_TYPES else float(parameters.get('gain', 0.0)), + q=float(parameters.get('q', DEFAULT_Q)), + enabled=enabled, + ) + + def parse_rew(text: str) -> RewImport: - """Returns the bands parsed from a REW / Equalizer APO filter export. + """Returns the bands parsed from a CamillaDSP YAML (or JSON) export. - Parsed line-by-line and never silently dropping: blank lines are ignored, a `Preamp:` - line is recognized but neither applied nor skipped (the editor's auto-ceiling owns - pre-amp), a supported filter line becomes a fresh-id `Band`, and everything else — - unsupported kinds, filter lines missing `Fc`, or non-filter text — is collected in - `skipped`. + `yaml.safe_load` parses YAML — and JSON, a YAML subset, so REW/CamillaDSP JSON pastes + parse too. Each `filters` entry that is a supported `Biquad` becomes a fresh-id `Band` + (its enabled state read from the pipeline's `bypassed`); every unsupported filter is + collected in `skipped`, and an unparseable document falls back to a single skipped + entry (its raw text) so nothing is dropped silently. Parameters ---------- text: `str` - The REW / Equalizer APO filter export to parse. + The CamillaDSP YAML/JSON export to parse. """ + try: + document = yaml.safe_load(text) + except yaml.YAMLError: + document = None + if not isinstance(document, dict): + stripped = text.strip() + return RewImport(bands=[], skipped=[stripped] if stripped else []) + filters = document.get('filters') + if not isinstance(filters, dict): + stripped = text.strip() + return RewImport(bands=[], skipped=[stripped] if stripped else []) + bypassed = _bypassed_by_name(document.get('pipeline')) bands: list[Band] = [] skipped: list[str] = [] - for raw in text.splitlines(): - line = raw.strip() - if not line: - continue - if _PREAMP_RE.match(line): - continue # recognized, not applied, not skipped - match = _FILTER_RE.match(line) - mapped = _PARSE_TYPES.get(match.group('kind').upper()) if match else None - fc = match.group('fc') if match else None - if match is None or mapped is None or fc is None: - skipped.append(line) - continue - gain = match.group('gain') - q = match.group('q') - bands.append( - Band( - id=uuid.uuid4().hex, - type=mapped, # type: ignore - freq=float(fc), - gain=float(gain) if gain is not None else 0.0, - q=float(q) if q is not None else _DEFAULT_Q, - enabled=match.group('state').upper() == 'ON', - ) - ) + for name, filter_ in filters.items(): + band = _filter_to_band(filter_, enabled=not bypassed.get(str(name), False)) + if band is None: + skipped.append(str(name)) + else: + bands.append(band) return RewImport(bands=bands, skipped=skipped) def format_rew(preamp_db: float, bands: list[Band]) -> str: - """Returns a REW-format filter export for a pre-amp and a set of bands. + """Returns a CamillaDSP YAML fragment for a set of bands, importable by REW. - The first line is `Preamp: {preamp_db:.1f} dB` (the honest value — for a saved config - this is the real auto-clamped pre-amp, so the text applies safely in another tool), - followed by one 1-indexed `Filter` line per band. Pass filters omit `Gain` but still - emit `Q`, so a pass band's `q` survives the round-trip. + Emits a `{filters, pipeline}` fragment — one `Biquad` per band (built by the shared + `compiler._band_to_biquad`, so export and compile can never drift) plus a stereo + pipeline step per band whose `bypassed` mirrors the band's enabled state. The leading + `filters:` tag is the shape REW requires to import. The pre-amp is intentionally not + emitted (the editor's auto-ceiling owns it on import), so `preamp_db` is accepted for + call-site symmetry with the saved config but not serialized. Parameters ---------- preamp_db: `float` - The pre-amp attenuation in dB, emitted for fidelity (`parse_rew` does not re-apply - it). + The pre-amp attenuation in dB. Accepted for signature symmetry with the editor's + saved config; not written to the fragment (pre-amp is not round-tripped). bands: `list[audera.models.dsp.Band]` The parametric-EQ bands to format. """ - lines = [f'Preamp: {preamp_db:.1f} dB'] - for index, band in enumerate(bands, start=1): - kind = _FORMAT_TYPES[band.type] - state = 'ON' if band.enabled else 'OFF' - gain = '' if band.type in _PASS_TYPES else f' Gain {band.gain:.2f} dB' - lines.append(f'Filter {index}: {state} {kind} Fc {band.freq:.1f} Hz{gain} Q {band.q:.3f}') - return '\n'.join(lines) + filters: dict[str, dict] = {} + pipeline: list[dict] = [] + for band in bands: + filters[band.id] = _band_to_biquad(band) + pipeline.append({'type': 'Filter', 'channels': [0, 1], 'names': [band.id], 'bypassed': not band.enabled}) + return yaml.safe_dump({'filters': filters, 'pipeline': pipeline}, sort_keys=False) diff --git a/audera/models/dsp.py b/audera/models/dsp.py index deacd03..04884ab 100644 --- a/audera/models/dsp.py +++ b/audera/models/dsp.py @@ -7,6 +7,14 @@ from pydantic import BaseModel, Field +# Butterworth Q ≈ 1/√2 — the maximally-flat response with no resonant peak; the standard +# default when a filter specifies no Q. +DEFAULT_Q = 0.707 + +# Pass filters (`Lowpass`/`Highpass`) carry no gain; every other band type does. Centralized +# here so the compiler, REW interop, and the editor share one type taxonomy. +PASS_TYPES = frozenset({'Lowpass', 'Highpass'}) + class Band(BaseModel): """A `class` that represents a single parametric-EQ band. @@ -15,8 +23,8 @@ class Band(BaseModel): ---------- id: `str` The band identifier (stable key for UI rows). - type: `Literal['Peaking', 'LowShelf', 'HighShelf', 'Lowpass', 'Highpass']` - The biquad filter type. + type: `Literal['Peaking', 'Lowshelf', 'Highshelf', 'Lowpass', 'Highpass']` + The biquad filter type, cased to match CamillaDSP's own parameter names. freq: `float` The center/corner frequency in Hz. gain: `float` @@ -28,10 +36,10 @@ class Band(BaseModel): """ id: str - type: Literal['Peaking', 'LowShelf', 'HighShelf', 'Lowpass', 'Highpass'] = 'Peaking' + type: Literal['Peaking', 'Lowshelf', 'Highshelf', 'Lowpass', 'Highpass'] = 'Peaking' freq: float gain: float = 0.0 - q: float = 0.707 + q: float = DEFAULT_Q enabled: bool = True @classmethod @@ -63,8 +71,9 @@ class DSPConfig(BaseModel): Attributes ---------- - id: `str` - The DSP configuration identifier and file key. + player_id: `str` + The player identifier and file key (the config lives at `dsp/{player_id}.json`, + so the filename is the link — no separate FK is needed). preamp_db: `float` The pre-amp Gain filter attenuation in dB. bands: `list[Band]` @@ -73,7 +82,7 @@ class DSPConfig(BaseModel): Whether the DSP configuration is active. """ - id: str + player_id: str preamp_db: float = 0.0 bands: list[Band] = Field(default_factory=list) enabled: bool = True @@ -86,7 +95,7 @@ def from_dict(cls, dict_object: dict) -> 'DSPConfig': def to_dict(self) -> dict: """Returns a `DSPConfig` object as a `dict`.""" return { - 'id': self.id, + 'player_id': self.player_id, 'preamp_db': self.preamp_db, 'bands': [band.to_dict() for band in self.bands], 'enabled': self.enabled, @@ -103,7 +112,8 @@ class Preset(BaseModel): A preset is its own entity (not a flavored `DSPConfig`): a display name plus a list of bands that can be cloned and appended onto any player's configuration. Unlike `Band`/`DSPConfig`, it serializes via pydantic directly - (`model_dump()`/`model_validate`) — there is no legacy on-disk shape to preserve. + (`model_dump()`/`model_validate`) — a brand-new type with no hand-written on-disk + shape to preserve. Attributes ---------- diff --git a/audera/ui/AGENTS.md b/audera/ui/AGENTS.md index dfa0052..c5b8ab6 100644 --- a/audera/ui/AGENTS.md +++ b/audera/ui/AGENTS.md @@ -14,6 +14,21 @@ Every UI app follows this two-file pattern: - Page methods: one per route (e.g. `index()`, `welcome()`, `connect()`) - Tab builder methods: `_build__tab()` — private, called from a page method +**`pages/` sub-package** — when `pages.py` grows past a few hundred lines, split it into a +`pages/` package instead of one flat file: +- `pages/__init__.py`: the thin `Page` class (`__init__`, `load()`) re-exported so + `from …pages import Page` is unchanged. Each route method delegates to a module-level + `render(page, …)` — e.g. `def dsp(self, player_id): dsp.render(self, player_id)`. +- One module per route (`pages/index.py`, `pages/dsp.py`, …): a `render(page, …)` function + plus that route's private `_build__tab(page)` / `_on_(page, …)` helpers. + `page` carries the shared state (`page.settings`, `page._dialog_open`); the `Page` class is + **not** split across files. `@ui.refreshable` helpers stay module-level, keyed on the + `page` argument, and are refreshed via `_build__tab.refresh()`. +- Private helper modules (`pages/_clients.py`, `pages/_plex.py`, …): shared client factories + and self-contained flows, imported by the route modules. To avoid an import cycle, route + modules never import names from `pages/__init__.py` at runtime — they take `page` as an + argument and import `Page` only under `TYPE_CHECKING`. + **`__init__.py`** — thin `run()` entry point only: ```python def run() -> None: diff --git a/audera/ui/streamer/pages.py b/audera/ui/streamer/pages.py deleted file mode 100644 index 773ee65..0000000 --- a/audera/ui/streamer/pages.py +++ /dev/null @@ -1,850 +0,0 @@ -"""Audera streamer dashboard pages""" - -import asyncio -import os -import socket -import subprocess -import uuid -from importlib.metadata import version as _pkg_version -from typing import Literal, Optional, get_args - -import httpx -from dotenv import load_dotenv -from nicegui import ui - -import audera -from audera.clients import CamillaDSPClient, SnapserverClient -from audera.dal import dsp as dsp_dal -from audera.dal import presets as presets_dal -from audera.dal import settings as settings_dal -from audera.domains.dsp import auto_preamp_db, clone_bands, compile_pipeline, format_rew, loudness_preset, parse_rew -from audera.models.dsp import Band, Preset -from audera.models.settings import Settings -from audera.ui import components, features - -load_dotenv() - -# Derived from the model literal so the editor's type choices can never drift from -# `audera.models.dsp.Band`. Pass filters carry no gain, so their gain field is disabled. -_BAND_TYPES = list(get_args(Band.model_fields['type'].annotation)) -_PASS_TYPES = {'Lowpass', 'Highpass'} - -_PLEX_CLIENT_ID = str(uuid.uuid4()) -_PLEX_HEADERS = { - 'X-Plex-Product': audera.NAME, - 'X-Plex-Version': _pkg_version('audera'), - 'X-Plex-Client-Identifier': _PLEX_CLIENT_ID, - 'X-Plex-Platform': 'Linux', - 'Accept': 'application/json', -} - - -def _load_settings() -> Settings: - return settings_dal.get_or_create( - Settings( - plexamp_host=os.getenv('AUDERA_PLEXAMP_HOST', 'localhost'), - snapserver_host=os.getenv('AUDERA_SNAPSERVER_HOST', 'localhost'), - features=features.default_selections(), - ) - ) - - -def _snapserver(settings: Settings) -> SnapserverClient: - return SnapserverClient(host=settings.snapserver_host, port=audera.SNAPSERVER_PORT) - - -def _camilladsp(host: str) -> CamillaDSPClient: - """Returns a CamillaDSPClient for the given player host.""" - return CamillaDSPClient(host=host) - - -def _plexamp_state() -> Literal['inactive', 'unclaimed', 'claimed']: - try: - result = subprocess.run( - ['systemctl', 'is-active', 'plexamp'], - capture_output=True, - text=True, - timeout=5, - ) - if result.stdout.strip() != 'active': - return 'inactive' - except Exception: - return 'inactive' - try: - with socket.create_connection(('127.0.0.1', audera.PLEXAMP_PORT), timeout=1): - return 'claimed' - except OSError: - return 'unclaimed' - - -def _create_plex_pin() -> tuple[int, str]: - resp = httpx.post( - 'https://plex.tv/api/v2/pins', - params={'strong': 'true'}, - headers=_PLEX_HEADERS, - timeout=10, - ) - resp.raise_for_status() - data = resp.json() - return data['id'], data['code'] - - -def _poll_plex_pin(pin_id: int) -> Optional[str]: - resp = httpx.get( - f'https://plex.tv/api/v2/pins/{pin_id}', - headers=_PLEX_HEADERS, - timeout=5, - ) - resp.raise_for_status() - return resp.json().get('authToken') or None - - -def _get_claim_token(auth_token: str) -> str: - resp = httpx.get( - 'https://plex.tv/api/claim/token.json', - headers={**_PLEX_HEADERS, 'X-Plex-Token': auth_token}, - timeout=10, - ) - resp.raise_for_status() - return resp.json()['token'] - - -def _restart_plexamp_with_claim(claim_token: str) -> None: - subprocess.run(['systemctl', 'stop', 'plexamp'], timeout=15, check=True) - override_dir = '/etc/systemd/system/plexamp.service.d' - os.makedirs(override_dir, exist_ok=True) - with open(f'{override_dir}/claim.conf', 'w') as f: - f.write(f'[Service]\nEnvironment=PLEXAMP_CLAIM_TOKEN={claim_token}\n') - subprocess.run(['systemctl', 'daemon-reload'], timeout=10, check=True) - subprocess.run(['systemctl', 'start', 'plexamp'], timeout=10, check=True) - - -def _remove_claim_override() -> None: - override = '/etc/systemd/system/plexamp.service.d/claim.conf' - if os.path.exists(override): - os.remove(override) - subprocess.run(['systemctl', 'daemon-reload'], timeout=10) - - -class Page: - """A `class` that represents the streamer dashboard app.""" - - def __init__(self): - """Initializes an instance of the streamer dashboard app.""" - self.settings = _load_settings() - self._client = _snapserver(self.settings) - self._dialog_open: bool = False - - def load(self) -> None: - """Registers page routes.""" - ui.page('/')(self.index) - ui.page('/player/{player_id}/dsp')(self.dsp) - - def index(self) -> None: - """Renders the main dashboard page.""" - components.header.render(audera.NAME, 'Streamer') - - with ui.tabs().classes('w-full') as tabs: - players_tab = ui.tab('Players') - services_tab = ui.tab('Services') - settings_tab = ui.tab('Settings') - - with ui.tab_panels(tabs, value=players_tab).classes('w-full'): - with ui.tab_panel(players_tab): - self._build_players_tab() # type: ignore - with ui.tab_panel(services_tab): - self._build_services_tab() # type: ignore - with ui.tab_panel(settings_tab): - self._build_settings_tab() - - def _maybe_refresh(): - if not self._dialog_open: - self._build_players_tab.refresh() - - ui.timer(10.0, _maybe_refresh) - - def dsp(self, player_id: str) -> None: - """Renders the full-page parametric-EQ editor for a single player. - - Bands are the source of truth; they are compiled to a live CamillaDSP pipeline - on Save. Per-page edit state lives in closures (not on `self`, which is shared - across every connected client): `state['saved']` mirrors the persisted config and - `state['staged']` is the working copy that is compiled, validated, and pushed on - Save. Scalar field edits mutate a band in place and only recompute the dirty - indicator, clip-safe pre-amp clamp, and live response chart; structural changes - (add/delete/type/preset/reset) refresh the band table. - """ - components.header.render(audera.NAME, 'Streamer') - - snap = _snapserver(self.settings) - try: - clients = snap.get_clients() - except Exception: - clients = [] - live = next((client for client in clients if client.id == player_id), None) - - if live is None: - with ui.column().classes('w-full gap-2 p-4'): - ui.link('‹ Players', '/') - ui.label('Player not found or unreachable.').classes('text-gray-500') - return - - # The config is keyed by the player's own id (`dsp/{id}.json` is the link), so - # `live.id` is all that's needed to resolve it. - saved = dsp_dal.resolve_for_player(live) - # Clamp the baseline once so an over-hot legacy config opens clean, not falsely dirty. - saved.preamp_db = min(saved.preamp_db, auto_preamp_db(saved.bands)) - state = {'saved': saved, 'staged': saved.model_copy(deep=True)} - - def _dirty() -> bool: - return state['staged'] != state['saved'] - - def _mark_changed() -> None: - """Clamps pre-amp to the clip-safe ceiling, then refreshes dirty state, chart, count.""" - clamped = min(state['staged'].preamp_db, auto_preamp_db(state['staged'].bands)) - if clamped != state['staged'].preamp_db: # min is a fixpoint — converges in one pass - state['staged'].preamp_db = clamped - preamp_field.value = clamped - dirty_label.set_visibility(_dirty()) - has_bands = bool(state['staged'].bands) # chart only once a band exists - chart.set_visibility(has_bands) - chart_message.set_visibility(not has_bands) - if has_bands: - # `EChart.options` is a read-only view onto the live props dict; swap its - # contents in place (the documented "change the options" push) and redraw. - chart.options.clear() - chart.options.update(components.response_plot.options(state['staged'])) - chart.update() - count_label.set_text(f'Bands ({len(state["staged"].bands)})') - - def _on_preamp(e) -> None: - if e.value is not None: - state['staged'].preamp_db = float(e.value) - _mark_changed() - - def _on_enabled(band: Band, value: bool) -> None: - band.enabled = bool(value) - _mark_changed() - - def _on_type(band: Band, value: str) -> None: - # `value` is constrained to `_BAND_TYPES` by the select; the assignment is - # unvalidated (Band sets no `validate_assignment`), so the literal narrows fine. - band.type = value # type: ignore - _band_table.refresh() # the gain field's enabled state depends on the type - _mark_changed() - - def _on_freq(band: Band, value) -> None: - if value is not None: - band.freq = float(value) - _mark_changed() - - def _on_gain(band: Band, value) -> None: - if value is not None: - band.gain = float(value) - _mark_changed() - - def _on_q(band: Band, value) -> None: - if value is not None: - band.q = float(value) - _mark_changed() - - def _add_band() -> None: - state['staged'].bands.append(Band(id=uuid.uuid4().hex, type='Peaking', freq=1000.0, gain=0.0, q=0.707)) - _band_table.refresh() - _mark_changed() - - def _remove_band(band: Band) -> None: - state['staged'].bands = [b for b in state['staged'].bands if b.id != band.id] - _band_table.refresh() - _mark_changed() - - def _apply_preset(kind: Literal['loudness', 'flat']) -> None: - if kind == 'loudness': - state['staged'].bands.extend(loudness_preset()) - else: - state['staged'].bands = [] - _band_table.refresh() - _mark_changed() - - def _apply_saved_preset(preset: Preset) -> None: - """Appends fresh clones of a saved preset's bands onto the staged config. - - Apply = append + clone (never replace); the clones carry fresh ids so their - `audera_peq_` filter names can't collide. Routing through `_mark_changed` - re-clamps the pre-amp and redraws the chart over the merged set — same wiring - as REW import. - """ - state['staged'].bands.extend(clone_bands(preset.bands)) - _band_table.refresh() - _mark_changed() - - def _delete_preset(preset: Preset) -> None: - presets_dal.delete_preset(preset.id) - _presets_menu.refresh() - ui.notify(f'Deleted preset "{preset.name}"', type='positive', position='top-right') - - def _open_save_preset_dialog() -> None: - """Opens a dialog to capture the current bands as a named, reusable preset.""" - with ui.dialog() as dialog, ui.card().classes('w-96'): - ui.label('Save preset').classes('font-medium text-lg mb-1') - ui.label('Capture the current bands as a reusable preset you can append to any player.').classes( - 'text-xs text-gray-500 mb-2' - ) - name_field = ( - ui.input('Preset name', placeholder='My preset') - .props('outlined dense') - .classes('w-full') - .mark('preset-save-name') - ) - - def _on_save_preset() -> None: - preset = Preset( - id=uuid.uuid4().hex, - name=(name_field.value or '').strip() or 'Untitled', - bands=clone_bands(state['staged'].bands), - ) - presets_dal.save_preset(preset) - _presets_menu.refresh() - ui.notify(f'Saved preset "{preset.name}"', type='positive', position='top-right') - dialog.close() - - with ui.row().classes('justify-between w-full mt-2'): - ui.button('Cancel', on_click=dialog.close).props('flat dense') - ui.button('Save', on_click=_on_save_preset).props('dense').classes('bg-gray-800 text-white').mark( - 'preset-save-run' - ) - - dialog.open() - - @ui.refreshable - def _presets_menu() -> None: - ui.menu_item('Loudness (seed bands)', on_click=lambda: _apply_preset('loudness')).mark('preset-loudness') - ui.menu_item('Flat / clear all bands', on_click=lambda: _apply_preset('flat')).mark('preset-flat') - saved = presets_dal.get_all_presets() - if saved: - ui.separator() - for preset in saved: - with ui.menu_item(on_click=lambda p=preset: _apply_saved_preset(p)).mark('preset-saved'): - with ui.row().classes('items-center justify-between w-full'): - ui.label(preset.name) - ( - ui.button(icon='delete', on_click=lambda p=preset: _delete_preset(p)) - .props('flat dense round size=sm') - .classes('text-gray-400') - .on('click.stop') # .stop: delete doesn't also fire append - ) - ui.separator() - ui.menu_item('Save current as preset…', on_click=_open_save_preset_dialog).mark('preset-save-as') - - def _open_import_dialog() -> None: - """Opens a paste-import dialog that appends REW / Equalizer APO filters as bands. - - Import is append-only (it never replaces existing bands) and routes through - `_mark_changed`, so the auto-ceiling re-clamps the pre-amp and the chart redraws - over the merged band set. Unparseable lines are surfaced in the notification - rather than dropped. - """ - with ui.dialog() as dialog, ui.card().classes('w-96'): - ui.label('Import REW filters').classes('font-medium text-lg mb-1') - ui.label( - 'Paste a REW or Equalizer APO filter export. Bands are appended to the current configuration; ' - 'the pre-amp stays auto-protected.' - ).classes('text-xs text-gray-500 mb-2') - text = ( - ui.textarea(placeholder='Filter 1: ON PK Fc 1000 Hz Gain -3.0 dB Q 1.41') - .props('outlined autogrow') - .classes('w-full font-mono') - ) - - def _on_import() -> None: - result = parse_rew(text.value or '') - state['staged'].bands.extend(result.bands) - _band_table.refresh() - _mark_changed() # re-clamps pre-amp + redraws chart over the merged bands - message = f'Imported {len(result.bands)} band(s)' - if result.skipped: - message += f', skipped {len(result.skipped)} line(s)' - ui.notify(message, type='positive', position='top-right') - dialog.close() - - with ui.row().classes('justify-between w-full mt-2'): - ui.button('Cancel', on_click=dialog.close).props('flat dense') - ui.button('Import', on_click=_on_import).props('dense').classes('bg-gray-800 text-white').mark( - 'config-import-run' - ) - - dialog.open() - - def _open_export_dialog() -> None: - """Opens a dialog showing the *saved* configuration as re-importable REW text. - - The text is always the saved config (never the staged edits): a partial edit - would export a config that never clip-guarded, so Save is the gate. When the - editor is dirty a banner spells this out; Copy and Download surface the same text. - """ - rew_text = format_rew(state['saved'].preamp_db, state['saved'].bands) - with ui.dialog() as dialog, ui.card().classes('w-96'): - ui.label('Export REW filters').classes('font-medium text-lg mb-1') - if _dirty(): - ui.label( - 'There are unsaved changes. Export downloads only the saved configuration — ' - 'Save first to export the current editor contents.' - ).classes('text-xs text-amber-500 mb-2').mark('export-unsaved-banner') - ui.textarea(value=rew_text).props('outlined readonly autogrow').classes('w-full font-mono') - - def _on_copy() -> None: - ui.clipboard.write(rew_text) # synchronous in NiceGUI 3.11.1 - ui.notify('Copied to clipboard', type='positive', position='top-right') - - def _on_download() -> None: - ui.download.content(rew_text, f'{live.name}-dsp.txt') - - with ui.row().classes('justify-between w-full mt-2'): - ui.button('Close', on_click=dialog.close).props('flat dense') - ui.button('Copy', on_click=_on_copy).props('dense') - ui.button('Download .txt', on_click=_on_download).props('dense').classes('bg-gray-800 text-white') - - dialog.open() - - def _on_reset() -> None: - state['staged'] = state['saved'].model_copy(deep=True) - preamp_field.value = state['staged'].preamp_db - _band_table.refresh() - _mark_changed() - - async def _on_save() -> None: - """Compiles → validates → pushes the live pipeline, then persists the config. - - Pattern B (live apply, no restart): the daemon owns volume and `SetConfigJson` - leaves the fader untouched, so no volume snapshot/restore is needed. The config - is keyed by the player id (`dsp/{id}.json`), so Save only updates that one file. - """ - camilla = _camilladsp(live.host) - try: - current = await asyncio.to_thread(camilla.get_config) - compiled = compile_pipeline(current, state['staged']) - await asyncio.to_thread(camilla.validate_config, compiled) # gate; raises on invalid - await asyncio.to_thread(camilla.set_config, compiled) - except Exception as exc: - ui.notify(f'Save failed: {exc}', type='negative', position='top-right') - return - await asyncio.to_thread(dsp_dal.update, state['staged']) - try: - await asyncio.to_thread(camilla.reset_clipped_samples) # start the clip watch fresh - except Exception: - pass - state['saved'] = state['staged'].model_copy(deep=True) - _mark_changed() - ui.notify('Saved', type='positive', position='top-right') - - async def _poll_clips() -> None: - try: - count = await asyncio.to_thread(_camilladsp(live.host).get_clipped_samples) - except Exception: - return - if count: - clip_label.set_text(f'⚠ {count} clipped samples') - clip_label.set_visibility(True) - else: - clip_label.set_visibility(False) - - @ui.refreshable - def _band_table() -> None: - with ui.row(wrap=False).classes('items-center gap-2 w-full text-xs text-gray-500'): - ui.label('On').classes('w-10 text-center') - ui.label('Type').classes('w-32') - ui.label('Freq (Hz)').classes('w-24') - ui.label('Gain (dB)').classes('w-24') - ui.label('Q').classes('w-20') - ui.label('').classes('w-10') - for band in state['staged'].bands: - with ui.row(wrap=False).classes('items-center gap-2 w-full'): - ui.checkbox(value=band.enabled, on_change=lambda e, b=band: _on_enabled(b, e.value)).classes('w-10') - ui.select(_BAND_TYPES, value=band.type, on_change=lambda e, b=band: _on_type(b, e.value)).props( - 'dense outlined' - ).classes('w-32') - ui.number(value=band.freq, step=1, format='%.0f', on_change=lambda e, b=band: _on_freq(b, e.value)).props( - 'dense outlined debounce=200' - ).classes('w-24') - gain_field = ( - ui.number(value=band.gain, step=0.1, format='%.1f', on_change=lambda e, b=band: _on_gain(b, e.value)) - .props('dense outlined debounce=200') - .classes('w-24') - ) - gain_field.set_enabled(band.type not in _PASS_TYPES) - ui.number(value=band.q, step=0.001, format='%.3f', on_change=lambda e, b=band: _on_q(b, e.value)).props( - 'dense outlined debounce=200' - ).classes('w-20') - ui.button(icon='delete', on_click=lambda b=band: _remove_band(b)).props('flat dense round size=sm').classes( - 'text-gray-400' - ) - ui.button('+ Add band', on_click=_add_band).props('flat dense').classes('mt-2') - - with ui.row().classes('items-center justify-between w-full'): - ui.label(f'{live.name} · Advanced DSP').classes('text-lg font-medium') - ui.link('‹ Players', '/') - - with ui.column().classes('w-full gap-3'): - with ui.row().classes('items-center gap-4 w-full'): - preamp_field = ( - ui.number( - 'Pre-amp (dB) · auto-protected', - value=state['staged'].preamp_db, - step=0.1, - format='%.1f', - on_change=_on_preamp, - ) - .props('dense outlined') - .classes('w-40') - ) - with ui.button('Presets', icon='tune').props('flat dense'): - with ui.menu(): - _presets_menu() - with ui.button('Config', icon='import_export').props('flat dense'): - with ui.menu(): - ui.menu_item('Import REW…', on_click=_open_import_dialog).mark('config-import') - ui.menu_item('Export…', on_click=_open_export_dialog).mark('config-export') - ui.space() - ui.button('Reset', on_click=_on_reset).props('flat dense') - ui.button('Save', on_click=_on_save).props('dense').classes('bg-gray-800 text-white') - - # Persistent handle + empty-state message, both toggled by the forward-closure - # `_mark_changed`: the chart shows only once a band exists, the message otherwise. - chart = components.response_plot.render(state['staged']) - chart_message = ui.label( - 'Add a band to see the live frequency-response curve — start from Presets ▾, or + Add band below.' - ).classes('text-sm text-gray-500 p-4') - - _band_table() - - with ui.row().classes('items-center gap-4 w-full mt-2 text-xs text-gray-500'): - count_label = ui.label() - ui.label('IIR biquads · ~0% CPU') - dirty_label = ui.label('Unsaved changes ●').classes('text-amber-500') - clip_label = ui.label('').classes('text-red-500') - clip_label.set_visibility(False) # hidden until the clip poll reports a nonzero count - - _mark_changed() - ui.timer(3.0, _poll_clips) - - @ui.refreshable - def _build_services_tab(self) -> None: - """Renders the Services tab — shows PlexAmp status and a browser-based OAuth claiming flow.""" - state = _plexamp_state() - - with ui.card().classes('w-full mb-2'): - with ui.row().classes('items-center justify-between w-full'): - ui.label('PlexAmp Headless').classes('font-medium') - if state == 'claimed': - ui.label('available').classes('text-sm text-green-500') - elif state == 'unclaimed': - ui.label('setup required').classes('text-sm text-amber-500') - else: - ui.label('inactive').classes('text-sm text-red-500') - - if state == 'claimed': - ui.link('Open PlexAmp', 'https://plexamp.local').classes('text-sm mt-1') - - elif state == 'unclaimed': - connect_btn = ui.button('Connect with Plex').classes('mt-2') - status_label = ui.label('').classes('text-sm text-gray-500 mt-1') - auth_link = ui.link("Didn't open? Click here to authorize with Plex", '#', new_tab=True).classes('text-sm mt-1') - auth_link.set_visibility(False) - - async def _on_connect(): - connect_btn.disable() - status_label.set_text('Opening Plex authorization…') - - try: - pin_id, pin_code = await asyncio.to_thread(_create_plex_pin) - except Exception as exc: - status_label.set_text(f'Error: {exc}') - connect_btn.enable() - return - - auth_url = ( - f'https://app.plex.tv/auth/#!?clientID={_PLEX_CLIENT_ID}' - f'&code={pin_code}' - f'&context%5Bdevice%5D%5Bproduct%5D={audera.NAME}' - ) - ui.navigate.to(auth_url, new_tab=True) - status_label.set_text('Waiting for Plex authorization…') - auth_link.props(f'href="{auth_url}"') - auth_link.set_visibility(True) - - deadline = asyncio.get_event_loop().time() + 300 # 5-minute timeout - poll_timer: list[ui.timer] = [] - - async def _poll_auth(): - if asyncio.get_event_loop().time() > deadline: - poll_timer[0].cancel() - status_label.set_text('Authorization timed out. Please try again.') - connect_btn.enable() - return - - try: - auth_token = await asyncio.to_thread(_poll_plex_pin, pin_id) - except Exception: - return # transient error; retry on next tick - - if not auth_token: - return - - poll_timer[0].cancel() - status_label.set_text('Authorized. Claiming PlexAmp…') - - try: - claim_token = await asyncio.to_thread(_get_claim_token, auth_token) - await asyncio.to_thread(_restart_plexamp_with_claim, claim_token) - except Exception as exc: - status_label.set_text(f'Claim failed: {exc}') - connect_btn.enable() - return - - status_label.set_text('PlexAmp restarting…') - - port_deadline = asyncio.get_event_loop().time() + 120 - port_timer: list[ui.timer] = [] - - async def _poll_port(): - if asyncio.get_event_loop().time() > port_deadline: - port_timer[0].cancel() - _remove_claim_override() - status_label.set_text('PlexAmp did not come up in time. Check the service.') - connect_btn.enable() - return - - if _plexamp_state() == 'claimed': - port_timer[0].cancel() - _remove_claim_override() - self._build_services_tab.refresh() - - port_timer.append(ui.timer(2.0, _poll_port)) - - poll_timer.append(ui.timer(2.0, _poll_auth)) - - connect_btn.on('click', _on_connect) - - @ui.refreshable - def _build_players_tab(self) -> None: - """Renders the Players tab — lists Snapcast clients with per-client volume and mute/enable controls.""" - snap = _snapserver(self.settings) - try: - clients = snap.get_clients() - except Exception: - clients = [] - - connected_clients = [c for c in clients if c.connected] - if not connected_clients: - ui.label('No Snapcast clients found.').classes('text-gray-500') - return - - # Named after the feature-flag constant so flag-gated UI is obvious to the reader. - FF_DISABLED_VS_MUTE = features.flag_enabled(self.settings, features.PLAYER_SELECTION_KEY, features.FF_DISABLED_VS_MUTE) - - for client in connected_clients: - minimized = FF_DISABLED_VS_MUTE and client.muted - with ui.card().classes('w-full mb-2'): - mute_cb = None - with ui.row().classes('items-center justify-between w-full'): - with ui.row().classes('items-center gap-2'): - if FF_DISABLED_VS_MUTE: - ui.switch(value=not client.muted, on_change=lambda e, c=client: self._on_enabled_change(c, e.value)) - # A disabled player is grayed out to reinforce the "disabled" state. - name_label = ui.label(client.name).classes('font-medium') - if minimized: - name_label.classes('text-gray-400') - with ui.row().classes('items-center gap-2'): - if not FF_DISABLED_VS_MUTE: - mute_cb = ui.checkbox( - 'Mute', value=client.muted, on_change=lambda e, c=client: self._on_mute_change(c.id, e.value) - ) - settings_btn = ( - ui.button(on_click=lambda c=client: self._open_settings_dialog(c)) - .props('icon=edit_square flat dense round size=sm') - .classes('text-gray-400') - .mark('player-settings') - ) - # Disable the settings icon for a disabled player, matching the intent of "disable". - if minimized: - settings_btn.set_enabled(False) - dsp_btn = ( - ui.button(on_click=lambda c=client: ui.navigate.to(f'/player/{c.id}/dsp')) - .props('icon=equalizer flat dense round size=sm') - .classes('text-gray-400') - .mark('player-dsp') - ) - # A disabled player has no live pipeline to edit, so gray out its EQ icon too. - if minimized: - dsp_btn.set_enabled(False) - - if minimized: - continue - - with ui.row(wrap=False).classes('items-center gap-4 w-full'): - host = client.host - try: - init_vol = _camilladsp(host).get_percent_volume() - except Exception: - init_vol = CamillaDSPClient.DEFAULT_PERCENT_VOLUME - slider = self._build_volume_controls(client.id, init_vol, client.host) - if mute_cb is not None: - slider.bind_enabled_from(mute_cb, 'value', backward=lambda v: not v) - - async def _on_mute_change(self, client_id: str, muted: bool) -> None: - """Handles the Mute checkbox in the card header for the default Player Selection experience. - - Does not refresh the Players tab afterward, so the volume slider keeps its live - drag state — see `_reset_snap_volume` for the same rationale. - """ - await asyncio.to_thread(_snapserver(self.settings).set_client_volume, client_id, 100, muted=muted) - - async def _on_enabled_change(self, client, enabled: bool) -> None: - """Handles the 'disabled' Player Selection experience's enable/disable switch. - - Toggling off mutes the Snapcast client (the minimized-card state is derived from - `client.muted` on the next render); toggling on unmutes it. - """ - await asyncio.to_thread(_snapserver(self.settings).set_client_volume, client.id, 100, muted=not enabled) - self._build_players_tab.refresh() - - def _open_settings_dialog(self, client) -> None: - """Opens a settings popup for renaming, latency, and Snapcast volume reset.""" - self._dialog_open = True - - with ui.dialog() as dialog, ui.card().classes('w-96'): - ui.label('Settings').classes('font-medium text-lg mb-2') - - name_input = ui.input('Name', value=client.name).classes('w-full') - latency_input = ui.number('Latency (ms)', value=client.latency_ms, min=-500, max=500, step=1).classes('w-full') - - # Snapcast volume is a sidecar kept at 100/0; CamillaDSP controls actual loudness. - snap_vol = client.volume - with ui.row().classes('w-full items-start justify-between mt-2'): - with ui.column().classes('gap-0'): - ui.label('Snapcast Volume').classes('text-xs') - current_vol_label = ui.label(f'Current Volume {snap_vol}%').classes('text-xs text-gray-500') - ui.button('Reset', on_click=lambda c=client, lbl=current_vol_label: self._reset_snap_volume(c, lbl)).props( - 'dense' - ).classes('bg-gray-800 text-white') - - ui.separator().classes('mt-4 mb-2') - with ui.column().classes('text-xs text-gray-500 gap-1'): - ui.label(f'ID {client.id}') - ui.label(f'Host {client.host}') - ui.label(f'Group {client.group_id or "—"}') - - with ui.row().classes('justify-between w-full mt-4'): - - def _on_cancel(): - self._dialog_open = False - dialog.close() - - async def _on_save(c=client, ni=name_input, li=latency_input): - snap = _snapserver(self.settings) - if ni.value and ni.value != c.name: - snap.set_client_name(c.id, ni.value) - ui.notify(f'Renamed to "{ni.value}"', type='positive', position='top-right') - if int(li.value) != c.latency_ms: - snap.set_client_latency(c.id, int(li.value)) - ui.notify(f'Latency set to {int(li.value)} ms', type='positive', position='top-right') - - self._dialog_open = False - dialog.close() - self._build_players_tab.refresh() - - ui.button('Cancel', on_click=_on_cancel).props('flat dense') - ui.button('Save', on_click=_on_save).props('dense').classes('bg-gray-800 text-white') - - dialog.on('hide', lambda: setattr(self, '_dialog_open', False)) - dialog.open() - - def _reset_snap_volume(self, client, vol_label=None) -> None: - """Resets the Snapcast client volume to 100% / unmuted. - - If vol_label is provided (from the settings dialog), its text is updated to - reflect the new value. The players tab is *not* refreshed so that the CamillaDSP - volume sliders retain their current visual state. - """ - _snapserver(self.settings).set_client_volume(client.id, 100, muted=False) - if vol_label is not None: - vol_label.set_text('Current Volume 100%') - ui.notify('Snapcast volume reset to 100%', type='positive', position='top-right') - - def _build_volume_controls( - self, - client_id: str, - initial_volume: float, - client_host: str = '', - ) -> ui.slider: - """Renders a volume icon, slider, and live value label (routed through CamillaDSP). - - Volume is owned by the CamillaDSP daemon, which persists it durably via its - `--statefile`; the slider seeds from the daemon (`get_percent_volume`) and writes - through it (`set_percent_volume`) — the app keeps no replica. - - The slider is **always** a percent (0-100) control regardless of the 'volume' - feature selection, so the handle sits at the same physical spot when toggling - between modes. The selection only changes the value label: percent mode shows - `NN%`, dB mode shows `percent_to_db(value)` as `-N.N dB`. Both modes drive - CamillaDSP through `set_percent_volume`. - - Mute is anchored at the slider floor (`percent <= 0`, displayed as `MIN_DB` in dB - mode), never on lossy zero-rounding, so a mid-range edit never silently mutes the - client on the next refresh. The seed is step-aligned to the integer percent slider - so the periodic refresh does not fire a phantom `update:model-value`. - - Returns the `ui.slider` element so the caller can bind its enabled state to the - Mute checkbox built alongside it in the card header. - """ - camilla = _camilladsp(client_host) if client_host else _camilladsp('localhost') - # Named after the feature-flag constant so flag-gated UI is obvious to the reader. - FF_VOLUME_PERC_OR_DB = features.flag_enabled(self.settings, features.VOLUME_KEY, features.FF_VOLUME_PERC_OR_DB) - - async def _persist_and_sync(percent: float) -> None: - muted = percent <= 0 - await asyncio.to_thread( - _snapserver(self.settings).set_client_volume, - client_id, - 0 if muted else 100, - muted=muted, - ) - - async def _on_volume_percent(e): - percent = int(e.value) - try: - await asyncio.to_thread(camilla.set_percent_volume, percent) - except Exception: - pass - await _persist_and_sync(percent) - - ui.icon('volume_up').classes('text-gray-400') - slider = ui.slider(min=0, max=100, step=1, value=int(round(initial_volume)), on_change=_on_volume_percent).classes( - 'grow' - ) - # Fixed width + right-align keeps the slider length constant as the label text - # changes digit count (e.g. -9.0 dB -> -10.0 dB), so the handle doesn't shift. - value_label = ui.label().classes('text-xs text-gray-500 shrink-0 whitespace-nowrap text-right w-16') - if FF_VOLUME_PERC_OR_DB: - value_label.bind_text_from(slider, 'value', backward=lambda v: f'{camilla.percent_to_db(v):.1f} dB') - else: - value_label.bind_text_from(slider, 'value', backward=lambda v: f'{int(v)}%') - - return slider - - def _build_settings_tab(self) -> None: - """Renders the Settings tab — one single-select button group per registered UX feature.""" - ui.label('Features').classes('text-lg font-medium mb-2') - for feature in features.FEATURES: - ui.label(feature.label).classes('text-sm text-gray-500') - ui.toggle( - {option.value: option.label for option in feature.options}, - value=features.selected(self.settings, feature.key), - on_change=lambda e, key=feature.key: self._on_feature_change(key, e.value), - ).classes('mb-4') - - def _on_feature_change(self, key: str, value: str) -> None: - """Persists a feature-flag selection and refreshes the Players tab to reflect it.""" - self.settings.features[key] = value - settings_dal.save(self.settings) - self._build_players_tab.refresh() diff --git a/audera/ui/streamer/pages/__init__.py b/audera/ui/streamer/pages/__init__.py new file mode 100644 index 0000000..ff11110 --- /dev/null +++ b/audera/ui/streamer/pages/__init__.py @@ -0,0 +1,49 @@ +"""Audera streamer dashboard pages. + +The `Page` class holds the shared per-app state (settings, Snapcast client, dialog +flag) and registers routes; each route's rendering lives in its own module and is +invoked as `module.render(page, …)`, so the class stays thin while the large EQ editor +(`dsp`) and the multi-tab dashboard (`index`) live in dedicated files. +""" + +from dotenv import load_dotenv +from nicegui import ui + +from audera.ui.streamer.pages import dsp, index +from audera.ui.streamer.pages._clients import _load_settings, _snapserver + +load_dotenv() + + +class Page: + """A `class` that represents the streamer dashboard app.""" + + def __init__(self): + """Initializes an instance of the streamer dashboard app.""" + self.settings = _load_settings() + self._client = _snapserver(self.settings) + self._dialog_open: bool = False + + def load(self) -> None: + """Registers page routes.""" + ui.page('/')(self.index) + ui.page('/player/{player_id}/dsp')(self.dsp) + + def index(self) -> None: + """Renders the main dashboard page.""" + index.render(self) + + def dsp(self, player_id: str) -> None: + """Renders the full-page parametric-EQ editor for a single player.""" + dsp.render(self, player_id) + + # The refreshable tabs stay `@ui.refreshable` *methods* so each `Page` instance keys + # its own refresh targets (NiceGUI filters targets by `instance`); their bodies live in + # `index` and are re-run via `self._build__tab.refresh()`. + @ui.refreshable + def _build_players_tab(self) -> None: + index.build_players_tab(self) + + @ui.refreshable + def _build_services_tab(self) -> None: + index.build_services_tab(self) diff --git a/audera/ui/streamer/pages/_clients.py b/audera/ui/streamer/pages/_clients.py new file mode 100644 index 0000000..db215cb --- /dev/null +++ b/audera/ui/streamer/pages/_clients.py @@ -0,0 +1,28 @@ +"""Client factories and settings loader shared across the streamer pages.""" + +import os + +import audera +from audera.clients import CamillaDSPClient, SnapserverClient +from audera.dal import settings as settings_dal +from audera.models.settings import Settings +from audera.ui import features + + +def _load_settings() -> Settings: + return settings_dal.get_or_create( + Settings( + plexamp_host=os.getenv('AUDERA_PLEXAMP_HOST', 'localhost'), + snapserver_host=os.getenv('AUDERA_SNAPSERVER_HOST', 'localhost'), + features=features.default_selections(), + ) + ) + + +def _snapserver(settings: Settings) -> SnapserverClient: + return SnapserverClient(host=settings.snapserver_host, port=audera.SNAPSERVER_PORT) + + +def _camilladsp(host: str) -> CamillaDSPClient: + """Returns a CamillaDSPClient for the given player host.""" + return CamillaDSPClient(host=host) diff --git a/audera/ui/streamer/pages/_plex.py b/audera/ui/streamer/pages/_plex.py new file mode 100644 index 0000000..4d7e0b0 --- /dev/null +++ b/audera/ui/streamer/pages/_plex.py @@ -0,0 +1,89 @@ +"""PlexAmp status probing and the browser-OAuth claim/pin flow for the streamer UI.""" + +import os +import socket +import subprocess +import uuid +from importlib.metadata import version as _pkg_version +from typing import Literal, Optional + +import httpx + +import audera + +_PLEX_CLIENT_ID = str(uuid.uuid4()) +_PLEX_HEADERS = { + 'X-Plex-Product': audera.NAME, + 'X-Plex-Version': _pkg_version('audera'), + 'X-Plex-Client-Identifier': _PLEX_CLIENT_ID, + 'X-Plex-Platform': 'Linux', + 'Accept': 'application/json', +} + + +def _plexamp_state() -> Literal['inactive', 'unclaimed', 'claimed']: + try: + result = subprocess.run( + ['systemctl', 'is-active', 'plexamp'], + capture_output=True, + text=True, + timeout=5, + ) + if result.stdout.strip() != 'active': + return 'inactive' + except Exception: + return 'inactive' + try: + with socket.create_connection(('127.0.0.1', audera.PLEXAMP_PORT), timeout=1): + return 'claimed' + except OSError: + return 'unclaimed' + + +def _create_plex_pin() -> tuple[int, str]: + resp = httpx.post( + 'https://plex.tv/api/v2/pins', + params={'strong': 'true'}, + headers=_PLEX_HEADERS, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + return data['id'], data['code'] + + +def _poll_plex_pin(pin_id: int) -> Optional[str]: + resp = httpx.get( + f'https://plex.tv/api/v2/pins/{pin_id}', + headers=_PLEX_HEADERS, + timeout=5, + ) + resp.raise_for_status() + return resp.json().get('authToken') or None + + +def _get_claim_token(auth_token: str) -> str: + resp = httpx.get( + 'https://plex.tv/api/claim/token.json', + headers={**_PLEX_HEADERS, 'X-Plex-Token': auth_token}, + timeout=10, + ) + resp.raise_for_status() + return resp.json()['token'] + + +def _restart_plexamp_with_claim(claim_token: str) -> None: + subprocess.run(['systemctl', 'stop', 'plexamp'], timeout=15, check=True) + override_dir = '/etc/systemd/system/plexamp.service.d' + os.makedirs(override_dir, exist_ok=True) + with open(f'{override_dir}/claim.conf', 'w') as f: + f.write(f'[Service]\nEnvironment=PLEXAMP_CLAIM_TOKEN={claim_token}\n') + subprocess.run(['systemctl', 'daemon-reload'], timeout=10, check=True) + subprocess.run(['systemctl', 'start', 'plexamp'], timeout=10, check=True) + + +def _remove_claim_override() -> None: + override = '/etc/systemd/system/plexamp.service.d/claim.conf' + if os.path.exists(override): + os.remove(override) + subprocess.run(['systemctl', 'daemon-reload'], timeout=10) diff --git a/audera/ui/streamer/pages/dsp.py b/audera/ui/streamer/pages/dsp.py new file mode 100644 index 0000000..363fe69 --- /dev/null +++ b/audera/ui/streamer/pages/dsp.py @@ -0,0 +1,393 @@ +"""The full-page parametric-EQ editor for a single player.""" + +import asyncio +import uuid +from typing import TYPE_CHECKING, Literal, get_args + +from nicegui import ui + +import audera +from audera.dal import dsp as dsp_dal +from audera.dal import presets as presets_dal +from audera.domains.dsp import auto_preamp_db, clone_bands, compile_pipeline, format_rew, loudness_preset, parse_rew +from audera.models.dsp import PASS_TYPES, Band, DSPConfig, Preset +from audera.ui import components +from audera.ui.streamer.pages._clients import _camilladsp, _snapserver + +if TYPE_CHECKING: + from audera.ui.streamer.pages import Page + +# Derived from the model literal so the editor's type choices can never drift from +# `audera.models.dsp.Band`. Pass filters carry no gain, so their gain field is disabled +# (their membership is tested against the shared `PASS_TYPES` taxonomy). +_BAND_TYPES = list(get_args(Band.model_fields['type'].annotation)) + + +def render(page: 'Page', player_id: str) -> None: + """Renders the full-page parametric-EQ editor for a single player. + + Bands are the source of truth; they are compiled to a live CamillaDSP pipeline + on Save. Per-page edit state lives in closures (not on `page`, which is shared + across every connected client): `state['saved']` mirrors the persisted config and + `state['staged']` is the working copy that is compiled, validated, and pushed on + Save. Scalar field edits mutate a band in place and only recompute the dirty + indicator, clip-safe pre-amp clamp, and live response chart; structural changes + (add/delete/type/preset/reset) refresh the band table. + """ + components.header.render(audera.NAME, 'Streamer') + + snap = _snapserver(page.settings) + try: + clients = snap.get_clients() + except Exception: + clients = [] + live = next((client for client in clients if client.id == player_id), None) + + if live is None: + with ui.column().classes('w-full gap-2 p-4'): + ui.link('‹ Players', '/') + ui.label('Player not found or unreachable.').classes('text-gray-500') + return + + # The config is keyed by the player's own id (`dsp/{player_id}.json` is the link), + # so `live.id` is all that's needed to resolve it. + saved = dsp_dal.get_or_create(DSPConfig(player_id=live.id)) + state = {'saved': saved, 'staged': saved.model_copy(deep=True)} + + def _dirty() -> bool: + return state['staged'] != state['saved'] + + def _mark_changed() -> None: + """Clamps pre-amp to the clip-safe ceiling, then refreshes dirty state, chart, count.""" + clamped = min(state['staged'].preamp_db, auto_preamp_db(state['staged'].bands)) + if clamped != state['staged'].preamp_db: # min is a fixpoint — converges in one pass + state['staged'].preamp_db = clamped + preamp_field.value = clamped + dirty_label.set_visibility(_dirty()) + has_bands = bool(state['staged'].bands) # chart only once a band exists + chart.set_visibility(has_bands) + chart_message.set_visibility(not has_bands) + if has_bands: + # `EChart.options` is a read-only view onto the live props dict; swap its + # contents in place (the documented "change the options" push) and redraw. + chart.options.clear() + chart.options.update(components.response_plot.options(state['staged'])) + chart.update() + count_label.set_text(f'Bands ({len(state["staged"].bands)})') + + def _on_preamp(e) -> None: + if e.value is not None: + state['staged'].preamp_db = float(e.value) + _mark_changed() + + def _on_enabled(band: Band, value: bool) -> None: + band.enabled = bool(value) + _mark_changed() + + def _on_type(band: Band, value: str) -> None: + # `value` is constrained to `_BAND_TYPES` by the select; the assignment is + # unvalidated (Band sets no `validate_assignment`), so the literal narrows fine. + band.type = value # type: ignore + _band_table.refresh() # the gain field's enabled state depends on the type + _mark_changed() + + def _on_freq(band: Band, value) -> None: + if value is not None: + band.freq = float(value) + _mark_changed() + + def _on_gain(band: Band, value) -> None: + if value is not None: + band.gain = float(value) + _mark_changed() + + def _on_q(band: Band, value) -> None: + if value is not None: + band.q = float(value) + _mark_changed() + + def _add_band() -> None: + state['staged'].bands.append(Band(id=uuid.uuid4().hex, type='Peaking', freq=1000.0, gain=0.0, q=0.707)) + _band_table.refresh() + _mark_changed() + + def _remove_band(band: Band) -> None: + state['staged'].bands = [b for b in state['staged'].bands if b.id != band.id] + _band_table.refresh() + _mark_changed() + + def _apply_preset(kind: Literal['loudness', 'flat']) -> None: + if kind == 'loudness': + state['staged'].bands.extend(loudness_preset()) + else: + state['staged'].bands = [] + _band_table.refresh() + _mark_changed() + + def _apply_saved_preset(preset: Preset) -> None: + """Appends fresh clones of a saved preset's bands onto the staged config. + + Apply = append + clone (never replace); the clones carry fresh ids so their + `audera_peq_` filter names can't collide. Routing through `_mark_changed` + re-clamps the pre-amp and redraws the chart over the merged set — same wiring + as REW import. + """ + state['staged'].bands.extend(clone_bands(preset.bands)) + _band_table.refresh() + _mark_changed() + + def _delete_preset(preset: Preset) -> None: + presets_dal.delete_preset(preset.id) + _presets_menu.refresh() + ui.notify(f'Deleted preset "{preset.name}"', type='positive', position='top-right') + + def _open_save_preset_dialog() -> None: + """Opens a dialog to capture the current bands as a named, reusable preset.""" + with ui.dialog() as dialog, ui.card().classes('w-96'): + ui.label('Save preset').classes('font-medium text-lg mb-1') + ui.label('Capture the current bands as a reusable preset you can append to any player.').classes( + 'text-xs text-gray-500 mb-2' + ) + name_field = ( + ui.input('Preset name', placeholder='My preset') + .props('outlined dense') + .classes('w-full') + .mark('preset-save-name') + ) + + def _on_save_preset() -> None: + preset = Preset( + id=uuid.uuid4().hex, + name=(name_field.value or '').strip() or 'Untitled', + bands=clone_bands(state['staged'].bands), + ) + presets_dal.save_preset(preset) + _presets_menu.refresh() + ui.notify(f'Saved preset "{preset.name}"', type='positive', position='top-right') + dialog.close() + + with ui.row().classes('justify-between w-full mt-2'): + ui.button('Cancel', on_click=dialog.close).props('flat dense') + ui.button('Save', on_click=_on_save_preset).props('dense').classes('bg-gray-800 text-white').mark( + 'preset-save-run' + ) + + dialog.open() + + @ui.refreshable + def _presets_menu() -> None: + ui.menu_item('Loudness (seed bands)', on_click=lambda: _apply_preset('loudness')).mark('preset-loudness') + ui.menu_item('Flat / clear all bands', on_click=lambda: _apply_preset('flat')).mark('preset-flat') + saved = presets_dal.get_all_presets() + if saved: + ui.separator() + for preset in saved: + with ui.menu_item(on_click=lambda p=preset: _apply_saved_preset(p)).mark('preset-saved'): + with ui.row().classes('items-center justify-between w-full'): + ui.label(preset.name) + ( + ui.button(icon='delete', on_click=lambda p=preset: _delete_preset(p)) + .props('flat dense round size=sm') + .classes('text-gray-400') + .on('click.stop') # .stop: delete doesn't also fire append + ) + ui.separator() + ui.menu_item('Save current as preset…', on_click=_open_save_preset_dialog).mark('preset-save-as') + + def _open_import_dialog() -> None: + """Opens a paste-import dialog that appends CamillaDSP YAML filters as bands. + + REW (v5.20.14+) exports its parametric EQ as CamillaDSP YAML — the same + `filters:` shape our compiler emits — so its exports paste in directly. Import is + append-only (it never replaces existing bands) and routes through `_mark_changed`, + so the auto-ceiling re-clamps the pre-amp and the chart redraws over the merged + band set. Unsupported filter types are surfaced in the notification rather than + dropped. + """ + with ui.dialog() as dialog, ui.card().classes('w-96'): + ui.label('Import CamillaDSP YAML').classes('font-medium text-lg mb-1') + ui.label( + 'Paste a CamillaDSP YAML export (e.g. from REW). Biquad filters are appended to the current ' + 'configuration; the pre-amp stays auto-protected.' + ).classes('text-xs text-gray-500 mb-2') + text = ( + ui.textarea( + placeholder='filters:\n band_1:\n type: Biquad\n parameters:' + '\n type: Peaking\n freq: 1000\n q: 1.41\n gain: -3.0' + ) + .props('outlined autogrow') + .classes('w-full font-mono') + ) + + def _on_import() -> None: + result = parse_rew(text.value or '') + state['staged'].bands.extend(result.bands) + _band_table.refresh() + _mark_changed() # re-clamps pre-amp + redraws chart over the merged bands + message = f'Imported {len(result.bands)} band(s)' + if result.skipped: + message += f', skipped {len(result.skipped)} filter(s)' + ui.notify(message, type='positive', position='top-right') + dialog.close() + + with ui.row().classes('justify-between w-full mt-2'): + ui.button('Cancel', on_click=dialog.close).props('flat dense') + ui.button('Import', on_click=_on_import).props('dense').classes('bg-gray-800 text-white').mark( + 'config-import-run' + ) + + dialog.open() + + def _open_export_dialog() -> None: + """Opens a dialog showing the *saved* configuration as CamillaDSP YAML. + + The YAML carries the `filters:` tag REW (v5.20.14+) requires to re-import, so a + round-trip out to REW and back is lossless. The text is always the saved config + (never the staged edits): a partial edit would export a config that never + clip-guarded, so Save is the gate. When the editor is dirty a banner spells this + out; Copy and Download surface the same text. + """ + yaml_text = format_rew(state['saved'].preamp_db, state['saved'].bands) + with ui.dialog() as dialog, ui.card().classes('w-96'): + ui.label('Export CamillaDSP YAML').classes('font-medium text-lg mb-1') + if _dirty(): + ui.label( + 'There are unsaved changes. Export downloads only the saved configuration — ' + 'Save first to export the current editor contents.' + ).classes('text-xs text-amber-500 mb-2').mark('export-unsaved-banner') + ui.textarea(value=yaml_text).props('outlined readonly autogrow').classes('w-full font-mono') + + def _on_copy() -> None: + ui.clipboard.write(yaml_text) # synchronous in NiceGUI 3.11.1 + ui.notify('Copied to clipboard', type='positive', position='top-right') + + def _on_download() -> None: + ui.download.content(yaml_text, f'{live.name}-dsp.yml') + + with ui.row().classes('justify-between w-full mt-2'): + ui.button('Close', on_click=dialog.close).props('flat dense') + ui.button('Copy', on_click=_on_copy).props('dense') + ui.button('Download .yml', on_click=_on_download).props('dense').classes('bg-gray-800 text-white') + + dialog.open() + + def _on_reset() -> None: + state['staged'] = state['saved'].model_copy(deep=True) + preamp_field.value = state['staged'].preamp_db + _band_table.refresh() + _mark_changed() + + async def _on_save() -> None: + """Compiles → validates → pushes the live pipeline, then persists the config. + + Pattern B (live apply, no restart): the daemon owns volume and `SetConfigJson` + leaves the fader untouched, so no volume snapshot/restore is needed. The config + is keyed by the player id (`dsp/{id}.json`), so Save only updates that one file. + """ + camilla = _camilladsp(live.host) + try: + current = await asyncio.to_thread(camilla.get_config) + compiled = compile_pipeline(current, state['staged']) + await asyncio.to_thread(camilla.validate_config, compiled) # gate; raises on invalid + await asyncio.to_thread(camilla.set_config, compiled) + except Exception as exc: + ui.notify(f'Save failed: {exc}', type='negative', position='top-right') + return + await asyncio.to_thread(dsp_dal.update, state['staged']) + try: + await asyncio.to_thread(camilla.reset_clipped_samples) # start the clip watch fresh + except Exception: + pass + state['saved'] = state['staged'].model_copy(deep=True) + _mark_changed() + ui.notify('Saved', type='positive', position='top-right') + + async def _poll_clips() -> None: + try: + count = await asyncio.to_thread(_camilladsp(live.host).get_clipped_samples) + except Exception: + return + if count: + clip_label.set_text(f'⚠ {count} clipped samples') + clip_label.set_visibility(True) + else: + clip_label.set_visibility(False) + + @ui.refreshable + def _band_table() -> None: + with ui.row(wrap=False).classes('items-center gap-2 w-full text-xs text-gray-500'): + ui.label('On').classes('w-10 text-center') + ui.label('Type').classes('w-32') + ui.label('Freq (Hz)').classes('w-24') + ui.label('Gain (dB)').classes('w-24') + ui.label('Q').classes('w-20') + ui.label('').classes('w-10') + for band in state['staged'].bands: + with ui.row(wrap=False).classes('items-center gap-2 w-full'): + ui.checkbox(value=band.enabled, on_change=lambda e, b=band: _on_enabled(b, e.value)).classes('w-10') + ui.select(_BAND_TYPES, value=band.type, on_change=lambda e, b=band: _on_type(b, e.value)).props( + 'dense outlined' + ).classes('w-32') + ui.number(value=band.freq, step=1, format='%.0f', on_change=lambda e, b=band: _on_freq(b, e.value)).props( + 'dense outlined debounce=200' + ).classes('w-24') + gain_field = ( + ui.number(value=band.gain, step=0.1, format='%.1f', on_change=lambda e, b=band: _on_gain(b, e.value)) + .props('dense outlined debounce=200') + .classes('w-24') + ) + gain_field.set_enabled(band.type not in PASS_TYPES) + ui.number(value=band.q, step=0.001, format='%.3f', on_change=lambda e, b=band: _on_q(b, e.value)).props( + 'dense outlined debounce=200' + ).classes('w-20') + ui.button(icon='delete', on_click=lambda b=band: _remove_band(b)).props('flat dense round size=sm').classes( + 'text-gray-400' + ) + ui.button('+ Add band', on_click=_add_band).props('flat dense').classes('mt-2') + + with ui.row().classes('items-center justify-between w-full'): + ui.label(f'{live.name} · Advanced DSP').classes('text-lg font-medium') + ui.link('‹ Players', '/') + + with ui.column().classes('w-full gap-3'): + with ui.row().classes('items-center gap-4 w-full'): + preamp_field = ( + ui.number( + 'Pre-amp (dB) · auto-protected', + value=state['staged'].preamp_db, + step=0.1, + format='%.1f', + on_change=_on_preamp, + ) + .props('dense outlined') + .classes('w-40') + ) + with ui.button('Presets', icon='tune').props('flat dense'): + with ui.menu(): + _presets_menu() + with ui.button('Config', icon='import_export').props('flat dense'): + with ui.menu(): + ui.menu_item('Import CamillaDSP YAML…', on_click=_open_import_dialog).mark('config-import') + ui.menu_item('Export CamillaDSP YAML…', on_click=_open_export_dialog).mark('config-export') + ui.space() + ui.button('Reset', on_click=_on_reset).props('flat dense') + ui.button('Save', on_click=_on_save).props('dense').classes('bg-gray-800 text-white') + + # Persistent handle + empty-state message, both toggled by the forward-closure + # `_mark_changed`: the chart shows only once a band exists, the message otherwise. + chart = components.response_plot.render(state['staged']) + chart_message = ui.label( + 'Add a band to see the live frequency-response curve — start from Presets ▾, or + Add band below.' + ).classes('text-sm text-gray-500 p-4') + + _band_table() + + with ui.row().classes('items-center gap-4 w-full mt-2 text-xs text-gray-500'): + count_label = ui.label() + ui.label('IIR biquads · ~0% CPU') + dirty_label = ui.label('Unsaved changes ●').classes('text-amber-500') + clip_label = ui.label('').classes('text-red-500') + clip_label.set_visibility(False) # hidden until the clip poll reports a nonzero count + + _mark_changed() + ui.timer(3.0, _poll_clips) diff --git a/audera/ui/streamer/pages/index.py b/audera/ui/streamer/pages/index.py new file mode 100644 index 0000000..0dcc310 --- /dev/null +++ b/audera/ui/streamer/pages/index.py @@ -0,0 +1,375 @@ +"""The main dashboard page — Players, Services, and Settings tabs.""" + +import asyncio +from typing import TYPE_CHECKING + +from nicegui import ui + +import audera +from audera.clients import CamillaDSPClient +from audera.dal import settings as settings_dal +from audera.ui import components, features +from audera.ui.streamer.pages import _plex +from audera.ui.streamer.pages._clients import _camilladsp, _snapserver + +if TYPE_CHECKING: + from audera.ui.streamer.pages import Page + + +def render(page: 'Page') -> None: + """Renders the main dashboard page.""" + components.header.render(audera.NAME, 'Streamer') + + with ui.tabs().classes('w-full') as tabs: + players_tab = ui.tab('Players') + services_tab = ui.tab('Services') + settings_tab = ui.tab('Settings') + + with ui.tab_panels(tabs, value=players_tab).classes('w-full'): + with ui.tab_panel(players_tab): + page._build_players_tab() # type: ignore + with ui.tab_panel(services_tab): + page._build_services_tab() # type: ignore + with ui.tab_panel(settings_tab): + _build_settings_tab(page) + + def _maybe_refresh(): + if not page._dialog_open: + page._build_players_tab.refresh() + + ui.timer(10.0, _maybe_refresh) + + +def build_services_tab(page: 'Page') -> None: + """Renders the Services tab — shows PlexAmp status and a browser-based OAuth claiming flow. + + Called by `Page._build_services_tab`, the `@ui.refreshable` method that owns the refresh + target (so refreshes are keyed per `Page` instance). + """ + state = _plex._plexamp_state() + + with ui.card().classes('w-full mb-2'): + with ui.row().classes('items-center justify-between w-full'): + ui.label('PlexAmp Headless').classes('font-medium') + if state == 'claimed': + ui.label('available').classes('text-sm text-green-500') + elif state == 'unclaimed': + ui.label('setup required').classes('text-sm text-amber-500') + else: + ui.label('inactive').classes('text-sm text-red-500') + + if state == 'claimed': + ui.link('Open PlexAmp', 'https://plexamp.local').classes('text-sm mt-1') + + elif state == 'unclaimed': + connect_btn = ui.button('Connect with Plex').classes('mt-2') + status_label = ui.label('').classes('text-sm text-gray-500 mt-1') + auth_link = ui.link("Didn't open? Click here to authorize with Plex", '#', new_tab=True).classes('text-sm mt-1') + auth_link.set_visibility(False) + + async def _on_connect(): + connect_btn.disable() + status_label.set_text('Opening Plex authorization…') + + try: + pin_id, pin_code = await asyncio.to_thread(_plex._create_plex_pin) + except Exception as exc: + status_label.set_text(f'Error: {exc}') + connect_btn.enable() + return + + auth_url = ( + f'https://app.plex.tv/auth/#!?clientID={_plex._PLEX_CLIENT_ID}' + f'&code={pin_code}' + f'&context%5Bdevice%5D%5Bproduct%5D={audera.NAME}' + ) + ui.navigate.to(auth_url, new_tab=True) + status_label.set_text('Waiting for Plex authorization…') + auth_link.props(f'href="{auth_url}"') + auth_link.set_visibility(True) + + deadline = asyncio.get_event_loop().time() + 300 # 5-minute timeout + poll_timer: list[ui.timer] = [] + + async def _poll_auth(): + if asyncio.get_event_loop().time() > deadline: + poll_timer[0].cancel() + status_label.set_text('Authorization timed out. Please try again.') + connect_btn.enable() + return + + try: + auth_token = await asyncio.to_thread(_plex._poll_plex_pin, pin_id) + except Exception: + return # transient error; retry on next tick + + if not auth_token: + return + + poll_timer[0].cancel() + status_label.set_text('Authorized. Claiming PlexAmp…') + + try: + claim_token = await asyncio.to_thread(_plex._get_claim_token, auth_token) + await asyncio.to_thread(_plex._restart_plexamp_with_claim, claim_token) + except Exception as exc: + status_label.set_text(f'Claim failed: {exc}') + connect_btn.enable() + return + + status_label.set_text('PlexAmp restarting…') + + port_deadline = asyncio.get_event_loop().time() + 120 + port_timer: list[ui.timer] = [] + + async def _poll_port(): + if asyncio.get_event_loop().time() > port_deadline: + port_timer[0].cancel() + _plex._remove_claim_override() + status_label.set_text('PlexAmp did not come up in time. Check the service.') + connect_btn.enable() + return + + if _plex._plexamp_state() == 'claimed': + port_timer[0].cancel() + _plex._remove_claim_override() + page._build_services_tab.refresh() + + port_timer.append(ui.timer(2.0, _poll_port)) + + poll_timer.append(ui.timer(2.0, _poll_auth)) + + connect_btn.on('click', _on_connect) + + +def build_players_tab(page: 'Page') -> None: + """Renders the Players tab — lists Snapcast clients with per-client volume and mute/enable controls. + + Called by `Page._build_players_tab`, the `@ui.refreshable` method that owns the refresh + target (so refreshes are keyed per `Page` instance). + """ + snap = _snapserver(page.settings) + try: + clients = snap.get_clients() + except Exception: + clients = [] + + connected_clients = [c for c in clients if c.connected] + if not connected_clients: + ui.label('No Snapcast clients found.').classes('text-gray-500') + return + + # Named after the feature-flag constant so flag-gated UI is obvious to the reader. + FF_DISABLED_VS_MUTE = features.flag_enabled(page.settings, features.PLAYER_SELECTION_KEY, features.FF_DISABLED_VS_MUTE) + + for client in connected_clients: + minimized = FF_DISABLED_VS_MUTE and client.muted + with ui.card().classes('w-full mb-2'): + mute_cb = None + with ui.row().classes('items-center justify-between w-full'): + with ui.row().classes('items-center gap-2'): + if FF_DISABLED_VS_MUTE: + ui.switch(value=not client.muted, on_change=lambda e, c=client: _on_enabled_change(page, c, e.value)) + # A disabled player is grayed out to reinforce the "disabled" state. + name_label = ui.label(client.name).classes('font-medium') + if minimized: + name_label.classes('text-gray-400') + with ui.row().classes('items-center gap-2'): + if not FF_DISABLED_VS_MUTE: + mute_cb = ui.checkbox( + 'Mute', value=client.muted, on_change=lambda e, c=client: _on_mute_change(page, c.id, e.value) + ) + settings_btn = ( + ui.button(on_click=lambda c=client: _open_settings_dialog(page, c)) + .props('icon=edit_square flat dense round size=sm') + .classes('text-gray-400') + .mark('player-settings') + ) + # Disable the settings icon for a disabled player, matching the intent of "disable". + if minimized: + settings_btn.set_enabled(False) + dsp_btn = ( + ui.button(on_click=lambda c=client: ui.navigate.to(f'/player/{c.id}/dsp')) + .props('icon=equalizer flat dense round size=sm') + .classes('text-gray-400') + .mark('player-dsp') + ) + # A disabled player has no live pipeline to edit, so gray out its EQ icon too. + if minimized: + dsp_btn.set_enabled(False) + + if minimized: + continue + + with ui.row(wrap=False).classes('items-center gap-4 w-full'): + host = client.host + try: + init_vol = _camilladsp(host).get_percent_volume() + except Exception: + init_vol = CamillaDSPClient.DEFAULT_PERCENT_VOLUME + slider = _build_volume_controls(page, client.id, init_vol, client.host) + if mute_cb is not None: + slider.bind_enabled_from(mute_cb, 'value', backward=lambda v: not v) + + +async def _on_mute_change(page: 'Page', client_id: str, muted: bool) -> None: + """Handles the Mute checkbox in the card header for the default Player Selection experience. + + Does not refresh the Players tab afterward, so the volume slider keeps its live + drag state — see `_reset_snap_volume` for the same rationale. + """ + await asyncio.to_thread(_snapserver(page.settings).set_client_volume, client_id, 100, muted=muted) + + +async def _on_enabled_change(page: 'Page', client, enabled: bool) -> None: + """Handles the 'disabled' Player Selection experience's enable/disable switch. + + Toggling off mutes the Snapcast client (the minimized-card state is derived from + `client.muted` on the next render); toggling on unmutes it. + """ + await asyncio.to_thread(_snapserver(page.settings).set_client_volume, client.id, 100, muted=not enabled) + page._build_players_tab.refresh() + + +def _open_settings_dialog(page: 'Page', client) -> None: + """Opens a settings popup for renaming, latency, and Snapcast volume reset.""" + page._dialog_open = True + + with ui.dialog() as dialog, ui.card().classes('w-96'): + ui.label('Settings').classes('font-medium text-lg mb-2') + + name_input = ui.input('Name', value=client.name).classes('w-full') + latency_input = ui.number('Latency (ms)', value=client.latency_ms, min=-500, max=500, step=1).classes('w-full') + + # Snapcast volume is a sidecar kept at 100/0; CamillaDSP controls actual loudness. + snap_vol = client.volume + with ui.row().classes('w-full items-start justify-between mt-2'): + with ui.column().classes('gap-0'): + ui.label('Snapcast Volume').classes('text-xs') + current_vol_label = ui.label(f'Current Volume {snap_vol}%').classes('text-xs text-gray-500') + ui.button('Reset', on_click=lambda c=client, lbl=current_vol_label: _reset_snap_volume(page, c, lbl)).props( + 'dense' + ).classes('bg-gray-800 text-white') + + ui.separator().classes('mt-4 mb-2') + with ui.column().classes('text-xs text-gray-500 gap-1'): + ui.label(f'ID {client.id}') + ui.label(f'Host {client.host}') + ui.label(f'Group {client.group_id or "—"}') + + with ui.row().classes('justify-between w-full mt-4'): + + def _on_cancel(): + page._dialog_open = False + dialog.close() + + async def _on_save(c=client, ni=name_input, li=latency_input): + snap = _snapserver(page.settings) + if ni.value and ni.value != c.name: + snap.set_client_name(c.id, ni.value) + ui.notify(f'Renamed to "{ni.value}"', type='positive', position='top-right') + if int(li.value) != c.latency_ms: + snap.set_client_latency(c.id, int(li.value)) + ui.notify(f'Latency set to {int(li.value)} ms', type='positive', position='top-right') + + page._dialog_open = False + dialog.close() + page._build_players_tab.refresh() + + ui.button('Cancel', on_click=_on_cancel).props('flat dense') + ui.button('Save', on_click=_on_save).props('dense').classes('bg-gray-800 text-white') + + dialog.on('hide', lambda: setattr(page, '_dialog_open', False)) + dialog.open() + + +def _reset_snap_volume(page: 'Page', client, vol_label=None) -> None: + """Resets the Snapcast client volume to 100% / unmuted. + + If vol_label is provided (from the settings dialog), its text is updated to + reflect the new value. The players tab is *not* refreshed so that the CamillaDSP + volume sliders retain their current visual state. + """ + _snapserver(page.settings).set_client_volume(client.id, 100, muted=False) + if vol_label is not None: + vol_label.set_text('Current Volume 100%') + ui.notify('Snapcast volume reset to 100%', type='positive', position='top-right') + + +def _build_volume_controls( + page: 'Page', + client_id: str, + initial_volume: float, + client_host: str = '', +) -> ui.slider: + """Renders a volume icon, slider, and live value label (routed through CamillaDSP). + + Volume is owned by the CamillaDSP daemon, which persists it durably via its + `--statefile`; the slider seeds from the daemon (`get_percent_volume`) and writes + through it (`set_percent_volume`) — the app keeps no replica. + + The slider is **always** a percent (0-100) control regardless of the 'volume' + feature selection, so the handle sits at the same physical spot when toggling + between modes. The selection only changes the value label: percent mode shows + `NN%`, dB mode shows `percent_to_db(value)` as `-N.N dB`. Both modes drive + CamillaDSP through `set_percent_volume`. + + Mute is anchored at the slider floor (`percent <= 0`, displayed as `MIN_DB` in dB + mode), never on lossy zero-rounding, so a mid-range edit never silently mutes the + client on the next refresh. The seed is step-aligned to the integer percent slider + so the periodic refresh does not fire a phantom `update:model-value`. + + Returns the `ui.slider` element so the caller can bind its enabled state to the + Mute checkbox built alongside it in the card header. + """ + camilla = _camilladsp(client_host) if client_host else _camilladsp('localhost') + # Named after the feature-flag constant so flag-gated UI is obvious to the reader. + FF_VOLUME_PERC_OR_DB = features.flag_enabled(page.settings, features.VOLUME_KEY, features.FF_VOLUME_PERC_OR_DB) + + async def _persist_and_sync(percent: float) -> None: + muted = percent <= 0 + await asyncio.to_thread( + _snapserver(page.settings).set_client_volume, + client_id, + 0 if muted else 100, + muted=muted, + ) + + async def _on_volume_percent(e): + percent = int(e.value) + try: + await asyncio.to_thread(camilla.set_percent_volume, percent) + except Exception: + pass + await _persist_and_sync(percent) + + ui.icon('volume_up').classes('text-gray-400') + slider = ui.slider(min=0, max=100, step=1, value=int(round(initial_volume)), on_change=_on_volume_percent).classes('grow') + # Fixed width + right-align keeps the slider length constant as the label text + # changes digit count (e.g. -9.0 dB -> -10.0 dB), so the handle doesn't shift. + value_label = ui.label().classes('text-xs text-gray-500 shrink-0 whitespace-nowrap text-right w-16') + if FF_VOLUME_PERC_OR_DB: + value_label.bind_text_from(slider, 'value', backward=lambda v: f'{camilla.percent_to_db(v):.1f} dB') + else: + value_label.bind_text_from(slider, 'value', backward=lambda v: f'{int(v)}%') + + return slider + + +def _build_settings_tab(page: 'Page') -> None: + """Renders the Settings tab — one single-select button group per registered UX feature.""" + ui.label('Features').classes('text-lg font-medium mb-2') + for feature in features.FEATURES: + ui.label(feature.label).classes('text-sm text-gray-500') + ui.toggle( + {option.value: option.label for option in feature.options}, + value=features.selected(page.settings, feature.key), + on_change=lambda e, key=feature.key: _on_feature_change(page, key, e.value), + ).classes('mb-4') + + +def _on_feature_change(page: 'Page', key: str, value: str) -> None: + """Persists a feature-flag selection and refreshes the Players tab to reflect it.""" + page.settings.features[key] = value + settings_dal.save(page.settings) + page._build_players_tab.refresh() diff --git a/os/dietpi/lib/common.sh b/os/dietpi/lib/common.sh index 62e91d8..4dcf265 100644 --- a/os/dietpi/lib/common.sh +++ b/os/dietpi/lib/common.sh @@ -50,8 +50,7 @@ install_camilladsp() { } # Writes the camilladsp systemd service unit, captures from ALSA loopback, plays to -# physical DAC (hw:0). Volume is persisted by the daemon via --statefile, so no --gain -# seed is passed; the fader is restored from the statefile across restarts. +# physical DAC (hw:0). Volume is persisted by the daemon via --statefile. write_camilladsp_service() { local config_path="$1" local statefile_path="$2" diff --git a/pyproject.toml b/pyproject.toml index 15d2aff..8683b84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "python-dotenv>=1.2.2", "httpx>=0.28.1", "websockets>=16.0", + "pyyaml>=6.0", "camilladsp-plot @ git+https://github.com/HEnquist/pycamilladsp-plot@v3.0.2", ] diff --git a/tests/dal/test_dsp.py b/tests/dal/test_dsp.py index 1bb7255..1ac097e 100644 --- a/tests/dal/test_dsp.py +++ b/tests/dal/test_dsp.py @@ -1,33 +1,27 @@ import audera.dal.dsp as dsp_dal -import audera.dal.presets as presets_dal -from audera.models.dsp import Band, DSPConfig, Preset -from audera.models.player import Player +from audera.models.dsp import Band, DSPConfig -def _make_dsp(id='dsp-1') -> DSPConfig: +def _make_dsp(player_id='abc123') -> DSPConfig: return DSPConfig( - id=id, + player_id=player_id, preamp_db=-6.0, - bands=[Band(id='b1', type='LowShelf', freq=90.0, gain=10.0)], + bands=[Band(id='b1', type='Lowshelf', freq=90.0, gain=10.0)], enabled=True, ) -def _make_player(id='abc123') -> Player: - return Player(id=id, host='192.168.1.50', port=1704, connected=True) - - def test_dsp_create(audera_home): config = _make_dsp() dsp_dal.create(config) - assert dsp_dal.exists(config.id) + assert dsp_dal.exists(config.player_id) def test_dsp_get(audera_home): config = _make_dsp() dsp_dal.create(config) - result = dsp_dal.get(config.id) + result = dsp_dal.get(config.player_id) assert result == config @@ -38,40 +32,50 @@ def test_dsp_update(audera_home): updated = config.model_copy(update={'enabled': False}) dsp_dal.update(updated) - result = dsp_dal.get(config.id) + result = dsp_dal.get(config.player_id) assert result.enabled is False def test_dsp_delete(audera_home): config = _make_dsp() dsp_dal.create(config) - dsp_dal.delete(config.id) - assert not dsp_dal.exists(config.id) + dsp_dal.delete(config.player_id) + assert not dsp_dal.exists(config.player_id) def test_dsp_bands_round_trip(audera_home): config = DSPConfig( - id='dsp-bands', + player_id='dsp-bands', preamp_db=-3.0, bands=[ - Band(id='b1', type='LowShelf', freq=90.0, gain=10.0, q=0.7), - Band(id='b2', type='HighShelf', freq=8000.0, gain=6.0, q=0.7), + Band(id='b1', type='Lowshelf', freq=90.0, gain=10.0, q=0.7), + Band(id='b2', type='Highshelf', freq=8000.0, gain=6.0, q=0.7), Band(id='b3', type='Peaking', freq=1000.0, gain=-3.0, q=2.0, enabled=False), ], ) dsp_dal.create(config) - result = dsp_dal.get(config.id) + result = dsp_dal.get(config.player_id) assert result == config - assert [b.type for b in result.bands] == ['LowShelf', 'HighShelf', 'Peaking'] + assert [b.type for b in result.bands] == ['Lowshelf', 'Highshelf', 'Peaking'] def test_dsp_get_or_create_creates(audera_home): config = _make_dsp() - assert not dsp_dal.exists(config.id) + assert not dsp_dal.exists(config.player_id) dsp_dal.get_or_create(config) - assert dsp_dal.exists(config.id) + assert dsp_dal.exists(config.player_id) + + +def test_dsp_get_or_create_creates_keyed_by_player_id(audera_home): + # The config file is `dsp/{player_id}.json` — the filename is the link to the player. + config = dsp_dal.get_or_create(DSPConfig(player_id='abc123')) + + assert config.player_id == 'abc123' + assert dsp_dal.exists('abc123') + assert config.bands == [] + assert config.preamp_db == 0.0 def test_dsp_get_or_create_reads(audera_home): @@ -82,43 +86,11 @@ def test_dsp_get_or_create_reads(audera_home): assert result == config -def test_resolve_for_player_creates_keyed_by_player_id(audera_home): - player = _make_player() - - config = dsp_dal.resolve_for_player(player) - - # The config is created keyed by the player's own id — the filename is the link. - assert config.id == player.id - assert dsp_dal.exists(player.id) - assert config.bands == [] - assert config.preamp_db == 0.0 +def test_dsp_get_or_create_idempotent_after_edit(audera_home): + dsp_dal.get_or_create(DSPConfig(player_id='abc123')) # first open creates an empty config - -def test_resolve_for_player_idempotent_after_edit(audera_home): - player = _make_player() - dsp_dal.resolve_for_player(player) # first open creates an empty config - - edited = _make_dsp(id=player.id) + edited = _make_dsp(player_id='abc123') dsp_dal.update(edited) # A second open re-reads the edited config — it never re-mints an empty one. - assert dsp_dal.resolve_for_player(player) == edited - - -def test_resolve_for_player_returns_existing(audera_home): - existing = _make_dsp(id='abc123') - dsp_dal.create(existing) - player = _make_player(id='abc123') - - config = dsp_dal.resolve_for_player(player) - assert config == existing - - -def test_resolve_for_player_ignores_preset_namespace(audera_home): - # A preset keyed with the same id lives under `dsp/presets/`, not `dsp/`. - presets_dal.save_preset(Preset(id='abc123', name='Bass', bands=[Band(id='b1', type='LowShelf', freq=90.0, gain=6.0)])) - - config = dsp_dal.resolve_for_player(_make_player(id='abc123')) - - # The preset is never returned — the player config is a fresh, empty one. - assert config.bands == [] + assert dsp_dal.get_or_create(DSPConfig(player_id='abc123')) == edited diff --git a/tests/dal/test_presets.py b/tests/dal/test_presets.py index 00e5134..15b1136 100644 --- a/tests/dal/test_presets.py +++ b/tests/dal/test_presets.py @@ -11,8 +11,8 @@ def _make_preset(id='p1', name='My preset') -> Preset: id=id, name=name, bands=[ - Band(id='b1', type='LowShelf', freq=90.0, gain=10.0, q=0.7), - Band(id='b2', type='HighShelf', freq=8000.0, gain=6.0, q=0.7), + Band(id='b1', type='Lowshelf', freq=90.0, gain=10.0, q=0.7), + Band(id='b2', type='Highshelf', freq=8000.0, gain=6.0, q=0.7), ], ) @@ -23,7 +23,7 @@ def test_save_and_get_round_trip(audera_home): result = presets_dal.get_all_presets() assert result == [preset] - assert result[0].bands[0].type == 'LowShelf' + assert result[0].bands[0].type == 'Lowshelf' def test_delete_removes_preset(audera_home): @@ -61,7 +61,7 @@ def test_malformed_preset_is_skipped(audera_home): def test_player_config_is_never_returned(audera_home): # A player config written to dsp/*.json lives one directory up from dsp/presets/, # so the preset namespace can never surface it. - dsp_dal.create(DSPConfig(id='cfg1', bands=[Band(id='b1', freq=1000.0)])) + dsp_dal.create(DSPConfig(player_id='cfg1', bands=[Band(id='b1', freq=1000.0)])) assert presets_dal.get_all_presets() == [] diff --git a/tests/domains/dsp/test_compiler.py b/tests/domains/dsp/test_compiler.py index 7b4dc54..ff0b72a 100644 --- a/tests/domains/dsp/test_compiler.py +++ b/tests/domains/dsp/test_compiler.py @@ -18,11 +18,11 @@ def _foreign_config() -> dict: def test_preamp_and_peq_filters_exist(): config = DSPConfig( - id='x', + player_id='x', preamp_db=-6.0, bands=[ Band(id='b1', type='Peaking', freq=1000.0, gain=3.0), - Band(id='b2', type='LowShelf', freq=90.0, gain=10.0), + Band(id='b2', type='Lowshelf', freq=90.0, gain=10.0), ], ) compiled = compile_pipeline(_empty_config(), config) @@ -34,7 +34,7 @@ def test_preamp_and_peq_filters_exist(): def test_each_managed_filter_has_own_stereo_step(): config = DSPConfig( - id='x', + player_id='x', bands=[ Band(id='b1', type='Peaking', freq=1000.0, gain=3.0), Band(id='b2', type='Peaking', freq=2000.0, gain=3.0), @@ -52,12 +52,13 @@ def test_each_managed_filter_has_own_stereo_step(): assert steps[2]['names'] == [_PEQ_PREFIX + 'b2'] -def test_shelf_casing_is_lowercased(): +def test_shelf_type_passes_through_to_camilladsp(): + # `Band.type` already carries CamillaDSP's own casing, so the compiler emits it verbatim. config = DSPConfig( - id='x', + player_id='x', bands=[ - Band(id='low', type='LowShelf', freq=90.0, gain=10.0), - Band(id='high', type='HighShelf', freq=8000.0, gain=6.0), + Band(id='low', type='Lowshelf', freq=90.0, gain=10.0), + Band(id='high', type='Highshelf', freq=8000.0, gain=6.0), ], ) compiled = compile_pipeline(_empty_config(), config) @@ -67,7 +68,7 @@ def test_shelf_casing_is_lowercased(): def test_disabled_band_step_is_bypassed(): config = DSPConfig( - id='x', + player_id='x', bands=[ Band(id='on', type='Peaking', freq=1000.0, gain=3.0, enabled=True), Band(id='off', type='Peaking', freq=2000.0, gain=3.0, enabled=False), @@ -82,7 +83,7 @@ def test_disabled_band_step_is_bypassed(): def test_pass_filters_omit_gain_shelves_include_it(): config = DSPConfig( - id='x', + player_id='x', bands=[ Band(id='lp', type='Lowpass', freq=12000.0, q=0.7), Band(id='hp', type='Highpass', freq=40.0, q=0.7), @@ -96,7 +97,7 @@ def test_pass_filters_omit_gain_shelves_include_it(): def test_foreign_filters_and_steps_are_preserved(): - config = DSPConfig(id='x', bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=3.0)]) + config = DSPConfig(player_id='x', bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=3.0)]) compiled = compile_pipeline(_foreign_config(), config) assert compiled['devices'] == {'samplerate': 48000} assert 'user_filter' in compiled['filters'] @@ -105,17 +106,17 @@ def test_foreign_filters_and_steps_are_preserved(): def test_does_not_mutate_caller_config(): base = _empty_config() - config = DSPConfig(id='x', bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=3.0)]) + config = DSPConfig(player_id='x', bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=3.0)]) compile_pipeline(base, config) assert base == {'filters': {}, 'pipeline': []} def test_idempotency(): config = DSPConfig( - id='x', + player_id='x', preamp_db=-4.0, bands=[ - Band(id='b1', type='LowShelf', freq=90.0, gain=10.0), + Band(id='b1', type='Lowshelf', freq=90.0, gain=10.0), Band(id='b2', type='Peaking', freq=1000.0, gain=3.0, enabled=False), ], ) @@ -125,7 +126,7 @@ def test_idempotency(): def test_empty_base_config_compiles_cleanly(): - config = DSPConfig(id='x') + config = DSPConfig(player_id='x') compiled = compile_pipeline(_empty_config(), config) assert compiled['filters'] == {_PREAMP_KEY: {'type': 'Gain', 'parameters': {'gain': 0.0}}} assert compiled['pipeline'] == [{'type': 'Filter', 'channels': [0, 1], 'names': [_PREAMP_KEY], 'bypassed': False}] diff --git a/tests/domains/dsp/test_headroom.py b/tests/domains/dsp/test_headroom.py index d4eadf4..428f360 100644 --- a/tests/domains/dsp/test_headroom.py +++ b/tests/domains/dsp/test_headroom.py @@ -9,34 +9,34 @@ def test_single_band_matches_eval_filter_math(): band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) - config = DSPConfig(id='x', preamp_db=-3.0, bands=[band]) + config = DSPConfig(player_id='x', preamp_db=-3.0, bands=[band]) expected = max(eval_filter(_band_to_biquad(band), samplerate=48000)['magnitude']) + config.preamp_db assert response_peak_db(config) == pytest.approx(expected) def test_single_peaking_band_peaks_near_gain(): band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) - config = DSPConfig(id='x', bands=[band]) + config = DSPConfig(player_id='x', bands=[band]) assert response_peak_db(config) == pytest.approx(6.0, abs=0.1) @pytest.mark.parametrize('filter_type', ['Lowpass', 'Highpass']) def test_high_q_pass_filter_resonates_above_unity(filter_type): band = Band(id='b1', type=filter_type, freq=1000.0, q=4.0) - config = DSPConfig(id='x', bands=[band]) + config = DSPConfig(player_id='x', bands=[band]) assert response_peak_db(config) > 0.0 def test_preamp_shifts_peak_by_scalar(): band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) - flat = response_peak_db(DSPConfig(id='x', preamp_db=0.0, bands=[band])) - shifted = response_peak_db(DSPConfig(id='x', preamp_db=-4.0, bands=[band])) + flat = response_peak_db(DSPConfig(player_id='x', preamp_db=0.0, bands=[band])) + shifted = response_peak_db(DSPConfig(player_id='x', preamp_db=-4.0, bands=[band])) assert shifted == pytest.approx(flat - 4.0) def test_no_enabled_bands_returns_preamp(): config = DSPConfig( - id='x', + player_id='x', preamp_db=-5.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, enabled=False)], ) @@ -44,12 +44,12 @@ def test_no_enabled_bands_returns_preamp(): def test_empty_config_returns_preamp(): - assert response_peak_db(DSPConfig(id='x', preamp_db=-2.0)) == pytest.approx(-2.0) + assert response_peak_db(DSPConfig(player_id='x', preamp_db=-2.0)) == pytest.approx(-2.0) def test_response_curve_lengths_match_and_frequency_is_strictly_increasing(): band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) - frequencies, magnitudes = response_curve(DSPConfig(id='x', bands=[band])) + frequencies, magnitudes = response_curve(DSPConfig(player_id='x', bands=[band])) assert len(frequencies) == len(magnitudes) assert all(later > earlier for earlier, later in zip(frequencies, frequencies[1:])) @@ -57,7 +57,7 @@ def test_response_curve_lengths_match_and_frequency_is_strictly_increasing(): def test_response_curve_no_enabled_bands_synthesizes_full_flat_line(): # The axis is synthesized from `logspace`, not borrowed from a band's `eval_filter` # result, so the full grid + flat pre-amp line exists with no band firing. - config = DSPConfig(id='x', preamp_db=-3.0, bands=[Band(id='b1', freq=1000.0, gain=6.0, enabled=False)]) + config = DSPConfig(player_id='x', preamp_db=-3.0, bands=[Band(id='b1', freq=1000.0, gain=6.0, enabled=False)]) frequencies, magnitudes = response_curve(config, npoints=400) assert frequencies == logspace(1.0, 48000 * 0.95 / 2.0, 400) assert len(magnitudes) == 400 @@ -66,7 +66,7 @@ def test_response_curve_no_enabled_bands_synthesizes_full_flat_line(): def test_response_peak_db_equals_curve_max_on_fine_grid(): band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) - config = DSPConfig(id='x', preamp_db=-2.0, bands=[band]) + config = DSPConfig(player_id='x', preamp_db=-2.0, bands=[band]) assert response_peak_db(config) == pytest.approx(max(response_curve(config, npoints=1000)[1])) @@ -85,7 +85,7 @@ def test_auto_preamp_db_cancels_boost_peak_so_clamped_config_never_clips(): band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) ceiling = auto_preamp_db([band]) assert ceiling == pytest.approx(-6.0, abs=0.1) - clamped = DSPConfig(id='x', preamp_db=ceiling, bands=[band]) + clamped = DSPConfig(player_id='x', preamp_db=ceiling, bands=[band]) assert response_peak_db(clamped) <= 1e-6 diff --git a/tests/domains/dsp/test_presets.py b/tests/domains/dsp/test_presets.py index 8781b35..f509e01 100644 --- a/tests/domains/dsp/test_presets.py +++ b/tests/domains/dsp/test_presets.py @@ -7,8 +7,8 @@ def test_loudness_preset_shape(): assert len(bands) == 2 low, high = bands - assert (low.type, low.freq, low.gain, low.q) == ('LowShelf', 90.0, 10.0, 0.7) - assert (high.type, high.freq, high.gain, high.q) == ('HighShelf', 8000.0, 6.0, 0.7) + assert (low.type, low.freq, low.gain, low.q) == ('Lowshelf', 90.0, 10.0, 0.7) + assert (high.type, high.freq, high.gain, high.q) == ('Highshelf', 8000.0, 6.0, 0.7) assert all(band.enabled for band in bands) @@ -20,13 +20,13 @@ def test_loudness_preset_ids_are_unique(): def test_loudness_preset_needs_headroom(): - config = DSPConfig(id='x', bands=loudness_preset()) + config = DSPConfig(player_id='x', bands=loudness_preset()) assert response_peak_db(config) > 0.0 def _source_bands() -> list[Band]: return [ - Band(id='b1', type='LowShelf', freq=90.0, gain=10.0, q=0.7), + Band(id='b1', type='Lowshelf', freq=90.0, gain=10.0, q=0.7), Band(id='b2', type='Peaking', freq=1000.0, gain=-3.0, q=2.0, enabled=False), ] diff --git a/tests/domains/dsp/test_rew.py b/tests/domains/dsp/test_rew.py index c36c01e..6b2a2f0 100644 --- a/tests/domains/dsp/test_rew.py +++ b/tests/domains/dsp/test_rew.py @@ -1,114 +1,158 @@ +import json + import pytest +import yaml from audera.domains.dsp import format_rew, parse_rew -from audera.models.dsp import Band, DSPConfig +from audera.models.dsp import DEFAULT_Q, Band, DSPConfig -@pytest.mark.parametrize( - 'kind, expected', - [ - ('PK', 'Peaking'), - ('LS', 'LowShelf'), - ('LSC', 'LowShelf'), - ('HS', 'HighShelf'), - ('HSC', 'HighShelf'), - ('LP', 'Lowpass'), - ('HP', 'Highpass'), - ], -) -def test_parse_maps_supported_kinds(kind, expected): - result = parse_rew(f'Filter 1: ON {kind} Fc 1000 Hz Gain 3.0 dB Q 1.0') - assert [band.type for band in result.bands] == [expected] +def _biquad(band_type, **parameters) -> dict: + """Returns a CamillaDSP Biquad filter `dict` with the given `parameters.type`.""" + return {'type': 'Biquad', 'parameters': {'type': band_type, **parameters}} + + +def _yaml(filters, pipeline=None) -> str: + """Serializes a CamillaDSP `{filters, pipeline}` document to YAML text.""" + document = {'filters': filters} + if pipeline is not None: + document['pipeline'] = pipeline + return yaml.safe_dump(document, sort_keys=False) + + +@pytest.mark.parametrize('band_type', ['Peaking', 'Lowshelf', 'Highshelf', 'Lowpass', 'Highpass']) +def test_parse_maps_supported_types(band_type): + result = parse_rew(_yaml({'f1': _biquad(band_type, freq=1000.0, q=1.0, gain=3.0)})) + assert [band.type for band in result.bands] == [band_type] assert result.skipped == [] def test_parse_reads_numeric_fields(): - result = parse_rew('Filter 1: ON PK Fc 1000 Hz Gain -3.5 dB Q 1.41') - (band,) = result.bands + (band,) = parse_rew(_yaml({'f1': _biquad('Peaking', freq=1000.0, q=1.41, gain=-3.5)})).bands assert band.freq == pytest.approx(1000.0) assert band.gain == pytest.approx(-3.5) assert band.q == pytest.approx(1.41) -@pytest.mark.parametrize('state, enabled', [('ON', True), ('OFF', False)]) -def test_parse_on_off_sets_enabled(state, enabled): - (band,) = parse_rew(f'Filter 1: {state} PK Fc 1000 Hz Gain 3.0 dB Q 1.0').bands +@pytest.mark.parametrize('bypassed, enabled', [(False, True), (True, False)]) +def test_parse_bypassed_sets_enabled(bypassed, enabled): + text = _yaml( + {'f1': _biquad('Peaking', freq=1000.0, q=1.0, gain=3.0)}, + pipeline=[{'type': 'Filter', 'channels': [0, 1], 'names': ['f1'], 'bypassed': bypassed}], + ) + (band,) = parse_rew(text).bands assert band.enabled is enabled -def test_parse_accepts_equalizer_apo_lines_without_index(): - (band,) = parse_rew('Filter: ON PK Fc 1000 Hz Gain 3.0 dB Q 1.0').bands - assert band.type == 'Peaking' +def test_parse_filter_absent_from_pipeline_defaults_enabled(): + # A filter no pipeline step references is treated as active. + (band,) = parse_rew(_yaml({'f1': _biquad('Peaking', freq=1000.0, q=1.0, gain=3.0)})).bands + assert band.enabled is True -@pytest.mark.parametrize('kind', ['NO', 'AP', 'BP']) -def test_parse_skips_unsupported_kinds(kind): - result = parse_rew(f'Filter 1: ON {kind} Fc 1000 Hz Gain 3.0 dB Q 1.0') +@pytest.mark.parametrize('subtype', ['Notch', 'Bandpass', 'Allpass', 'LowshelfFO', 'HighshelfFO']) +def test_parse_skips_unsupported_biquad_subtype(subtype): + result = parse_rew(_yaml({'weird': _biquad(subtype, freq=1000.0, q=1.0, gain=3.0)})) assert result.bands == [] - assert len(result.skipped) == 1 + assert result.skipped == ['weird'] -def test_parse_skips_supported_kind_missing_fc(): - result = parse_rew('Filter 1: ON PK Gain 3.0 dB Q 1.0') +@pytest.mark.parametrize( + 'filter_', + [ + {'type': 'Gain', 'parameters': {'gain': -3.0}}, + {'type': 'Conv', 'parameters': {'values': [1.0]}}, + {'type': 'BiquadCombo', 'parameters': {'type': 'ButterworthHighpass', 'freq': 40.0, 'order': 4}}, + ], +) +def test_parse_skips_non_biquad_filter(filter_): + result = parse_rew(_yaml({'foreign': filter_})) assert result.bands == [] - assert len(result.skipped) == 1 + assert result.skipped == ['foreign'] -def test_parse_skips_garbage_line(): - result = parse_rew('this is not a filter line') +def test_parse_skips_supported_type_missing_freq(): + result = parse_rew(_yaml({'f1': _biquad('Peaking', q=1.0, gain=3.0)})) # no freq assert result.bands == [] - assert result.skipped == ['this is not a filter line'] + assert result.skipped == ['f1'] -def test_parse_ignores_blank_lines(): - result = parse_rew('\n \n\t\n') - assert result.bands == [] - assert result.skipped == [] +@pytest.mark.parametrize('band_type', ['Lowpass', 'Highpass']) +def test_parse_pass_filter_gain_is_zero(band_type): + (band,) = parse_rew(_yaml({'f1': _biquad(band_type, freq=1000.0, q=0.9, gain=3.0)})).bands + assert band.gain == 0.0 -def test_parse_recognizes_preamp_without_band_or_skip(): - result = parse_rew('Preamp: -6.5 dB') +def test_parse_omitted_q_defaults(): + (band,) = parse_rew(_yaml({'f1': _biquad('Peaking', freq=1000.0, gain=3.0)})).bands # no q + assert band.q == pytest.approx(DEFAULT_Q) + + +def test_parse_accepts_json_paste(): + # JSON is a YAML subset, so a CamillaDSP JSON export parses too. + text = json.dumps({'filters': {'f1': _biquad('Peaking', freq=1000.0, q=1.0, gain=3.0)}}) + (band,) = parse_rew(text).bands + assert band.type == 'Peaking' + + +def test_parse_garbage_input_is_skipped_raw(): + result = parse_rew('this is not a camilladsp document') assert result.bands == [] - assert result.skipped == [] + assert result.skipped == ['this is not a camilladsp document'] -def test_parse_preamp_plus_filter_yields_one_band(): - result = parse_rew('Preamp: -6.5 dB\nFilter 1: ON PK Fc 1000 Hz Gain 3.0 dB Q 1.0') - assert len(result.bands) == 1 - assert result.skipped == [] +def test_parse_yaml_syntax_error_is_skipped_raw(): + result = parse_rew('filters: [unclosed') + assert result.bands == [] + assert result.skipped == ['filters: [unclosed'] -@pytest.mark.parametrize('kind, expected', [('LP', 'Lowpass'), ('HP', 'Highpass')]) -def test_parse_pass_filter_without_q_defaults(kind, expected): - (band,) = parse_rew(f'Filter 1: ON {kind} Fc 1000 Hz').bands - assert band.type == expected - assert band.q == pytest.approx(0.707) +def test_parse_blank_input_yields_nothing(): + result = parse_rew('\n \n\t\n') + assert result.bands == [] + assert result.skipped == [] def test_parse_mints_unique_ids(): - result = parse_rew('Filter 1: ON PK Fc 1000 Hz Gain 3.0 dB Q 1.0\nFilter 2: ON PK Fc 2000 Hz Gain 3.0 dB Q 1.0') - ids = [band.id for band in result.bands] + text = _yaml( + { + 'f1': _biquad('Peaking', freq=1000.0, q=1.0, gain=3.0), + 'f2': _biquad('Peaking', freq=2000.0, q=1.0, gain=3.0), + } + ) + ids = [band.id for band in parse_rew(text).bands] assert len(ids) == len(set(ids)) == 2 -def test_format_emits_preamp_and_filter_lines(): +def test_format_emits_filters_and_pipeline(): text = format_rew(-6.0, [Band(id='b1', type='Peaking', freq=1000.0, gain=3.0, q=1.0)]) - lines = text.splitlines() - assert lines[0] == 'Preamp: -6.0 dB' - assert lines[1].startswith('Filter 1:') - assert 'PK' in lines[1] + assert 'filters:' in text # the tag REW requires to import + document = yaml.safe_load(text) + assert document['filters']['b1'] == { + 'type': 'Biquad', + 'parameters': {'type': 'Peaking', 'freq': 1000.0, 'q': 1.0, 'gain': 3.0}, + } + assert document['pipeline'] == [{'type': 'Filter', 'channels': [0, 1], 'names': ['b1'], 'bypassed': False}] def test_format_pass_filter_omits_gain_keeps_q(): - text = format_rew(0.0, [Band(id='b1', type='Lowpass', freq=12000.0, q=0.8)]) - (_, filter_line) = text.splitlines() - assert 'Gain' not in filter_line - assert 'Q 0.800' in filter_line + document = yaml.safe_load(format_rew(0.0, [Band(id='b1', type='Lowpass', freq=12000.0, q=0.8)])) + parameters = document['filters']['b1']['parameters'] + assert 'gain' not in parameters + assert parameters['q'] == pytest.approx(0.8) + +def test_format_disabled_band_is_bypassed(): + document = yaml.safe_load(format_rew(0.0, [Band(id='b1', type='Peaking', freq=1000.0, gain=3.0, enabled=False)])) + assert document['pipeline'][0]['bypassed'] is True -def test_format_disabled_band_emits_off(): - text = format_rew(0.0, [Band(id='b1', type='Peaking', freq=1000.0, gain=3.0, enabled=False)]) - assert ' OFF PK ' in text.splitlines()[1] + +def test_format_does_not_emit_preamp(): + # Pre-amp is not round-tripped — no Gain filter is written, only band biquads. + text = format_rew(-9.9, [Band(id='b1', type='Peaking', freq=1000.0, gain=3.0, q=1.0)]) + document = yaml.safe_load(text) + assert 'preamp' not in text.lower() + assert all(filter_['type'] == 'Biquad' for filter_ in document['filters'].values()) def _bands_equal(a: Band, b: Band) -> bool: @@ -123,12 +167,12 @@ def _bands_equal(a: Band, b: Band) -> bool: def test_round_trip_over_bands_ignoring_ids(): config = DSPConfig( - id='x', + player_id='x', preamp_db=-6.3, bands=[ Band(id='b1', type='Peaking', freq=1000.0, gain=-3.5, q=1.41), - Band(id='b2', type='LowShelf', freq=90.0, gain=4.0, q=0.7), - Band(id='b3', type='HighShelf', freq=8000.0, gain=6.0, q=0.71, enabled=False), + Band(id='b2', type='Lowshelf', freq=90.0, gain=4.0, q=0.7), + Band(id='b3', type='Highshelf', freq=8000.0, gain=6.0, q=0.71, enabled=False), Band(id='b4', type='Lowpass', freq=12000.0, q=0.8), Band(id='b5', type='Highpass', freq=40.0, q=0.9), ], @@ -138,14 +182,3 @@ def test_round_trip_over_bands_ignoring_ids(): assert all(_bands_equal(got, expected) for got, expected in zip(reparsed, config.bands)) # Fresh ids on re-import — bands round-trip modulo id. assert all(got.id != expected.id for got, expected in zip(reparsed, config.bands)) - - -def test_round_trip_does_not_reapply_preamp(): - # `format_rew` emits the pre-amp for fidelity, but `parse_rew` recognizes it without - # producing a band or a skip — the auto-ceiling owns pre-amp on import. - config = DSPConfig(id='x', preamp_db=-9.9, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=3.0, q=1.0)]) - text = format_rew(config.preamp_db, config.bands) - assert 'Preamp: -9.9 dB' in text - result = parse_rew(text) - assert len(result.bands) == 1 - assert result.skipped == [] diff --git a/tests/models/test_dsp.py b/tests/models/test_dsp.py index f3d3e42..a6cc562 100644 --- a/tests/models/test_dsp.py +++ b/tests/models/test_dsp.py @@ -10,10 +10,10 @@ def test_band_defaults(): def test_band_to_dict(): - band = Band(id='b1', type='LowShelf', freq=90.0, gain=10.0, q=0.7, enabled=False) + band = Band(id='b1', type='Lowshelf', freq=90.0, gain=10.0, q=0.7, enabled=False) assert band.to_dict() == { 'id': 'b1', - 'type': 'LowShelf', + 'type': 'Lowshelf', 'freq': 90.0, 'gain': 10.0, 'q': 0.7, @@ -22,41 +22,42 @@ def test_band_to_dict(): def test_band_from_dict_round_trip(): - band = Band(id='b1', type='HighShelf', freq=8000.0, gain=6.0, q=0.7) + band = Band(id='b1', type='Highshelf', freq=8000.0, gain=6.0, q=0.7) assert Band.from_dict(band.to_dict()) == band def test_dsp_config_defaults(): - config = DSPConfig(id='x') + config = DSPConfig(player_id='x') assert config.preamp_db == 0.0 assert config.bands == [] assert config.enabled is True def test_dsp_config_to_dict_keys(): - config = DSPConfig(id='x') - assert set(config.to_dict().keys()) == {'id', 'preamp_db', 'bands', 'enabled'} + config = DSPConfig(player_id='x') + assert set(config.to_dict().keys()) == {'player_id', 'preamp_db', 'bands', 'enabled'} def test_dsp_config_bands_round_trip(): config = DSPConfig( - id='x', + player_id='x', preamp_db=-6.0, bands=[ - Band(id='b1', type='LowShelf', freq=90.0, gain=10.0), - Band(id='b2', type='HighShelf', freq=8000.0, gain=6.0), + Band(id='b1', type='Lowshelf', freq=90.0, gain=10.0), + Band(id='b2', type='Highshelf', freq=8000.0, gain=6.0), ], ) result = DSPConfig.from_dict(config.to_dict()) assert result == config - assert result.bands[0].type == 'LowShelf' + assert result.bands[0].type == 'Lowshelf' assert result.bands[1].freq == 8000.0 def test_dsp_config_legacy_dict_drops_retired_keys(): legacy = { - 'id': 'x', 'player_id': 'player-1', + 'id': 'dsp-1', + 'dsp_id': 'dsp-1', 'pipeline': {'filters': {}, 'pipeline': []}, 'loudness_enabled': True, 'loudness_reference_level': -30.0, @@ -65,16 +66,16 @@ def test_dsp_config_legacy_dict_drops_retired_keys(): } config = DSPConfig.from_dict(legacy) result = config.to_dict() - assert set(result.keys()) == {'id', 'preamp_db', 'bands', 'enabled'} - for retired in ('player_id', 'pipeline', 'loudness_enabled', 'loudness_reference_level', 'volume'): + assert set(result.keys()) == {'player_id', 'preamp_db', 'bands', 'enabled'} + for retired in ('id', 'dsp_id', 'pipeline', 'loudness_enabled', 'loudness_reference_level', 'volume'): assert retired not in result -def test_dsp_config_id_is_identity(): - a = DSPConfig(id='same', preamp_db=-3.0) - b = DSPConfig(id='same', preamp_db=-3.0) +def test_dsp_config_player_id_is_identity(): + a = DSPConfig(player_id='same', preamp_db=-3.0) + b = DSPConfig(player_id='same', preamp_db=-3.0) assert a == b - assert a != DSPConfig(id='other', preamp_db=-3.0) + assert a != DSPConfig(player_id='other', preamp_db=-3.0) def test_preset_defaults(): @@ -87,8 +88,8 @@ def test_preset_round_trip(): id='p1', name='Loudness', bands=[ - Band(id='b1', type='LowShelf', freq=90.0, gain=10.0, q=0.7), - Band(id='b2', type='HighShelf', freq=8000.0, gain=6.0, q=0.7), + Band(id='b1', type='Lowshelf', freq=90.0, gain=10.0, q=0.7), + Band(id='b2', type='Highshelf', freq=8000.0, gain=6.0, q=0.7), ], ) result = Preset.model_validate(preset.model_dump()) @@ -97,5 +98,5 @@ def test_preset_round_trip(): assert result.name == 'Loudness' # Bands reconstruct as real `Band`s, not raw dicts. assert all(isinstance(band, Band) for band in result.bands) - assert result.bands[0].type == 'LowShelf' + assert result.bands[0].type == 'Lowshelf' assert result.bands[1].freq == 8000.0 diff --git a/tests/ui/test_streamer.py b/tests/ui/test_streamer.py index b4e5e94..c8f900d 100644 --- a/tests/ui/test_streamer.py +++ b/tests/ui/test_streamer.py @@ -7,7 +7,7 @@ from nicegui.client import Client from nicegui.testing import User -import audera.ui.streamer.pages as streamer_pages +import audera.ui.streamer.pages._plex as streamer_plex from audera.clients import CamillaDSPClient, SnapserverClient from audera.dal import dsp as dsp_dal from audera.dal import presets as presets_dal @@ -128,7 +128,7 @@ def db_volume_mode(audera_home): async def test_index_renders_tabs(audera_home, mock_snapserver_empty, monkeypatch, user: User): - monkeypatch.setattr(streamer_pages, '_plexamp_state', lambda: 'inactive') + monkeypatch.setattr(streamer_plex, '_plexamp_state', lambda: 'inactive') Page().load() await user.open('/') await user.should_see('Players') @@ -223,7 +223,7 @@ async def test_players_tab_disabled_experience_toggle_on_unmutes_client( async def test_services_tab_shows_inactive(audera_home, mock_snapserver_empty, monkeypatch, user: User): - monkeypatch.setattr(streamer_pages, '_plexamp_state', lambda: 'inactive') + monkeypatch.setattr(streamer_plex, '_plexamp_state', lambda: 'inactive') Page().load() await user.open('/') user.find('Services').click() @@ -231,9 +231,9 @@ async def test_services_tab_shows_inactive(audera_home, mock_snapserver_empty, m async def test_services_tab_shows_unclaimed(audera_home, mock_snapserver_empty, monkeypatch, user: User): - monkeypatch.setattr(streamer_pages, '_plexamp_state', lambda: 'inactive') + monkeypatch.setattr(streamer_plex, '_plexamp_state', lambda: 'inactive') Page().load() - monkeypatch.setattr(streamer_pages, '_plexamp_state', lambda: 'unclaimed') + monkeypatch.setattr(streamer_plex, '_plexamp_state', lambda: 'unclaimed') await user.open('/') user.find('Services').click() await user.should_see('setup required') @@ -241,7 +241,7 @@ async def test_services_tab_shows_unclaimed(audera_home, mock_snapserver_empty, async def test_services_tab_shows_claimed(audera_home, mock_snapserver_empty, monkeypatch, user: User): - monkeypatch.setattr(streamer_pages, '_plexamp_state', lambda: 'claimed') + monkeypatch.setattr(streamer_plex, '_plexamp_state', lambda: 'claimed') Page().load() await user.open('/') user.find('Services').click() @@ -294,7 +294,7 @@ async def test_run_preamble_does_not_set_script_mode(audera_home, monkeypatch, u ui.run() raises: RuntimeError: ui.page cannot be used in NiceGUI scripts when UI is defined in the global scope. """ - monkeypatch.setattr(streamer_pages, '_plexamp_state', lambda: 'inactive') + monkeypatch.setattr(streamer_plex, '_plexamp_state', lambda: 'inactive') Page().load() Client.instances.clear() # replicate production: no pre-existing clients components.theme.apply_defaults() @@ -419,7 +419,7 @@ async def test_settings_dialog_no_longer_shows_loudness(audera_home, mock_snapse await user.should_not_see('Reference level (dB)') -# --- Advanced DSP editor (WS-4 / WS-5) --------------------------------------------------- +# --- Advanced DSP editor ----------------------------------------------------------------- async def test_players_tab_shows_dsp_icon(audera_home, mock_snapserver_with_client, mock_camilladsp, user: User): @@ -476,9 +476,9 @@ def _seed_dsp(config: DSPConfig) -> None: """Persists a DSP config keyed by player 'abc123' (the page's load path). The config is keyed by the player id (`dsp/abc123.json` is the link), so persisting it - under that key is all `resolve_for_player` needs to open it instead of a fresh empty one. + under that key is all `get_or_create` needs to open it instead of a fresh empty one. """ - dsp_dal.save(config.model_copy(update={'id': 'abc123'})) + dsp_dal.save(config.model_copy(update={'player_id': 'abc123'})) async def test_dsp_bandless_shows_chart_message_and_hides_chart( @@ -502,7 +502,7 @@ async def test_dsp_preamp_clamp_keeps_below_ceiling_value( audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User ): # A +6 dB boost sets the ceiling at ~-6 dB; -10 is below it, so the clamp leaves it. - _seed_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + _seed_dsp(DSPConfig(player_id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') with user: @@ -516,7 +516,7 @@ async def test_dsp_preamp_clamp_pulls_above_ceiling_value_down( ): # Raising the pre-amp to -2 dB over a +6 dB boost would clip, so the clamp snaps it back # down to the ~-6 dB clip-safe ceiling. - _seed_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + _seed_dsp(DSPConfig(player_id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') with user: @@ -525,10 +525,11 @@ async def test_dsp_preamp_clamp_pulls_above_ceiling_value_down( assert user.find(kind=ui.number, content='auto-protected').elements.pop().value == pytest.approx(-6.0, abs=0.2) -async def test_dsp_over_hot_saved_config_opens_clean(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): - # Saved with a hot 0 dB pre-amp above the +6 dB boost ceiling; the load-time baseline - # clamp pulls it down so the editor opens without a false "Unsaved changes" flag. - _seed_dsp(DSPConfig(id='cfg1', preamp_db=0.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) +async def test_dsp_saved_config_opens_clean(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + # Save always clamps the pre-amp to the clip-safe ceiling, so a saved config's pre-amp is + # never above it; the `_mark_changed` clamp is a fixpoint on open and the editor opens + # without a false "Unsaved changes" flag. + _seed_dsp(DSPConfig(player_id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') await user.should_see(kind=ui.echart) @@ -562,7 +563,7 @@ async def test_dsp_save_applies_and_persists(audera_home, mock_snapserver_with_c assert len(dsp_dal.get('abc123').bands) == 2 -# --- REW import/export via Config ▾ (WS-7) ----------------------------------------------- +# --- CamillaDSP YAML import/export via Config ▾ ------------------------------------------ async def test_dsp_config_menu_exposes_import_and_export( @@ -579,46 +580,54 @@ async def test_dsp_import_appends_bands(audera_home, mock_snapserver_with_client await user.open('/player/abc123/dsp') await user.should_see('Bands (0)') user.find(marker='config-import').click() - rew = 'Filter 1: ON PK Fc 1000 Hz Gain -3.0 dB Q 1.41\nFilter 2: ON LS Fc 90 Hz Gain 4.0 dB Q 0.7' + yaml_text = ( + 'filters:\n' + ' band_1: {type: Biquad, parameters: {type: Peaking, freq: 1000, q: 1.41, gain: -3.0}}\n' + ' band_2: {type: Biquad, parameters: {type: Lowshelf, freq: 90, q: 0.7, gain: 4.0}}\n' + ) with user: - user.find(kind=ui.textarea).elements.pop().value = rew + user.find(kind=ui.textarea).elements.pop().value = yaml_text user.find(marker='config-import-run').click() await user.should_see('Bands (2)') await user.should_see('Imported 2 band(s)') -async def test_dsp_import_notifies_skipped_lines(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): +async def test_dsp_import_notifies_skipped_filters(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): Page().load() await user.open('/player/abc123/dsp') user.find(marker='config-import').click() - rew = 'Filter 1: ON PK Fc 1000 Hz Gain -3.0 dB Q 1.41\nFilter 2: ON NO Fc 60 Hz Gain 0 dB Q 5.0' + yaml_text = ( + 'filters:\n' + ' band_1: {type: Biquad, parameters: {type: Peaking, freq: 1000, q: 1.41, gain: -3.0}}\n' + ' band_2: {type: Biquad, parameters: {type: Notch, freq: 60, q: 5.0, gain: 0}}\n' + ) with user: - user.find(kind=ui.textarea).elements.pop().value = rew + user.find(kind=ui.textarea).elements.pop().value = yaml_text user.find(marker='config-import-run').click() await user.should_see('Bands (1)') - await user.should_see('Imported 1 band(s), skipped 1 line(s)') + await user.should_see('Imported 1 band(s), skipped 1 filter(s)') -async def test_dsp_export_renders_saved_config_text(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): - _seed_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) +async def test_dsp_export_renders_saved_config_yaml(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + _seed_dsp(DSPConfig(player_id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') user.find(marker='config-export').click() - await user.should_see('Preamp:') - await user.should_see('Filter 1:') + await user.should_see('filters:') # the CamillaDSP tag REW requires to import + await user.should_see('Peaking') async def test_dsp_export_banner_absent_on_clean_open(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): - _seed_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + _seed_dsp(DSPConfig(player_id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') user.find(marker='config-export').click() - await user.should_see('Filter 1:') + await user.should_see('filters:') await user.should_not_see(marker='export-unsaved-banner') async def test_dsp_export_banner_shown_after_edit(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): - _seed_dsp(DSPConfig(id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) + _seed_dsp(DSPConfig(player_id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') user.find(content='+ Add band').click() # stage an edit so staged ≠ saved @@ -627,7 +636,7 @@ async def test_dsp_export_banner_shown_after_edit(audera_home, mock_snapserver_w await user.should_see(marker='export-unsaved-banner') -# --- Named user presets (WS-8) ----------------------------------------------------------- +# --- Named user presets ------------------------------------------------------------------ async def test_dsp_saved_preset_appears_and_appends(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): @@ -636,7 +645,7 @@ async def test_dsp_saved_preset_appears_and_appends(audera_home, mock_snapserver id='p1', name='Bass Boost', bands=[ - Band(id='b1', type='LowShelf', freq=90.0, gain=6.0, q=0.7), + Band(id='b1', type='Lowshelf', freq=90.0, gain=6.0, q=0.7), Band(id='b2', type='Peaking', freq=1000.0, gain=-3.0, q=2.0), ], ) diff --git a/uv.lock b/uv.lock index 5dea2ca..b9b0a3d 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = "==3.13.*" [options] -exclude-newer = "2026-07-01T01:50:51.1229439Z" +exclude-newer = "2026-07-02T19:14:54.2422552Z" exclude-newer-span = "P1W" [[package]] @@ -121,6 +121,7 @@ dependencies = [ { name = "nicegui" }, { name = "pydantic" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "websockets" }, ] @@ -147,6 +148,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.23.1" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.10" }, { name = "testcontainers", marker = "extra == 'dev'", specifier = ">=4.14.2" }, From 278731708540a61862eaf0871e8bf312c25c5517 Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Fri, 10 Jul 2026 13:05:33 -0500 Subject: [PATCH 12/18] docs: trim module docstrings to one-liners, fix "Auder" typo Condense the verbose module-level docstrings across the DSP domain, UI, and streamer packages to concise summaries; fix "Auder app" -> "Audera app" in `audera/ui/__init__.py`. Co-Authored-By: Claude Opus 4.8 --- audera/clients/camilladsp.py | 5 ++--- audera/dal/presets.py | 12 +----------- audera/domains/__init__.py | 1 + audera/domains/dsp/__init__.py | 10 ++++------ audera/domains/dsp/compiler.py | 9 +-------- audera/domains/dsp/headroom.py | 11 +---------- audera/domains/dsp/presets.py | 7 +------ audera/domains/dsp/rew.py | 15 +++------------ audera/ui/__init__.py | 5 ++++- audera/ui/components/response_plot.py | 9 +-------- audera/ui/streamer/__init__.py | 4 ++-- audera/ui/streamer/pages/__init__.py | 8 +------- audera/ui/streamer/pages/dsp.py | 2 +- audera/ui/streamer/pages/index.py | 2 +- 14 files changed, 24 insertions(+), 76 deletions(-) diff --git a/audera/clients/camilladsp.py b/audera/clients/camilladsp.py index 53402a2..79badbf 100644 --- a/audera/clients/camilladsp.py +++ b/audera/clients/camilladsp.py @@ -88,12 +88,11 @@ def validate_config(self, config: dict) -> None: config: `dict` The CamillaDSP pipeline configuration. """ - # ValidateConfig expects a config string; JSON is a subset of YAML, so a JSON - # dict validates directly (ValidateConfigJson is v4+ and unavailable in v3.0.1). response = self._call(_CMD_VALIDATE_CONFIG, json.dumps(config)) inner = response.get(_CMD_VALIDATE_CONFIG, response) if isinstance(response, dict) else response + # `_call` only raises on a literal `result == 'Error'`, but any non-`Ok` result - # (e.g. a validation message / ConfigValidationError) means the config is invalid. + # (e.g. a validation message / ConfigValidationError) means the config is invalid. if isinstance(inner, dict) and inner.get('result') != 'Ok': raise RuntimeError('CamillaDSP validation failed [%s]: %s' % (_CMD_VALIDATE_CONFIG, inner)) diff --git a/audera/dal/presets.py b/audera/dal/presets.py index 8422582..f853ae1 100644 --- a/audera/dal/presets.py +++ b/audera/dal/presets.py @@ -1,14 +1,4 @@ -"""Named user-preset configuration-layer. - -Presets live in their own namespace nested under `dsp/` (`~/.audera/dsp/presets/`), -glob-listed and wrapped `{'preset': {...}}`, so a corrupt player config can't break the -preset menu and vice-versa. - -Storage is plain `json` + `glob`, not duckdb: a `Preset.bands` is a nested list of band -objects, which duckdb's `read_json_auto` — built for flat, columnar rows under the -pytensils DTYPES constraint — cannot model. With the duckdb-backed DALs retired, every -surviving DAL (`dsp`, `presets`, `settings`) is plain-json for the same reason. -""" +"""Preset DSP configuration-layer""" import glob import json diff --git a/audera/domains/__init__.py b/audera/domains/__init__.py index e69de29..dd9f8ee 100644 --- a/audera/domains/__init__.py +++ b/audera/domains/__init__.py @@ -0,0 +1 @@ +"""Unit-testable domain logic""" diff --git a/audera/domains/dsp/__init__.py b/audera/domains/dsp/__init__.py index 732ea30..08892bc 100644 --- a/audera/domains/dsp/__init__.py +++ b/audera/domains/dsp/__init__.py @@ -1,10 +1,8 @@ -"""Pure DSP computation layer. +"""DSP domain layer -Bands are the source of truth; this package compiles them into a CamillaDSP -pipeline (`compiler`), derives the combined magnitude response, its peak, and the -clip-safe pre-amp ceiling for headroom safety (`headroom`), and mints editable -preset bands (`presets`). It imports only `audera.models` (never `dal`/`clients`), -so it is unit-testable with zero I/O. +The `dsp` sub-package compiles bands into a CamillaDSP pipeline (`compiler`), derives +the combined magnitude response, its peak, and the clip-safe pre-amp ceiling for headroom +safety (`headroom`), and mints editable preset bands (`presets`). """ from audera.domains.dsp.compiler import compile_pipeline diff --git a/audera/domains/dsp/compiler.py b/audera/domains/dsp/compiler.py index 6098f6b..005f7c6 100644 --- a/audera/domains/dsp/compiler.py +++ b/audera/domains/dsp/compiler.py @@ -1,11 +1,4 @@ -"""Compile `preamp_db` + `bands` into a CamillaDSP pipeline configuration. - -Bands are the source of truth; the CamillaDSP config is a derived artifact. The -compiler strips every previously-managed (`audera_`-prefixed) filter and pipeline -step, then re-adds a pre-amp Gain followed by one Biquad per band — preserving -foreign filters/steps and never mutating the caller's dict. Strip-then-re-add -with stable ordering makes it idempotent by construction. -""" +"""DSP compiler""" import copy diff --git a/audera/domains/dsp/headroom.py b/audera/domains/dsp/headroom.py index 1c1d650..3ca8127 100644 --- a/audera/domains/dsp/headroom.py +++ b/audera/domains/dsp/headroom.py @@ -1,13 +1,4 @@ -"""Compute the combined magnitude response and clip-safe pre-amp of a DSP configuration. - -Bands are the source of truth; this module derives everything downstream from the same -element-wise sum. Each enabled band's magnitude response is evaluated with CamillaDSP's -own `eval_filter` over a shared `logspace` grid (dependent only on `samplerate`/`npoints`), -summed, and offset by the flat pre-amp Gain — matching the daemon's math so the live chart, -the automatic pre-amp ceiling, the headroom peak, and the compiled pipeline can never drift -apart. `logspace` is not re-exported from the package root, so it is imported from the -submodule that also backs `eval_filter`. -""" +"""DSP headroom""" from camilladsp_plot import eval_filter from camilladsp_plot.eval_filterconfig import logspace diff --git a/audera/domains/dsp/presets.py b/audera/domains/dsp/presets.py index 73296e5..13c8eeb 100644 --- a/audera/domains/dsp/presets.py +++ b/audera/domains/dsp/presets.py @@ -1,9 +1,4 @@ -"""Editable band presets. - -Loudness is a static preset — two editable shelf bands — rather than a dynamic -filter. The editor pairs this with the headroom guard to suggest a protective -pre-amp; this module only mints the bands. -""" +"""System DSP presets""" import uuid diff --git a/audera/domains/dsp/rew.py b/audera/domains/dsp/rew.py index 662f752..86eaf88 100644 --- a/audera/domains/dsp/rew.py +++ b/audera/domains/dsp/rew.py @@ -1,15 +1,6 @@ -"""Import/export parametric-EQ bands as CamillaDSP YAML (the format REW interops with). - -REW (v5.20.14+) natively round-trips CamillaDSP YAML: it exports the same structured -`filters:`/`pipeline:` shape our compiler emits, and it is the only format REW can -re-import. `parse_rew` and `format_rew` are band-inverses — the bands emitted by -`format_rew` round-trip back through `parse_rew` modulo fresh ids. Both are pure (they -import only `audera.models` + `audera.domains.dsp.compiler`, plus stdlib `uuid`, `yaml`, -and pydantic), so they are Docker-free and unit-testable with zero I/O. - -Pre-amp is intentionally not round-tripped: `format_rew` emits only the band biquads (the -editor's auto-ceiling owns the pre-amp on import), so a pasted export re-derives a -clip-safe pre-amp rather than carrying a stale one. +"""Import/export parametric-EQ bands as CamillaDSP YAML + +Compatible with REW (v5.20.14+). """ import uuid diff --git a/audera/ui/__init__.py b/audera/ui/__init__.py index 699e9ca..dd5549d 100644 --- a/audera/ui/__init__.py +++ b/audera/ui/__init__.py @@ -1 +1,4 @@ -"""Audera NiceGUI web UIs — player setup wizard and streamer dashboard""" +"""Audera NiceGUI apps + +WiFi setup wizard and Audera app +""" diff --git a/audera/ui/components/response_plot.py b/audera/ui/components/response_plot.py index 79c301f..e0a6332 100644 --- a/audera/ui/components/response_plot.py +++ b/audera/ui/components/response_plot.py @@ -1,11 +1,4 @@ -"""Live combined frequency-response chart for the parametric-EQ editor. - -Renders the curve `audera.domains.dsp.response_curve` already computes as an ECharts -line: a fixed −18…+18 dB box over a 20 Hz–20 kHz log frequency axis. The series is -clipped (ECharts default) so a hot trace stays inside the box rather than rescaling it. -UI→domain imports are allowed; `theme` is imported as a submodule so this module is -import-order-independent of the `components` package init. -""" +"""Frequency-response chart for the parametric-EQ editor""" from nicegui import ui diff --git a/audera/ui/streamer/__init__.py b/audera/ui/streamer/__init__.py index b29953a..ff9e6f0 100644 --- a/audera/ui/streamer/__init__.py +++ b/audera/ui/streamer/__init__.py @@ -1,4 +1,4 @@ -"""Audera streamer web UI""" +"""Audera app""" from nicegui import app, ui @@ -8,7 +8,7 @@ def run() -> None: - """Runs the streamer dashboard.""" + """Runs the Audera app.""" page = Page() page.load() diff --git a/audera/ui/streamer/pages/__init__.py b/audera/ui/streamer/pages/__init__.py index ff11110..275cac2 100644 --- a/audera/ui/streamer/pages/__init__.py +++ b/audera/ui/streamer/pages/__init__.py @@ -1,10 +1,4 @@ -"""Audera streamer dashboard pages. - -The `Page` class holds the shared per-app state (settings, Snapcast client, dialog -flag) and registers routes; each route's rendering lives in its own module and is -invoked as `module.render(page, …)`, so the class stays thin while the large EQ editor -(`dsp`) and the multi-tab dashboard (`index`) live in dedicated files. -""" +"""Audera app pages""" from dotenv import load_dotenv from nicegui import ui diff --git a/audera/ui/streamer/pages/dsp.py b/audera/ui/streamer/pages/dsp.py index 363fe69..94daaa6 100644 --- a/audera/ui/streamer/pages/dsp.py +++ b/audera/ui/streamer/pages/dsp.py @@ -1,4 +1,4 @@ -"""The full-page parametric-EQ editor for a single player.""" +"""Remote audio device parametric-EQ editor page""" import asyncio import uuid diff --git a/audera/ui/streamer/pages/index.py b/audera/ui/streamer/pages/index.py index 0dcc310..df74b44 100644 --- a/audera/ui/streamer/pages/index.py +++ b/audera/ui/streamer/pages/index.py @@ -1,4 +1,4 @@ -"""The main dashboard page — Players, Services, and Settings tabs.""" +"""Audera app index""" import asyncio from typing import TYPE_CHECKING From 7aa302fdefd86588b4161847bc16809a9ed2f488 Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Fri, 10 Jul 2026 13:39:54 -0500 Subject: [PATCH 13/18] feat: add cached env-settings singleton (audera/settings.py) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralize env-driven deployment config in one pydantic-settings singleton loaded once at startup, with backwards-compatible defaults (today's constants). `audera.__init__` re-exports the port values from the singleton so every `audera.*_PORT` call site is unchanged but now overridable via `AUDERA_*` env vars (and `.env`) — e.g. `AUDERA_SERVER_PORT=8080` for local Windows dev. - New `audera/settings.py`: `Settings(BaseSettings)` — hosts, service ports, and web-UI bind, all `AUDERA_`-prefixed. - Migrate scattered `os.getenv` host reads and hardcoded ports: streamer/setup `run()` bind to `server_host`/`server_port`; `_clients` seeds hosts from the singleton and lets an explicitly-set env host override a stale persisted `settings.json` (detected via `model_fields_set`). - Add `pydantic-settings>=2.0`; document the knobs in `.env.example`. - Docs: trim retired Group/Stream refs from AGENTS.md; README wording. Co-Authored-By: Claude Opus 4.8 --- .env.example | 19 ++++++++--- AGENTS.md | 4 --- README.md | 2 +- audera/__init__.py | 12 ++++--- audera/settings.py | 47 ++++++++++++++++++++++++++++ audera/ui/setup/__init__.py | 10 +++++- audera/ui/streamer/__init__.py | 3 +- audera/ui/streamer/pages/_clients.py | 19 ++++++++--- pyproject.toml | 1 + uv.lock | 18 ++++++++++- 10 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 audera/settings.py diff --git a/.env.example b/.env.example index 63c1515..95441a7 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,16 @@ -# PlexAmp headless — session status + playback controls (no token required) -AUDERA_PLEXAMP_HOST=192.168.1.x +# Cached environment/deployment settings — every knob is read once at startup by +# `audera.settings.Settings` (prefix `AUDERA_`). Copy to `.env` and override as +# needed; the defaults below reproduce the built-in values. -# Snapserver — JSON-RPC WebSocket (HTTP port 1780) -AUDERA_SNAPSERVER_HOST=localhost +# Hosts (backend services) +AUDERA_SNAPSERVER_HOST=localhost # Snapserver — JSON-RPC WebSocket +AUDERA_PLEXAMP_HOST=localhost # PlexAmp headless — session status + playback + +# Service ports +AUDERA_SNAPSERVER_PORT=1780 +AUDERA_CAMILLADSP_PORT=1234 +AUDERA_PLEXAMP_PORT=32500 + +# Web UI bind +AUDERA_SERVER_HOST=0.0.0.0 +AUDERA_SERVER_PORT=80 # e.g. 8080 on Windows to avoid elevation diff --git a/AGENTS.md b/AGENTS.md index 343a48f..7f0a39c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,12 +40,8 @@ Significant technical and UX decisions are recorded in `docs/adrs/`. Consult the All models are `@dataclass` with `from_dict()` / `from_config()` / `to_dict()` / `__repr__()` (JSON) / `__eq__()`. - `Player` — Snapcast client: `id, host, port, connected, volume, muted, group_id` -- `Group` — Snapcast group: `id, name, client_ids, stream_id, muted, volume` -- `Stream` — Plex-Amp stream: `id, name, uri, status, current_track` - `DSPConfig` — parametric-EQ config: `player_id, preamp_db, bands, enabled` (keyed by `player_id`; the CamillaDSP pipeline is compiled from `preamp_db` + `bands` on Save) -`Player.group_id` and `Group.stream_id` are empty strings (not `None`) when unassigned — required by the pytensils `'str'` DTYPES constraint. - The `dsp` models (`Band`/`DSPConfig`/`Preset`) are pydantic `BaseModel` (the `@dataclass` convention has drifted here); `Preset` (`id, name, bands`) serializes via `model_dump()`/`model_validate` — no hand-written `to_dict`. ## DAL diff --git a/README.md b/README.md index 3783e59..ef30467 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ \ \__\ \__\ \______/\ \______/\ \______\ \__\\ _\\ \__\ \__\ \|__|\|__|\|______| \|______| \|______|\|__|\|__|\|__|\|__| -`Audera` is a new era of composable audio systems that brings open-protocols to your own hardware for multi-room synchronous playback, built on [Plex-Amp](https://www.plex.tv/plexamp/) (headless), [Snapcast](https://github.com/badaix/snapcast), and [CamillaDSP](https://github.com/HEnquist/camilladsp). +`Audera` is a new era of composable audio systems that brings open-protocols to your own hardware for multi-room synchronous playback, built with [Plex-Amp](https://www.plex.tv/plexamp/) (headless), [Snapcast](https://github.com/badaix/snapcast), and [CamillaDSP](https://github.com/HEnquist/camilladsp). ## Architecture diff --git a/audera/__init__.py b/audera/__init__.py index 2a6ae91..0e3ec14 100644 --- a/audera/__init__.py +++ b/audera/__init__.py @@ -6,6 +6,7 @@ from audera import dal, models from audera.services import ap, logging, netifaces, platform +from audera.settings import settings __all__ = [ 'platform', @@ -27,8 +28,9 @@ # Websites HOME: str = 'https://github.com/Eleff-org/audera' -# Service ports -SNAPSERVER_PORT: int = 1780 -CAMILLADSP_PORT: int = 1234 -PLEXAMP_PORT: int = 32500 -SERVER_PORT: int = 80 +# Service ports (sourced from the cached environment-settings singleton so the +# `audera.*_PORT` call sites stay unchanged while becoming env-overridable) +SNAPSERVER_PORT: int = settings.snapserver_port +CAMILLADSP_PORT: int = settings.camilladsp_port +PLEXAMP_PORT: int = settings.plexamp_port +SERVER_PORT: int = settings.server_port diff --git a/audera/settings.py b/audera/settings.py new file mode 100644 index 0000000..b574f03 --- /dev/null +++ b/audera/settings.py @@ -0,0 +1,47 @@ +"""Cached environment/deployment settings.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Environment-driven deployment settings, loaded once at startup. + + Every field is overridable via an `AUDERA_`-prefixed environment variable + (or a matching key in a local `.env` file); the defaults reproduce the + values that were previously hardcoded across the package. + + Attributes + ---------- + snapserver_host: `str` + The hostname or IP address of the Snapserver instance. + plexamp_host: `str` + The hostname or IP address of the PlexAmp headless instance. + snapserver_port: `int` + The Snapserver HTTP/JSON-RPC WebSocket port. + camilladsp_port: `int` + The CamillaDSP WebSocket port. + plexamp_port: `int` + The PlexAmp headless HTTP port. + server_host: `str` + The interface the web UI binds to. + server_port: `int` + The port the web UI binds to. + """ + + model_config = SettingsConfigDict(env_prefix='AUDERA_', env_file='.env', extra='ignore') + + # Hosts (backend services) + snapserver_host: str = 'localhost' + plexamp_host: str = 'localhost' + + # Service ports + snapserver_port: int = 1780 + camilladsp_port: int = 1234 + plexamp_port: int = 32500 + + # Web UI bind + server_host: str = '0.0.0.0' + server_port: int = 80 + + +settings = Settings() diff --git a/audera/ui/setup/__init__.py b/audera/ui/setup/__init__.py index 9ed781f..a66c328 100644 --- a/audera/ui/setup/__init__.py +++ b/audera/ui/setup/__init__.py @@ -5,6 +5,7 @@ from nicegui import app, ui import audera +from audera.settings import settings from audera.ui import components from audera.ui.setup.pages import Page @@ -23,6 +24,13 @@ def run(role: Literal['streamer', 'player'] = 'player') -> None: components.theme.apply_defaults() try: - ui.run(host='0.0.0.0', port=80, title=audera.NAME.strip().lower(), show=False, reload=False, reconnect_timeout=60) + ui.run( + host=settings.server_host, + port=settings.server_port, + title=audera.NAME.strip().lower(), + show=False, + reload=False, + reconnect_timeout=60, + ) except KeyboardInterrupt: app.shutdown() diff --git a/audera/ui/streamer/__init__.py b/audera/ui/streamer/__init__.py index ff9e6f0..fc3a1cc 100644 --- a/audera/ui/streamer/__init__.py +++ b/audera/ui/streamer/__init__.py @@ -3,6 +3,7 @@ from nicegui import app, ui import audera +from audera.settings import settings from audera.ui import components from audera.ui.streamer.pages import Page @@ -15,6 +16,6 @@ def run() -> None: components.theme.apply_defaults() try: - ui.run(host='0.0.0.0', port=audera.SERVER_PORT, title=audera.NAME, show=False, reload=False) + ui.run(host=settings.server_host, port=settings.server_port, title=audera.NAME, show=False, reload=False) except KeyboardInterrupt: app.shutdown() diff --git a/audera/ui/streamer/pages/_clients.py b/audera/ui/streamer/pages/_clients.py index db215cb..3061dd3 100644 --- a/audera/ui/streamer/pages/_clients.py +++ b/audera/ui/streamer/pages/_clients.py @@ -1,23 +1,32 @@ """Client factories and settings loader shared across the streamer pages.""" -import os - import audera from audera.clients import CamillaDSPClient, SnapserverClient from audera.dal import settings as settings_dal from audera.models.settings import Settings +from audera.settings import settings as env from audera.ui import features def _load_settings() -> Settings: - return settings_dal.get_or_create( + cfg = settings_dal.get_or_create( Settings( - plexamp_host=os.getenv('AUDERA_PLEXAMP_HOST', 'localhost'), - snapserver_host=os.getenv('AUDERA_SNAPSERVER_HOST', 'localhost'), + plexamp_host=env.plexamp_host, + snapserver_host=env.snapserver_host, features=features.default_selections(), ) ) + # An explicitly-set `AUDERA_*` host overrides the persisted `settings.json` so the + # local-dev override stays reliable even after the file has been written; an unset + # var leaves the persisted value untouched (backwards compatible). + if 'snapserver_host' in env.model_fields_set: + cfg.snapserver_host = env.snapserver_host + if 'plexamp_host' in env.model_fields_set: + cfg.plexamp_host = env.plexamp_host + + return cfg + def _snapserver(settings: Settings) -> SnapserverClient: return SnapserverClient(host=settings.snapserver_host, port=audera.SNAPSERVER_PORT) diff --git a/pyproject.toml b/pyproject.toml index 8683b84..c41b34a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ classifiers = [ dependencies = [ "duckdb>=1.5.1", "pydantic>=2.0", + "pydantic-settings>=2.0", "netifaces>=0.11.0", "nicegui>=3.10.0", "python-dotenv>=1.2.2", diff --git a/uv.lock b/uv.lock index b9b0a3d..5341eb0 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = "==3.13.*" [options] -exclude-newer = "2026-07-02T19:14:54.2422552Z" +exclude-newer = "2026-07-03T18:29:39.8456585Z" exclude-newer-span = "P1W" [[package]] @@ -120,6 +120,7 @@ dependencies = [ { name = "netifaces" }, { name = "nicegui" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "pyyaml" }, { name = "websockets" }, @@ -145,6 +146,7 @@ requires-dist = [ { name = "nicegui", specifier = ">=3.10.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" }, { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic-settings", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, @@ -811,6 +813,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + [[package]] name = "pygments" version = "2.20.0" From 08667d232855d4ad1164122efd3679aba01db6d3 Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Fri, 10 Jul 2026 18:45:06 -0500 Subject: [PATCH 14/18] refactor: polish DSP editor + player-card UI, sanitize config filenames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI — DSP editor (streamer/pages/dsp.py): - Breadcrumb header (Players › {name} › DSP) + back button, replacing the plain title/link - Presets ▾ / Config ▾ promoted to their own row as dropdown buttons; menu items relabeled (Loudness, Clear, New preset, Import, Export) - Widen the auto-protected pre-amp field so its label no longer truncates; wrap=False keeps Reset/Save inline at narrow widths - Band-table column header renders only once a band exists - Empty-state hint restyled as a Material for MkDocs "info" admonition (blue, info icon, bold + ADD BAND) UI — player cards (streamer/pages/index.py): - DSP button uses sym_o_airwave (sound waves) and leads the settings gear; drop the bordered circle (rendered a sub-pixel oval); add a latency hint UI — response chart (components/response_plot.py): - Compact styling: tighter grid margins, endpoint-only axis labels (20 Hz / 20k, ±18 dB), shorter height Fix — cross-platform filenames (dal/path.py, dal/dsp.py, dal/presets.py): - to_filename() sanitizes reserved characters so a MAC-address player id (colons) maps to a valid Windows filename; the dsp/presets DALs route through it Docs/tests: - Document the screenshot-loop workflow in audera/ui/AGENTS.md - Cover the MAC-address round-trip; update streamer assertions for the new breadcrumb + info tip Co-Authored-By: Claude Opus 4.8 --- audera/dal/dsp.py | 8 +-- audera/dal/path.py | 24 +++++++++ audera/dal/presets.py | 4 +- audera/ui/AGENTS.md | 15 ++++++ audera/ui/components/response_plot.py | 17 +++++-- audera/ui/streamer/pages/dsp.py | 70 +++++++++++++++++---------- audera/ui/streamer/pages/index.py | 34 ++++++++----- tests/dal/test_dsp.py | 19 ++++++++ tests/ui/test_streamer.py | 5 +- 9 files changed, 144 insertions(+), 52 deletions(-) diff --git a/audera/dal/dsp.py b/audera/dal/dsp.py index f1720fa..870fdda 100644 --- a/audera/dal/dsp.py +++ b/audera/dal/dsp.py @@ -18,7 +18,7 @@ def exists(player_id: str) -> bool: player_id: `str` The player identifier. """ - return os.path.isfile(os.path.abspath(os.path.join(PATH, '.'.join([player_id, 'json'])))) + return os.path.isfile(os.path.abspath(os.path.join(PATH, path.to_filename(player_id)))) def create(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: @@ -40,7 +40,7 @@ def get(player_id: str) -> dsp.DSPConfig: player_id: `str` The player identifier. """ - file_path = os.path.join(PATH, '.'.join([player_id, 'json'])) + file_path = os.path.join(PATH, path.to_filename(player_id)) with open(file_path, 'r') as f: data = json.load(f) return dsp.DSPConfig.from_dict(data['dsp']) @@ -70,7 +70,7 @@ def save(dsp_config: dsp.DSPConfig) -> dsp.DSPConfig: """ if not os.path.isdir(PATH): os.makedirs(PATH) - file_path = os.path.join(PATH, '.'.join([dsp_config.player_id, 'json'])) + file_path = os.path.join(PATH, path.to_filename(dsp_config.player_id)) with open(file_path, 'w') as f: json.dump({'dsp': dsp_config.to_dict()}, f, indent=2) return dsp_config @@ -100,4 +100,4 @@ def delete(player_id: str): The player identifier. """ if exists(player_id): - os.remove(os.path.join(PATH, '.'.join([player_id, 'json']))) + os.remove(os.path.join(PATH, path.to_filename(player_id))) diff --git a/audera/dal/path.py b/audera/dal/path.py index 2ebf431..c70d5ed 100644 --- a/audera/dal/path.py +++ b/audera/dal/path.py @@ -1,5 +1,29 @@ """Data-access directory management""" import os +import re HOME = os.path.abspath(os.path.join(os.path.expanduser('~'), '.audera')) + +# Characters that are illegal in a Windows filename (`< > : " / \ | ? *` and the ASCII +# control codes). A Snapcast player id is a MAC address, so its colons would otherwise +# make `dsp/{player_id}.json` an invalid path on Windows and raise OSError 22 on open. +# POSIX only forbids `/`, but the same set is sanitized on every platform so a given id +# always resolves to the same filename regardless of where the streamer runs. +_RESERVED_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]') + + +def to_filename(stem: str, extension: str = 'json') -> str: + """Returns a cross-platform-safe `{stem}.{extension}` filename for a config key. + + Reserved characters in `stem` — notably the colons in a MAC-address player id — are + replaced with `-` so the name is valid on Windows as well as POSIX. + + Parameters + ---------- + stem: `str` + The file stem (e.g. a player id or preset id). + extension: `str` + The file extension, without a leading dot. + """ + return '.'.join([_RESERVED_CHARS.sub('-', stem), extension]) diff --git a/audera/dal/presets.py b/audera/dal/presets.py index f853ae1..8f513ae 100644 --- a/audera/dal/presets.py +++ b/audera/dal/presets.py @@ -39,7 +39,7 @@ def save_preset(preset: dsp.Preset) -> dsp.Preset: An instance of an `audera.models.dsp.Preset` object. """ os.makedirs(PATH, exist_ok=True) - file_path = os.path.join(PATH, '.'.join([preset.id, 'json'])) + file_path = os.path.join(PATH, path.to_filename(preset.id)) with open(file_path, 'w') as f: json.dump({'preset': preset.model_dump()}, f, indent=2) return preset @@ -55,6 +55,6 @@ def delete_preset(id: str) -> None: id: `str` The preset identifier. """ - file_path = os.path.join(PATH, '.'.join([id, 'json'])) + file_path = os.path.join(PATH, path.to_filename(id)) if os.path.isfile(file_path): os.remove(file_path) diff --git a/audera/ui/AGENTS.md b/audera/ui/AGENTS.md index c5b8ab6..964dc77 100644 --- a/audera/ui/AGENTS.md +++ b/audera/ui/AGENTS.md @@ -71,3 +71,18 @@ No implementation logic belongs in `__init__.py`. Features with more than one valid UX ("mute checkbox vs. disabled toggle") are registered in `audera/ui/features.py`'s `FEATURES` catalog, not hard-coded as a single rendering path. Every feature ships 2-3 `Option`s; the first is the default. Resolve a user's selection with `audera.ui.features.selected(settings, key)` or `flag_enabled(settings, key, option)` — never read `settings.features[...]` directly. When asked to implement a new UI feature, treat optionality as the default, not an afterthought: if there's more than one defensible UX for it, propose a `Feature` catalog entry (or ask the user which options to offer) before picking one and hard-coding it. Only skip the catalog when a feature genuinely has one correct UX with no reasonable alternative. + +## Previewing UI changes (screenshot loop) + +The user iterates on UI from screenshots. Most player UI only renders with live Snapcast clients, so preview a component in isolation rather than the full app: + +1. Write a throwaway `_btn_preview.py` at the repo root that reproduces the exact widget/props against `components.theme.apply_defaults()`. Reuse **port 8080** every run so the user doesn't open new browser tabs. +2. Run it as a **background task** (not `foo &`) so it can be killed cleanly. `uv run python`'s child process holds the socket, and a stray one causes a port conflict (WinError 10048) on the next run — stop it with the task tooling, not `kill`. +3. Screenshot headless and review, then iterate: + ```bash + "/c/Program Files/Google/Chrome/Application/chrome.exe" --headless --disable-gpu --screenshot=/tmp/preview.png --force-device-scale-factor=4 --virtual-time-budget=4000 http://127.0.0.1:8080 + ``` + `--force-device-scale-factor` zooms for pixel inspection; `--virtual-time-budget` gives Material Symbols web-fonts time to load. +4. **Clean up before committing**: stop the background server, delete `_btn_preview.py`, and free port 8080. The harness is never committed. + +The real app runs with `reload=False`, so the user restarts it to see applied changes. diff --git a/audera/ui/components/response_plot.py b/audera/ui/components/response_plot.py index e0a6332..b88fa95 100644 --- a/audera/ui/components/response_plot.py +++ b/audera/ui/components/response_plot.py @@ -17,20 +17,27 @@ def options(config: DSPConfig) -> dict: """ frequencies, magnitudes = response_curve(config) return { - 'grid': {'left': 44, 'right': 16, 'top': 16, 'bottom': 32}, + 'grid': {'left': 28, 'right': 12, 'top': 10, 'bottom': 20}, 'tooltip': {'trigger': 'axis'}, 'xAxis': { 'type': 'log', 'min': 20, 'max': 20000, - 'axisLabel': {'formatter': '{value} Hz'}, - 'splitLine': {'lineStyle': {'color': '#eeeeee'}}, + # Only the endpoints carry a label — 20 Hz and 20 kHz — everything between is blank + # (`:`-prefixed key → NiceGUI evaluates the value as a JS function; see echart.js). + 'axisLabel': { + 'showMinLabel': True, + 'showMaxLabel': True, + ':formatter': "v => v <= 20 ? '20' : v >= 20000 ? '20k' : ''", + }, + 'splitLine': {'show': False}, }, 'yAxis': { 'type': 'value', 'min': -18, 'max': 18, - 'axisLabel': {'formatter': '{value} dB'}, + 'interval': 36, # a single 36 dB step lands ticks only on the ±18 endpoints + 'axisLabel': {'formatter': '{value}'}, 'splitLine': {'lineStyle': {'color': '#eeeeee'}}, }, 'series': [ @@ -53,4 +60,4 @@ def render(config: DSPConfig) -> ui.echart: config: `audera.models.dsp.DSPConfig` An instance of an `audera.models.dsp.DSPConfig` object. """ - return ui.echart(options(config)).classes('w-full h-64') + return ui.echart(options(config)).classes('w-full h-40') diff --git a/audera/ui/streamer/pages/dsp.py b/audera/ui/streamer/pages/dsp.py index 94daaa6..9b2453a 100644 --- a/audera/ui/streamer/pages/dsp.py +++ b/audera/ui/streamer/pages/dsp.py @@ -176,8 +176,8 @@ def _on_save_preset() -> None: @ui.refreshable def _presets_menu() -> None: - ui.menu_item('Loudness (seed bands)', on_click=lambda: _apply_preset('loudness')).mark('preset-loudness') - ui.menu_item('Flat / clear all bands', on_click=lambda: _apply_preset('flat')).mark('preset-flat') + ui.menu_item('Loudness', on_click=lambda: _apply_preset('loudness')).mark('preset-loudness') + ui.menu_item('Clear', on_click=lambda: _apply_preset('flat')).mark('preset-flat') saved = presets_dal.get_all_presets() if saved: ui.separator() @@ -192,7 +192,7 @@ def _presets_menu() -> None: .on('click.stop') # .stop: delete doesn't also fire append ) ui.separator() - ui.menu_item('Save current as preset…', on_click=_open_save_preset_dialog).mark('preset-save-as') + ui.menu_item('New preset', on_click=_open_save_preset_dialog).mark('preset-save-as') def _open_import_dialog() -> None: """Opens a paste-import dialog that appends CamillaDSP YAML filters as bands. @@ -315,13 +315,14 @@ async def _poll_clips() -> None: @ui.refreshable def _band_table() -> None: - with ui.row(wrap=False).classes('items-center gap-2 w-full text-xs text-gray-500'): - ui.label('On').classes('w-10 text-center') - ui.label('Type').classes('w-32') - ui.label('Freq (Hz)').classes('w-24') - ui.label('Gain (dB)').classes('w-24') - ui.label('Q').classes('w-20') - ui.label('').classes('w-10') + if state['staged'].bands: # the column labels only make sense once there's a row beneath them + with ui.row(wrap=False).classes('items-center gap-2 w-full text-xs text-gray-500'): + ui.label('On').classes('w-10 text-center') + ui.label('Type').classes('w-32') + ui.label('Freq (Hz)').classes('w-24') + ui.label('Gain (dB)').classes('w-24') + ui.label('Q').classes('w-20') + ui.label('').classes('w-10') for band in state['staged'].bands: with ui.row(wrap=False).classes('items-center gap-2 w-full'): ui.checkbox(value=band.enabled, on_change=lambda e, b=band: _on_enabled(b, e.value)).classes('w-10') @@ -346,11 +347,29 @@ def _band_table() -> None: ui.button('+ Add band', on_click=_add_band).props('flat dense').classes('mt-2') with ui.row().classes('items-center justify-between w-full'): - ui.label(f'{live.name} · Advanced DSP').classes('text-lg font-medium') - ui.link('‹ Players', '/') + with ui.row().classes('items-center gap-2 text-sm'): + ui.link('Players', '/').classes('text-gray-500 no-underline hover:text-gray-700') + ui.label('›').classes('text-gray-400') + ui.label(live.name).classes('text-gray-500') + ui.label('›').classes('text-gray-400') + ui.label('DSP').classes('text-gray-800 font-medium') # the current segment leads + ui.button(icon='arrow_back', on_click=lambda: ui.navigate.to('/')).props('flat dense round size=sm').mark('dsp-back') with ui.column().classes('w-full gap-3'): - with ui.row().classes('items-center gap-4 w-full'): + # Presets and Config are the two "sources" that build a pipeline, so they lead on their + # own row; the pre-amp and the Reset/Save actions sit in line beneath them. + with ui.row().classes('items-center gap-2 w-full'): + with ui.button('Presets').props('flat dense icon-right=arrow_drop_down'): + with ui.menu(): + _presets_menu() + with ui.button('Config').props('flat dense icon-right=arrow_drop_down'): + with ui.menu(): + ui.menu_item('Import', on_click=_open_import_dialog).mark('config-import') + ui.menu_item('Export', on_click=_open_export_dialog).mark('config-export') + + # wrap=False keeps Reset/Save on the pre-amp's line: the widened field would + # otherwise push Save onto a second row at narrower window widths. + with ui.row(wrap=False).classes('items-center gap-4 w-full'): preamp_field = ( ui.number( 'Pre-amp (dB) · auto-protected', @@ -360,25 +379,24 @@ def _band_table() -> None: on_change=_on_preamp, ) .props('dense outlined') - .classes('w-40') + .classes('w-64') ) - with ui.button('Presets', icon='tune').props('flat dense'): - with ui.menu(): - _presets_menu() - with ui.button('Config', icon='import_export').props('flat dense'): - with ui.menu(): - ui.menu_item('Import CamillaDSP YAML…', on_click=_open_import_dialog).mark('config-import') - ui.menu_item('Export CamillaDSP YAML…', on_click=_open_export_dialog).mark('config-export') ui.space() ui.button('Reset', on_click=_on_reset).props('flat dense') ui.button('Save', on_click=_on_save).props('dense').classes('bg-gray-800 text-white') - # Persistent handle + empty-state message, both toggled by the forward-closure - # `_mark_changed`: the chart shows only once a band exists, the message otherwise. + # Persistent handle + empty-state tip, both toggled by the forward-closure + # `_mark_changed`: the chart shows only once a band exists, the tip otherwise. chart = components.response_plot.render(state['staged']) - chart_message = ui.label( - 'Add a band to see the live frequency-response curve — start from Presets ▾, or + Add band below.' - ).classes('text-sm text-gray-500 p-4') + with ( + ui.column() + .classes('w-full gap-1 border-l-4 border-blue-500 rounded px-3 py-2') + .style('background-color: rgba(59, 130, 246, 0.1)') as chart_message + ): + with ui.row().classes('items-center gap-1.5'): + ui.icon('info').classes('text-blue-600 text-base') + ui.label('Info').classes('text-sm font-semibold text-gray-800') + ui.html('Load a preset or click + ADD BAND to build a DSP pipeline.').classes('text-sm text-gray-700') _band_table() diff --git a/audera/ui/streamer/pages/index.py b/audera/ui/streamer/pages/index.py index df74b44..f5a8787 100644 --- a/audera/ui/streamer/pages/index.py +++ b/audera/ui/streamer/pages/index.py @@ -179,24 +179,28 @@ def build_players_tab(page: 'Page') -> None: mute_cb = ui.checkbox( 'Mute', value=client.muted, on_change=lambda e, c=client: _on_mute_change(page, c.id, e.value) ) - settings_btn = ( - ui.button(on_click=lambda c=client: _open_settings_dialog(page, c)) - .props('icon=edit_square flat dense round size=sm') - .classes('text-gray-400') - .mark('player-settings') - ) - # Disable the settings icon for a disabled player, matching the intent of "disable". - if minimized: - settings_btn.set_enabled(False) + # DSP first (left), then settings (right), both plain material icons. An icon reads + # as clickable on its own (like the gear), and dropping the bordered circle avoids + # the sub-pixel oval it rendered at some viewport widths. As two icon buttons with + # identical props they are the same size by construction — no pixel pinning needed. + # `sym_o_airwave` (a Material Symbol — hence the `sym_o_` prefix) reads as sound + # waves, fitting the DSP page. dsp_btn = ( ui.button(on_click=lambda c=client: ui.navigate.to(f'/player/{c.id}/dsp')) - .props('icon=equalizer flat dense round size=sm') - .classes('text-gray-400') + .props('icon=sym_o_airwave flat dense round size=sm') .mark('player-dsp') ) - # A disabled player has no live pipeline to edit, so gray out its EQ icon too. + # A disabled player has no live pipeline to edit, so gray out its DSP button too. if minimized: dsp_btn.set_enabled(False) + settings_btn = ( + ui.button(on_click=lambda c=client: _open_settings_dialog(page, c)) + .props('icon=settings flat dense round size=sm') + .mark('player-settings') + ) + # Disable the settings button for a disabled player, matching the intent of "disable". + if minimized: + settings_btn.set_enabled(False) if minimized: continue @@ -239,7 +243,11 @@ def _open_settings_dialog(page: 'Page', client) -> None: ui.label('Settings').classes('font-medium text-lg mb-2') name_input = ui.input('Name', value=client.name).classes('w-full') - latency_input = ui.number('Latency (ms)', value=client.latency_ms, min=-500, max=500, step=1).classes('w-full') + latency_input = ( + ui.number('Latency (ms)', value=client.latency_ms, min=-500, max=500, step=1) + .classes('w-full') + .props('hint="Adds playback delay."') + ) # Snapcast volume is a sidecar kept at 100/0; CamillaDSP controls actual loudness. snap_vol = client.volume diff --git a/tests/dal/test_dsp.py b/tests/dal/test_dsp.py index 1ac097e..eaac7fa 100644 --- a/tests/dal/test_dsp.py +++ b/tests/dal/test_dsp.py @@ -1,3 +1,5 @@ +import os + import audera.dal.dsp as dsp_dal from audera.models.dsp import Band, DSPConfig @@ -60,6 +62,23 @@ def test_dsp_bands_round_trip(audera_home): assert [b.type for b in result.bands] == ['Lowshelf', 'Highshelf', 'Peaking'] +def test_dsp_round_trip_with_mac_address_id(audera_home): + # A Snapcast player id is a MAC address, whose colons are illegal in a Windows + # filename (OSError 22). The DAL must sanitize them out of `dsp/{player_id}.json`. + config = _make_dsp(player_id='d8:3a:dd:80:3c:91') + dsp_dal.create(config) + + assert dsp_dal.exists('d8:3a:dd:80:3c:91') + assert dsp_dal.get('d8:3a:dd:80:3c:91') == config + + # The file on disk carries no colon — the id maps to a filesystem-safe stem. + json_files = [f for f in os.listdir(dsp_dal.PATH) if f.endswith('.json')] + assert json_files == ['d8-3a-dd-80-3c-91.json'] + + dsp_dal.delete('d8:3a:dd:80:3c:91') + assert not dsp_dal.exists('d8:3a:dd:80:3c:91') + + def test_dsp_get_or_create_creates(audera_home): config = _make_dsp() assert not dsp_dal.exists(config.player_id) diff --git a/tests/ui/test_streamer.py b/tests/ui/test_streamer.py index c8f900d..9ab5326 100644 --- a/tests/ui/test_streamer.py +++ b/tests/ui/test_streamer.py @@ -431,7 +431,8 @@ async def test_players_tab_shows_dsp_icon(audera_home, mock_snapserver_with_clie async def test_dsp_page_renders(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): Page().load() await user.open('/player/abc123/dsp') - await user.should_see('Advanced DSP') + await user.should_see('Living Room') # breadcrumb player segment + await user.should_see('DSP') # breadcrumb tail segment await user.should_see('Pre-amp (dB)') await user.should_see('Presets') await user.should_see('Save') @@ -486,7 +487,7 @@ async def test_dsp_bandless_shows_chart_message_and_hides_chart( ): Page().load() await user.open('/player/abc123/dsp') - await user.should_see('Add a band') + await user.should_see('Load a preset') # the empty-state tip await user.should_not_see(kind=ui.echart) From a0750572fef2f3fe427364cd7601fdaabcc48905 Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Thu, 23 Jul 2026 17:09:40 -0500 Subject: [PATCH 15/18] feat: user-selectable DSP band-editor UX (full / expanded / dialog) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the parametric-EQ band-row rendering into a runtime feature flag (audera/ui/features.py DSP_BAND_EDITOR_KEY) with three options, defaulting to `full`: - full — the existing inline On/Type/Freq/Gain/Q row with column headers - expanded — compact one-line rows in per-band cards; ✏ grows the card to reveal labeled controls inline (one open at a time) - dialog — compact rows in per-band cards; ✏ raises a modal with the controls stacked vertically full-width for narrow phone screens Refactor dsp.py around shared `_band_controls` (label/width switches) and `_compact_row` builders plus a `_band_summary` helper; `_on_type` now takes a refresh target so a type-change re-renders whichever tree owns the gain field (table body vs. dialog body). Add per-mode page tests and value pins. Co-Authored-By: Claude Opus 4.8 --- audera/ui/features.py | 14 +++ audera/ui/streamer/pages/dsp.py | 180 +++++++++++++++++++++++++++----- tests/ui/test_features.py | 9 ++ tests/ui/test_streamer.py | 87 +++++++++++++++ 4 files changed, 264 insertions(+), 26 deletions(-) diff --git a/audera/ui/features.py b/audera/ui/features.py index 174cbbc..4d446bd 100644 --- a/audera/ui/features.py +++ b/audera/ui/features.py @@ -62,10 +62,15 @@ def option(self, value: str) -> Option: PLAYER_SELECTION_KEY = 'player_selection' VOLUME_KEY = 'volume' +DSP_BAND_EDITOR_KEY = 'dsp_band_editor' FF_DISABLED_VS_MUTE = 'disabled' FF_VOLUME_PERC_OR_DB = 'db' +FF_DSP_BAND_EDITOR_FULL = 'full' +FF_DSP_BAND_EDITOR_EXPAND = 'expand' +FF_DSP_BAND_EDITOR_DIALOG = 'dialog' + FEATURES: list[Feature] = [ Feature( PLAYER_SELECTION_KEY, @@ -77,6 +82,15 @@ def option(self, value: str) -> Option: 'Volume', (Option('percent', 'Percent'), Option('db', 'Decibels')), ), + Feature( + DSP_BAND_EDITOR_KEY, + 'DSP Band Editor', + ( + Option(FF_DSP_BAND_EDITOR_FULL, 'Full'), + Option(FF_DSP_BAND_EDITOR_EXPAND, 'Expanded'), + Option(FF_DSP_BAND_EDITOR_DIALOG, 'Dialog'), + ), + ), ] diff --git a/audera/ui/streamer/pages/dsp.py b/audera/ui/streamer/pages/dsp.py index 9b2453a..f0ef363 100644 --- a/audera/ui/streamer/pages/dsp.py +++ b/audera/ui/streamer/pages/dsp.py @@ -11,7 +11,7 @@ from audera.dal import presets as presets_dal from audera.domains.dsp import auto_preamp_db, clone_bands, compile_pipeline, format_rew, loudness_preset, parse_rew from audera.models.dsp import PASS_TYPES, Band, DSPConfig, Preset -from audera.ui import components +from audera.ui import components, features from audera.ui.streamer.pages._clients import _camilladsp, _snapserver if TYPE_CHECKING: @@ -23,6 +23,15 @@ _BAND_TYPES = list(get_args(Band.model_fields['type'].annotation)) +def _band_summary(band: Band) -> str: + """One-line band description for compact rows; omits gain for pass filters.""" + parts = [band.type, f'{band.freq:.0f} Hz'] + if band.type not in PASS_TYPES: + parts.append(f'{band.gain:.1f} dB') + parts.append(f'Q {band.q:.3f}') + return ' · '.join(parts) + + def render(page: 'Page', player_id: str) -> None: """Renders the full-page parametric-EQ editor for a single player. @@ -53,6 +62,9 @@ def render(page: 'Page', player_id: str) -> None: # so `live.id` is all that's needed to resolve it. saved = dsp_dal.get_or_create(DSPConfig(player_id=live.id)) state = {'saved': saved, 'staged': saved.model_copy(deep=True)} + dsp_band_editor = features.selected(page.settings, features.DSP_BAND_EDITOR_KEY) + # `expand` mode reveals one band's controls at a time (single id ⇒ one-open-at-a-time). + expanded = {'id': None} def _dirty() -> bool: return state['staged'] != state['saved'] @@ -84,11 +96,11 @@ def _on_enabled(band: Band, value: bool) -> None: band.enabled = bool(value) _mark_changed() - def _on_type(band: Band, value: str) -> None: + def _on_type(band: Band, value: str, refresh) -> None: # `value` is constrained to `_BAND_TYPES` by the select; the assignment is # unvalidated (Band sets no `validate_assignment`), so the literal narrows fine. band.type = value # type: ignore - _band_table.refresh() # the gain field's enabled state depends on the type + refresh() # re-render the tree owning the gain field (its enabled state depends on the type) _mark_changed() def _on_freq(band: Band, value) -> None: @@ -107,12 +119,21 @@ def _on_q(band: Band, value) -> None: _mark_changed() def _add_band() -> None: - state['staged'].bands.append(Band(id=uuid.uuid4().hex, type='Peaking', freq=1000.0, gain=0.0, q=0.707)) + # A default Peaking band can't be edited from a compact row alone, so the + # compact modes auto-reveal its editor (accordion for expand, modal for dialog). + band = Band(id=uuid.uuid4().hex, type='Peaking', freq=1000.0, gain=0.0, q=0.707) + state['staged'].bands.append(band) + if dsp_band_editor == features.FF_DSP_BAND_EDITOR_EXPAND: + expanded['id'] = band.id _band_table.refresh() _mark_changed() + if dsp_band_editor == features.FF_DSP_BAND_EDITOR_DIALOG: + _open_band_dialog(band) def _remove_band(band: Band) -> None: state['staged'].bands = [b for b in state['staged'].bands if b.id != band.id] + if expanded['id'] == band.id: # tidy; a dangling id is otherwise harmless (matches no row) + expanded['id'] = None _band_table.refresh() _mark_changed() @@ -313,9 +334,113 @@ async def _poll_clips() -> None: else: clip_label.set_visibility(False) + def _band_controls(band: Band, refresh, labeled: bool = False, full_width: bool = False) -> None: + """Renders the Type / Freq / Gain / Q editors — shared by all three variants. + + Excludes the On checkbox and delete button (those live on the row and are never + duplicated in the editor). `refresh` re-renders the tree that owns the gain field, + whose enabled state flips with the type — the table body in full/expand, the dialog + body in dialog mode (a separate tree `_band_table.refresh()` can't reach). + + `labeled` adds floating field labels for the header-less compact modes (full mode + keeps its own column-header row instead). `full_width` stretches every field to fill + its container, for the dialog's vertical stack on narrow phone screens. + """ + type_w = 'w-full' if full_width else 'w-32' + num_w = 'w-full' if full_width else 'w-24' + q_w = 'w-full' if full_width else 'w-20' + ui.select( + _BAND_TYPES, + value=band.type, + label='Type' if labeled else None, + on_change=lambda e, b=band: _on_type(b, e.value, refresh), + ).props('dense outlined').classes(type_w) + ui.number( + 'Freq (Hz)' if labeled else None, + value=band.freq, + step=1, + format='%.0f', + on_change=lambda e, b=band: _on_freq(b, e.value), + ).props('dense outlined debounce=200').classes(num_w) + gain_field = ( + ui.number( + 'Gain (dB)' if labeled else None, + value=band.gain, + step=0.1, + format='%.1f', + on_change=lambda e, b=band: _on_gain(b, e.value), + ) + .props('dense outlined debounce=200') + .classes(num_w) + ) + gain_field.set_enabled(band.type not in PASS_TYPES) + ui.number( + 'Q' if labeled else None, + value=band.q, + step=0.001, + format='%.3f', + on_change=lambda e, b=band: _on_q(b, e.value), + ).props('dense outlined debounce=200').classes(q_w) + + def _compact_row(band: Band, on_edit) -> None: + """Renders a one-line band row (On · summary · ✏ edit · 🗑 delete) for expand + dialog. + + The two compact modes differ only in the ✏ handler passed as `on_edit`. + """ + with ui.row(wrap=False).classes('items-center gap-2 w-full'): + ui.checkbox(value=band.enabled, on_change=lambda e, b=band: _on_enabled(b, e.value)).classes('w-10') + ui.label(_band_summary(band)).classes('grow text-sm') + ( + ui.button(icon='edit', on_click=lambda b=band: on_edit(b)) + .props('flat dense round size=sm') + .classes('text-gray-400') + .mark('dsp-band-edit') + ) + ( + ui.button(icon='delete', on_click=lambda b=band: _remove_band(b)) + .props('flat dense round size=sm') + .classes('text-gray-400') + .mark('dsp-band-delete') + ) + + def _toggle_expand(band: Band) -> None: + expanded['id'] = None if expanded['id'] == band.id else band.id + _band_table.refresh() # rebuilds every summary from live band values + + def _open_band_dialog(band: Band) -> None: + """Raises a modal with the band controls; edits apply live (chart previews behind it). + + The dialog body is its own nested refreshable so a type-change re-renders the dialog + (not the table). Single Close button — no snapshot; the table refreshes on close so + the compact summary reflects the live edits. + """ + with ui.dialog() as dialog, ui.card().classes('w-96'): + ui.label('Edit band').classes('font-medium text-lg mb-1') + + @ui.refreshable + def _dialog_body() -> None: + # Vertical stack — phones have the vertical room, and full-width labeled + # fields read far more clearly than a cramped horizontal row. + with ui.column().classes('w-full gap-3'): + _band_controls(band, refresh=_dialog_body.refresh, labeled=True, full_width=True) + + _dialog_body() + + def _close() -> None: + _band_table.refresh() # summary line now reflects the live edits + dialog.close() + + with ui.row().classes('justify-end w-full mt-2'): + (ui.button('Close', on_click=_close).props('dense').classes('bg-gray-800 text-white').mark('dsp-band-close')) + + dialog.open() + @ui.refreshable def _band_table() -> None: - if state['staged'].bands: # the column labels only make sense once there's a row beneath them + bands = state['staged'].bands + # The column labels only make sense in full mode (compact rows self-describe) and + # only once there's a row beneath them. + if bands and dsp_band_editor == features.FF_DSP_BAND_EDITOR_FULL: with ui.row(wrap=False).classes('items-center gap-2 w-full text-xs text-gray-500'): ui.label('On').classes('w-10 text-center') ui.label('Type').classes('w-32') @@ -323,27 +448,30 @@ def _band_table() -> None: ui.label('Gain (dB)').classes('w-24') ui.label('Q').classes('w-20') ui.label('').classes('w-10') - for band in state['staged'].bands: - with ui.row(wrap=False).classes('items-center gap-2 w-full'): - ui.checkbox(value=band.enabled, on_change=lambda e, b=band: _on_enabled(b, e.value)).classes('w-10') - ui.select(_BAND_TYPES, value=band.type, on_change=lambda e, b=band: _on_type(b, e.value)).props( - 'dense outlined' - ).classes('w-32') - ui.number(value=band.freq, step=1, format='%.0f', on_change=lambda e, b=band: _on_freq(b, e.value)).props( - 'dense outlined debounce=200' - ).classes('w-24') - gain_field = ( - ui.number(value=band.gain, step=0.1, format='%.1f', on_change=lambda e, b=band: _on_gain(b, e.value)) - .props('dense outlined debounce=200') - .classes('w-24') - ) - gain_field.set_enabled(band.type not in PASS_TYPES) - ui.number(value=band.q, step=0.001, format='%.3f', on_change=lambda e, b=band: _on_q(b, e.value)).props( - 'dense outlined debounce=200' - ).classes('w-20') - ui.button(icon='delete', on_click=lambda b=band: _remove_band(b)).props('flat dense round size=sm').classes( - 'text-gray-400' - ) + for band in bands: + if dsp_band_editor == features.FF_DSP_BAND_EDITOR_FULL: + with ui.row(wrap=False).classes('items-center gap-2 w-full'): + ui.checkbox(value=band.enabled, on_change=lambda e, b=band: _on_enabled(b, e.value)).classes('w-10') + _band_controls(band, refresh=_band_table.refresh) + ( + ui.button(icon='delete', on_click=lambda b=band: _remove_band(b)) + .props('flat dense round size=sm') + .classes('text-gray-400') + .mark('dsp-band-delete') + ) + elif dsp_band_editor == features.FF_DSP_BAND_EDITOR_EXPAND: + # Each band is its own card; the ✏ edit grows the card downward to reveal + # the labeled controls inline (one card open at a time). + with ui.card().classes('w-full p-3 gap-2'): + _compact_row(band, on_edit=_toggle_expand) + if expanded['id'] == band.id: + with ui.row().classes('items-center gap-2 w-full pl-10'): + _band_controls(band, refresh=_band_table.refresh, labeled=True) + else: # FF_DSP_BAND_EDITOR_DIALOG + # Each band is its own card (matching the expanded view); the ✏ edit raises + # the modal rather than growing the card inline. + with ui.card().classes('w-full p-3 gap-2'): + _compact_row(band, on_edit=_open_band_dialog) ui.button('+ Add band', on_click=_add_band).props('flat dense').classes('mt-2') with ui.row().classes('items-center justify-between w-full'): diff --git a/tests/ui/test_features.py b/tests/ui/test_features.py index a072e03..dfdab33 100644 --- a/tests/ui/test_features.py +++ b/tests/ui/test_features.py @@ -84,3 +84,12 @@ def test_flag_enabled_false_when_unset_uses_default(): def test_option_resolves_unknown_value_to_default(): feature = features.get_feature(features.PLAYER_SELECTION_KEY) assert feature.option('does_not_exist') == feature.default + + +def test_default_selections_includes_dsp_band_editor_full(): + assert features.default_selections()[features.DSP_BAND_EDITOR_KEY] == features.FF_DSP_BAND_EDITOR_FULL + + +def test_dsp_band_editor_has_three_options(): + feature = features.get_feature(features.DSP_BAND_EDITOR_KEY) + assert [opt.value for opt in feature.options] == ['full', 'expand', 'dialog'] diff --git a/tests/ui/test_streamer.py b/tests/ui/test_streamer.py index 9ab5326..905fbe3 100644 --- a/tests/ui/test_streamer.py +++ b/tests/ui/test_streamer.py @@ -17,6 +17,7 @@ from audera.models.settings import Settings from audera.ui import components, features from audera.ui.streamer.pages import Page +from audera.ui.streamer.pages.dsp import _band_summary @pytest.fixture @@ -674,3 +675,89 @@ async def test_dsp_save_current_as_preset_persists(audera_home, mock_snapserver_ assert len(saved) == 1 assert saved[0].name == 'My Loudness' assert len(saved[0].bands) == 2 + + +# --- DSP band-editor UX (full / expand / dialog) ----------------------------------------- + + +def test_band_summary_omits_gain_for_pass_types(): + # `dB` also appears in the `Pre-amp (dB)` label, so the pass-type omission is asserted on + # the pure helper rather than page-level DOM matching. + assert 'dB' not in _band_summary(Band(id='x', type='Highpass', freq=80.0, gain=0.0, q=0.7)) + assert 'dB' in _band_summary(Band(id='y', type='Peaking', freq=1000.0, gain=-3.0, q=0.707)) + + +def _seed_band_editor(mode: str) -> None: + """Persists settings selecting the DSP band-editor mode before the page loads.""" + settings_dal.create( + Settings( + plexamp_host='localhost', + snapserver_host='localhost', + features={features.DSP_BAND_EDITOR_KEY: mode}, + ) + ) + + +# Discriminator for collapsed-vs-revealed controls: the DSP editor has no `ui.select` anywhere +# except the band Type control, so its presence cleanly signals whether controls are showing. +def _one_band() -> DSPConfig: + return DSPConfig(player_id='cfg1', bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=-3.0, q=0.707)]) + + +async def test_dsp_band_editor_full_shows_inline_controls( + audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User +): + _seed_band_editor(features.FF_DSP_BAND_EDITOR_FULL) + _seed_dsp(_one_band()) + Page().load() + await user.open('/player/abc123/dsp') + await user.should_see(kind=ui.select) # full mode renders the type/freq/gain/q controls inline + + +async def test_dsp_band_editor_expand_reveals_controls_on_edit( + audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User +): + _seed_band_editor(features.FF_DSP_BAND_EDITOR_EXPAND) + _seed_dsp(_one_band()) + Page().load() + await user.open('/player/abc123/dsp') + await user.should_see('1000 Hz') # the compact summary row + await user.should_not_see(kind=ui.select) # controls stay collapsed until the ✏ edit + user.find(marker='dsp-band-edit').click() + await user.should_see(kind=ui.select) # accordion revealed the controls inline + + +async def test_dsp_band_editor_dialog_opens_modal_on_edit( + audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User +): + _seed_band_editor(features.FF_DSP_BAND_EDITOR_DIALOG) + _seed_dsp(_one_band()) + Page().load() + await user.open('/player/abc123/dsp') + await user.should_see('1000 Hz') # the compact summary row + await user.should_not_see(kind=ui.select) # controls live in the modal, not the row + user.find(marker='dsp-band-edit').click() + await user.should_see('Edit band') # the modal title + await user.should_see(marker='dsp-band-close') + await user.should_see(kind=ui.select) # controls render inside the modal + + +async def test_dsp_band_editor_expand_add_band_reveals_controls( + audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User +): + _seed_band_editor(features.FF_DSP_BAND_EDITOR_EXPAND) + Page().load() + await user.open('/player/abc123/dsp') + await user.should_not_see(kind=ui.select) + user.find(content='+ Add band').click() + await user.should_see(kind=ui.select) # the new band's editor auto-reveals — no second click + + +async def test_dsp_band_editor_dialog_add_band_opens_modal( + audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User +): + _seed_band_editor(features.FF_DSP_BAND_EDITOR_DIALOG) + Page().load() + await user.open('/player/abc123/dsp') + user.find(content='+ Add band').click() + await user.should_see('Edit band') # the new band's editor auto-opens as a modal From 16e02289393d9f068085bc7056e3de16ed533f77 Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Fri, 24 Jul 2026 07:40:17 -0500 Subject: [PATCH 16/18] fix: derive DSP pre-amp from bands, normalize negative-zero ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-protected pre-amp used a min() clamp that only ratcheted down, so removing a boost band (or reducing gain / disabling a band / widening Q) left the pre-amp stuck low. Make the pre-amp a pure function of the bands — a read-only field recomputed on every change — and drop the speculative manual sub-ceiling attenuation. Legacy pre-amp values are normalized on load so the editor opens clean. Also snap float-noise ceilings to positive 0.0: eval_filter leaves a ~1e-14 dB residual on a flat/unity response (e.g. the default 0 dB Peaking band), which negated to a tiny -0.0 and rendered as "-0.0" in the field. Docs: fill in the ui/AGENTS.md "Running a local dev server" section, rename the preview harness to _preview.py, and unwrap the hard-wrapped prose. Co-Authored-By: Claude Opus 4.8 --- audera/domains/dsp/headroom.py | 9 +++++++- audera/ui/AGENTS.md | 36 +++++++++++++++++------------- audera/ui/streamer/pages/dsp.py | 29 +++++++++--------------- tests/domains/dsp/test_headroom.py | 15 +++++++++++++ tests/ui/test_streamer.py | 34 +++++++++------------------- 5 files changed, 63 insertions(+), 60 deletions(-) diff --git a/audera/domains/dsp/headroom.py b/audera/domains/dsp/headroom.py index 3ca8127..9778d5a 100644 --- a/audera/domains/dsp/headroom.py +++ b/audera/domains/dsp/headroom.py @@ -9,6 +9,10 @@ _SAMPLERATE = 48000 _CHART_NPOINTS = 400 _SAFETY_NPOINTS = 1000 +# Ceilings within this of 0 dB are float noise from a flat/net-cut/unity response (a 0 dB Peaking +# band peaks at ~1e-14 dB), not real attenuation; they get snapped to positive 0.0. Sits far above +# the observed noise (~1e-14) yet far below the editor's 0.1 dB display resolution. +_ZERO_CEILING_TOL = 1e-9 def _summed_magnitude(bands: list[Band], samplerate: int, npoints: int) -> list[float]: @@ -104,4 +108,7 @@ def auto_preamp_db(bands: list[Band], samplerate: int = _SAMPLERATE, margin_db: Extra headroom in dB reserved below 0 dBFS (default 0.0). """ summed = _summed_magnitude(bands, samplerate, npoints=_SAFETY_NPOINTS) - return -(max(0.0, max(summed)) + margin_db) + ceiling = -(max(0.0, max(summed)) + margin_db) + # A flat/net-cut/unity response needs no attenuation, but the magnitude eval leaves a + # sub-nanodecibel residual that negates to a tiny -0.0 (renders as '-0.0'); snap it to 0.0. + return 0.0 if abs(ceiling) < _ZERO_CEILING_TOL else ceiling diff --git a/audera/ui/AGENTS.md b/audera/ui/AGENTS.md index 964dc77..41fcbf7 100644 --- a/audera/ui/AGENTS.md +++ b/audera/ui/AGENTS.md @@ -14,20 +14,10 @@ Every UI app follows this two-file pattern: - Page methods: one per route (e.g. `index()`, `welcome()`, `connect()`) - Tab builder methods: `_build__tab()` — private, called from a page method -**`pages/` sub-package** — when `pages.py` grows past a few hundred lines, split it into a -`pages/` package instead of one flat file: -- `pages/__init__.py`: the thin `Page` class (`__init__`, `load()`) re-exported so - `from …pages import Page` is unchanged. Each route method delegates to a module-level - `render(page, …)` — e.g. `def dsp(self, player_id): dsp.render(self, player_id)`. -- One module per route (`pages/index.py`, `pages/dsp.py`, …): a `render(page, …)` function - plus that route's private `_build__tab(page)` / `_on_(page, …)` helpers. - `page` carries the shared state (`page.settings`, `page._dialog_open`); the `Page` class is - **not** split across files. `@ui.refreshable` helpers stay module-level, keyed on the - `page` argument, and are refreshed via `_build__tab.refresh()`. -- Private helper modules (`pages/_clients.py`, `pages/_plex.py`, …): shared client factories - and self-contained flows, imported by the route modules. To avoid an import cycle, route - modules never import names from `pages/__init__.py` at runtime — they take `page` as an - argument and import `Page` only under `TYPE_CHECKING`. +**`pages/` sub-package** — when `pages.py` grows past a few hundred lines, split it into a `pages/` package instead of one flat file: +- `pages/__init__.py`: the thin `Page` class (`__init__`, `load()`) re-exported so `from …pages import Page` is unchanged. Each route method delegates to a module-level `render(page, …)` — e.g. `def dsp(self, player_id): dsp.render(self, player_id)`. +- One module per route (`pages/index.py`, `pages/dsp.py`, …): a `render(page, …)` function plus that route's private `_build__tab(page)` / `_on_(page, …)` helpers. `page` carries the shared state (`page.settings`, `page._dialog_open`); the `Page` class is **not** split across files. `@ui.refreshable` helpers stay module-level, keyed on the `page` argument, and are refreshed via `_build__tab.refresh()`. +- Private helper modules (`pages/_clients.py`, `pages/_plex.py`, …): shared client factories and self-contained flows, imported by the route modules. To avoid an import cycle, route modules never import names from `pages/__init__.py` at runtime — they take `page` as an argument and import `Page` only under `TYPE_CHECKING`. **`__init__.py`** — thin `run()` entry point only: ```python @@ -76,13 +66,27 @@ When asked to implement a new UI feature, treat optionality as the default, not The user iterates on UI from screenshots. Most player UI only renders with live Snapcast clients, so preview a component in isolation rather than the full app: -1. Write a throwaway `_btn_preview.py` at the repo root that reproduces the exact widget/props against `components.theme.apply_defaults()`. Reuse **port 8080** every run so the user doesn't open new browser tabs. +1. Write a throwaway `_preview.py` at the repo root that reproduces the exact widget/props against `components.theme.apply_defaults()`. Reuse **port 8080** every run so the user doesn't open new browser tabs. 2. Run it as a **background task** (not `foo &`) so it can be killed cleanly. `uv run python`'s child process holds the socket, and a stray one causes a port conflict (WinError 10048) on the next run — stop it with the task tooling, not `kill`. 3. Screenshot headless and review, then iterate: ```bash "/c/Program Files/Google/Chrome/Application/chrome.exe" --headless --disable-gpu --screenshot=/tmp/preview.png --force-device-scale-factor=4 --virtual-time-budget=4000 http://127.0.0.1:8080 ``` `--force-device-scale-factor` zooms for pixel inspection; `--virtual-time-budget` gives Material Symbols web-fonts time to load. -4. **Clean up before committing**: stop the background server, delete `_btn_preview.py`, and free port 8080. The harness is never committed. +4. **Clean up before committing**: stop the background server, delete `_preview.py`, and free port 8080. The harness is never committed. The real app runs with `reload=False`, so the user restarts it to see applied changes. + +## Running a local dev server + +When a change needs the **full app** against real players — e.g. the DSP editor, which only renders for a live Snapcast client — run the streamer itself pointed at a streamer on the network. Override the deployment settings (`audera/settings.py`) with `AUDERA_`-prefixed env vars and call `streamer.run()` directly: that's exactly what `audera streamer start` runs, minus the Raspberry-Pi network-setup gate that's inappropriate on a dev box. + +```bash +AUDERA_SNAPSERVER_HOST= AUDERA_PLEXAMP_HOST= AUDERA_SERVER_HOST=127.0.0.1 AUDERA_SERVER_PORT=8080 uv run python -c "from audera.ui import streamer; streamer.run()" +``` + +- **Bind loopback only** (`AUDERA_SERVER_HOST=127.0.0.1`). The app defaults to `0.0.0.0`, which exposes the UI to the whole network — never bind non-loopback for a local review. +- **Port 8080**, not the default `80` (needs admin on Windows and tends to conflict); reuse it every run, same as the screenshot loop. +- Run it as a **background task** so it can be stopped cleanly with the task tooling, not `kill` (see the port-conflict note above). +- The DSP editor lives at `/player//dsp`; enumerate client ids with `SnapserverClient(host=..., port=...).get_clients()` when you need to deep-link a screenshot. +- `reload=False`, so **restart the server** to pick up code changes — stop the background task, then relaunch. \ No newline at end of file diff --git a/audera/ui/streamer/pages/dsp.py b/audera/ui/streamer/pages/dsp.py index f0ef363..5116d5e 100644 --- a/audera/ui/streamer/pages/dsp.py +++ b/audera/ui/streamer/pages/dsp.py @@ -40,7 +40,7 @@ def render(page: 'Page', player_id: str) -> None: across every connected client): `state['saved']` mirrors the persisted config and `state['staged']` is the working copy that is compiled, validated, and pushed on Save. Scalar field edits mutate a band in place and only recompute the dirty - indicator, clip-safe pre-amp clamp, and live response chart; structural changes + indicator, derived pre-amp, and live response chart; structural changes (add/delete/type/preset/reset) refresh the band table. """ components.header.render(audera.NAME, 'Streamer') @@ -61,6 +61,8 @@ def render(page: 'Page', player_id: str) -> None: # The config is keyed by the player's own id (`dsp/{player_id}.json` is the link), # so `live.id` is all that's needed to resolve it. saved = dsp_dal.get_or_create(DSPConfig(player_id=live.id)) + # The pre-amp is fully derived; normalize any legacy value so the editor opens clean. + saved.preamp_db = auto_preamp_db(saved.bands) state = {'saved': saved, 'staged': saved.model_copy(deep=True)} dsp_band_editor = features.selected(page.settings, features.DSP_BAND_EDITOR_KEY) # `expand` mode reveals one band's controls at a time (single id ⇒ one-open-at-a-time). @@ -70,11 +72,11 @@ def _dirty() -> bool: return state['staged'] != state['saved'] def _mark_changed() -> None: - """Clamps pre-amp to the clip-safe ceiling, then refreshes dirty state, chart, count.""" - clamped = min(state['staged'].preamp_db, auto_preamp_db(state['staged'].bands)) - if clamped != state['staged'].preamp_db: # min is a fixpoint — converges in one pass - state['staged'].preamp_db = clamped - preamp_field.value = clamped + """Recomputes the auto pre-amp from the bands, then refreshes dirty state, chart, count.""" + auto = auto_preamp_db(state['staged'].bands) + if auto != state['staged'].preamp_db: + state['staged'].preamp_db = auto + preamp_field.value = auto dirty_label.set_visibility(_dirty()) has_bands = bool(state['staged'].bands) # chart only once a band exists chart.set_visibility(has_bands) @@ -87,11 +89,6 @@ def _mark_changed() -> None: chart.update() count_label.set_text(f'Bands ({len(state["staged"].bands)})') - def _on_preamp(e) -> None: - if e.value is not None: - state['staged'].preamp_db = float(e.value) - _mark_changed() - def _on_enabled(band: Band, value: bool) -> None: band.enabled = bool(value) _mark_changed() @@ -499,14 +496,8 @@ def _band_table() -> None: # otherwise push Save onto a second row at narrower window widths. with ui.row(wrap=False).classes('items-center gap-4 w-full'): preamp_field = ( - ui.number( - 'Pre-amp (dB) · auto-protected', - value=state['staged'].preamp_db, - step=0.1, - format='%.1f', - on_change=_on_preamp, - ) - .props('dense outlined') + ui.number('Pre-amp (dB) · auto-protected', value=state['staged'].preamp_db, format='%.1f') + .props('dense outlined readonly') .classes('w-64') ) ui.space() diff --git a/tests/domains/dsp/test_headroom.py b/tests/domains/dsp/test_headroom.py index 428f360..cc9230e 100644 --- a/tests/domains/dsp/test_headroom.py +++ b/tests/domains/dsp/test_headroom.py @@ -1,3 +1,5 @@ +import math + import pytest from camilladsp_plot import eval_filter from camilladsp_plot.eval_filterconfig import logspace @@ -89,6 +91,19 @@ def test_auto_preamp_db_cancels_boost_peak_so_clamped_config_never_clips(): assert response_peak_db(clamped) <= 1e-6 +def test_auto_preamp_db_returns_positive_zero_not_negative_zero(): + # A flat/net-cut/unity response needs no attenuation, but its ceiling arrives as either exact + # IEEE-754 -0.0 or a sub-nanodecibel float-noise residual (the default 0 dB Peaking band added + # via '+ Add band' peaks at ~1e-14 dB). All must normalize to positive 0.0 so the editor never + # renders '-0.0'. + all_cut = Band(id='b1', type='Peaking', freq=1000.0, gain=-6.0, q=1.0) + unity = Band(id='b2', type='Peaking', freq=1000.0, gain=0.0, q=0.707) # the default added band + for ceiling in (auto_preamp_db([]), auto_preamp_db([all_cut]), auto_preamp_db([unity])): + assert ceiling == 0.0 + assert math.copysign(1.0, ceiling) == 1.0 # positive zero + assert f'{ceiling:.1f}' == '0.0' # mirrors the editor's format='%.1f' + + def test_auto_preamp_db_honours_margin(): band = Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0) no_margin = auto_preamp_db([band]) diff --git a/tests/ui/test_streamer.py b/tests/ui/test_streamer.py index 905fbe3..822afff 100644 --- a/tests/ui/test_streamer.py +++ b/tests/ui/test_streamer.py @@ -500,37 +500,23 @@ async def test_dsp_adding_band_reveals_chart(audera_home, mock_snapserver_with_c await user.should_see(kind=ui.echart) -async def test_dsp_preamp_clamp_keeps_below_ceiling_value( - audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User -): - # A +6 dB boost sets the ceiling at ~-6 dB; -10 is below it, so the clamp leaves it. - _seed_dsp(DSPConfig(player_id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) - Page().load() - await user.open('/player/abc123/dsp') - with user: - user.find(kind=ui.number, content='auto-protected').elements.pop().value = -10.0 - await asyncio.sleep(0.1) - assert user.find(kind=ui.number, content='auto-protected').elements.pop().value == pytest.approx(-10.0) - - -async def test_dsp_preamp_clamp_pulls_above_ceiling_value_down( - audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User -): - # Raising the pre-amp to -2 dB over a +6 dB boost would clip, so the clamp snaps it back - # down to the ~-6 dB clip-safe ceiling. - _seed_dsp(DSPConfig(player_id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) +async def test_dsp_preamp_rises_when_band_removed(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): + # The pre-amp is fully derived from the bands: removing the boost that lowered it must + # return it to 0 (regression: the old min() clamp only ratcheted down, never up). + band = Band(id='b1', type='Peaking', freq=1000.0, gain=5.0, q=0.707) + _seed_dsp(DSPConfig(player_id='cfg1', preamp_db=-5.0, bands=[band])) Page().load() await user.open('/player/abc123/dsp') with user: - user.find(kind=ui.number, content='auto-protected').elements.pop().value = -2.0 + user.find(marker='dsp-band-delete').click() await asyncio.sleep(0.1) - assert user.find(kind=ui.number, content='auto-protected').elements.pop().value == pytest.approx(-6.0, abs=0.2) + assert user.find(kind=ui.number, content='auto-protected').elements.pop().value == pytest.approx(0.0, abs=0.05) async def test_dsp_saved_config_opens_clean(audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User): - # Save always clamps the pre-amp to the clip-safe ceiling, so a saved config's pre-amp is - # never above it; the `_mark_changed` clamp is a fixpoint on open and the editor opens - # without a false "Unsaved changes" flag. + # The pre-amp is fully derived from the bands and normalized on load, so a saved config's + # seeded pre-amp is replaced by the recomputed clip-safe ceiling; the editor opens without + # a false "Unsaved changes" flag. _seed_dsp(DSPConfig(player_id='cfg1', preamp_db=-6.0, bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=6.0, q=1.0)])) Page().load() await user.open('/player/abc123/dsp') From c1a208fbdfe322ea7a3089ff2da98ff72244e89a Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Fri, 24 Jul 2026 08:18:53 -0500 Subject: [PATCH 17/18] =?UTF-8?q?fix:=20address=20PR=20#49=20final=20revie?= =?UTF-8?q?w=20=E2=80=94=20chart=20axes,=20sticky=20layout,=20preset=20col?= =?UTF-8?q?lision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response chart (response_plot.py): - y-axis max fixed at +5 dB display headroom (auto pre-amp keeps the curve ≤ 0 dB); min floors at -18 dB and auto-extends downward in 6 dB steps when a deep cut dips below, tracking only the visible [20, 20 kHz] window. - x-axis: full vertical decade lines at 100/1k/10k, minor ticks at the primary sub-decade frequencies, and the axis line + 20/20k labels dropped to the grid bottom (onZero=False). DSP editor layout (dsp.py): - Sticky editor: the chart, info line, "+ Add band", and full-mode column headers are pinned; only the band rows scroll. Wrapped the scroll area in a relative grow box with an absolute-inset child so QScrollArea keeps a concrete height instead of collapsing at short viewports. Preset save (dsp.py): - Case-insensitive, trimmed name-collision check before saving. On a match a confirm dialog offers Replace (reuses the existing id, overwrites in place) or Cancel (leaves the save dialog open to rename). Tests: preset-collision Replace/Cancel flows + a response_plot axis unit test. Co-Authored-By: Claude Opus 4.8 --- audera/ui/components/response_plot.py | 46 ++++++++++++-- audera/ui/streamer/pages/dsp.py | 92 ++++++++++++++++++++------- tests/ui/test_streamer.py | 61 ++++++++++++++++++ 3 files changed, 168 insertions(+), 31 deletions(-) diff --git a/audera/ui/components/response_plot.py b/audera/ui/components/response_plot.py index b88fa95..6af0952 100644 --- a/audera/ui/components/response_plot.py +++ b/audera/ui/components/response_plot.py @@ -1,11 +1,26 @@ """Frequency-response chart for the parametric-EQ editor""" +import math + from nicegui import ui from audera.domains.dsp import response_curve from audera.models.dsp import DSPConfig from audera.ui.components import theme +# The visible frequency window (Hz); the y-axis auto-min tracks only the curve inside it. +_X_MIN = 20 +_X_MAX = 20000 +# The y-axis floors at -18 dB and extends downward in tidy 6 dB steps when a filter dips below. +_Y_MIN_FLOOR = -18 +# The auto pre-amp keeps the curve ≤ 0 dB, so +5 is pure display headroom above unity. +_Y_MAX = 5 + + +def _floor_to_6(value: float) -> int: + """Rounds `value` down to the next lower multiple of 6 dB (keeps axis labels tidy).""" + return math.floor(value / 6) * 6 + def options(config: DSPConfig) -> dict: """Returns the ECharts option `dict` for a DSP configuration's response curve. @@ -16,13 +31,20 @@ def options(config: DSPConfig) -> dict: An instance of an `audera.models.dsp.DSPConfig` object. """ frequencies, magnitudes = response_curve(config) + # Auto-min tracks only the *visible* curve: `response_curve` grids from 1 Hz, but a pass + # filter's steep sub-20 Hz rolloff would otherwise drag the axis to an off-screen extreme + # (e.g. -60 dB) and squash the in-band curve. Window to [20, 20000] — always non-empty on the + # 400-point log grid. The floor holds at -18 dB and drops in 6 dB steps only when a deep cut + # inside the window demands it. + visible = [m for f, m in zip(frequencies, magnitudes) if _X_MIN <= f <= _X_MAX] + y_min = min(_Y_MIN_FLOOR, _floor_to_6(min(visible))) return { - 'grid': {'left': 28, 'right': 12, 'top': 10, 'bottom': 20}, + 'grid': {'left': 28, 'right': 12, 'top': 10, 'bottom': 24}, 'tooltip': {'trigger': 'axis'}, 'xAxis': { 'type': 'log', - 'min': 20, - 'max': 20000, + 'min': _X_MIN, + 'max': _X_MAX, # Only the endpoints carry a label — 20 Hz and 20 kHz — everything between is blank # (`:`-prefixed key → NiceGUI evaluates the value as a JS function; see echart.js). 'axisLabel': { @@ -30,13 +52,23 @@ def options(config: DSPConfig) -> dict: 'showMaxLabel': True, ':formatter': "v => v <= 20 ? '20' : v >= 20000 ? '20k' : ''", }, - 'splitLine': {'show': False}, + # Full vertical lines land on the log-axis decade ticks — within [20, 20000] that is + # exactly 100 / 1000 / 10000. + 'splitLine': {'show': True, 'lineStyle': {'color': '#eeeeee'}}, + # Major ticks on the decades; minor ticks at the 2–9 subdivisions per decade + # (30…90, 200…, 2k…). `minorSplitLine` off so only the three decades get a full line. + 'axisTick': {'show': True}, + 'minorTick': {'show': True}, + 'minorSplitLine': {'show': False}, + # Seat the axis line + 20/20k labels at the bottom of the grid rather than through + # y=0 (the range is asymmetric, so y=0 sits well above the floor). + 'axisLine': {'onZero': False}, }, 'yAxis': { 'type': 'value', - 'min': -18, - 'max': 18, - 'interval': 36, # a single 36 dB step lands ticks only on the ±18 endpoints + 'min': y_min, + 'max': _Y_MAX, + 'interval': _Y_MAX - y_min, # a single full-range step lands ticks only on the endpoints 'axisLabel': {'formatter': '{value}'}, 'splitLine': {'lineStyle': {'color': '#eeeeee'}}, }, diff --git a/audera/ui/streamer/pages/dsp.py b/audera/ui/streamer/pages/dsp.py index 5116d5e..d1f5a05 100644 --- a/audera/ui/streamer/pages/dsp.py +++ b/audera/ui/streamer/pages/dsp.py @@ -81,6 +81,8 @@ def _mark_changed() -> None: has_bands = bool(state['staged'].bands) # chart only once a band exists chart.set_visibility(has_bands) chart_message.set_visibility(not has_bands) + if header_row is not None: # full mode only; column headers show only above real rows + header_row.set_visibility(has_bands) if has_bands: # `EChart.options` is a read-only view onto the live props dict; swap its # contents in place (the documented "change the options" push) and redraw. @@ -173,17 +175,44 @@ def _open_save_preset_dialog() -> None: .mark('preset-save-name') ) - def _on_save_preset() -> None: - preset = Preset( - id=uuid.uuid4().hex, - name=(name_field.value or '').strip() or 'Untitled', - bands=clone_bands(state['staged'].bands), - ) + def _save_preset(preset: Preset, notice: str) -> None: presets_dal.save_preset(preset) _presets_menu.refresh() - ui.notify(f'Saved preset "{preset.name}"', type='positive', position='top-right') + ui.notify(notice, type='positive', position='top-right') dialog.close() + def _on_save_preset() -> None: + name = (name_field.value or '').strip() or 'Untitled' + bands = clone_bands(state['staged'].bands) + # Case-insensitive, trimmed name match against the same source the menu reads. + existing = next( + (preset for preset in presets_dal.get_all_presets() if preset.name.strip().lower() == name.lower()), + None, + ) + if existing is None: + _save_preset(Preset(id=uuid.uuid4().hex, name=name, bands=bands), f'Saved preset "{name}"') + return + + # A preset with this name already exists — confirm before overwriting it. + with ui.dialog() as confirm, ui.card().classes('w-96'): + ui.label(f'Preset "{name}" already exists.').classes('font-medium mb-1') + ui.label('Replace the existing preset with the current bands?').classes('text-xs text-gray-500 mb-2') + + def _on_replace() -> None: + # Reuse the existing id so save_preset overwrites the same {id}.json and any + # references to the preset stay stable. + _save_preset(Preset(id=existing.id, name=name, bands=bands), f'Replaced preset "{name}"') + confirm.close() + + with ui.row().classes('justify-between w-full mt-2'): + # Cancel dismisses only the confirm, leaving the save dialog open to rename. + ui.button('Cancel', on_click=confirm.close).props('flat dense').mark('preset-replace-cancel') + ui.button('Replace', on_click=_on_replace).props('dense').classes('bg-gray-800 text-white').mark( + 'preset-replace-confirm' + ) + + confirm.open() + with ui.row().classes('justify-between w-full mt-2'): ui.button('Cancel', on_click=dialog.close).props('flat dense') ui.button('Save', on_click=_on_save_preset).props('dense').classes('bg-gray-800 text-white').mark( @@ -434,18 +463,9 @@ def _close() -> None: @ui.refreshable def _band_table() -> None: - bands = state['staged'].bands - # The column labels only make sense in full mode (compact rows self-describe) and - # only once there's a row beneath them. - if bands and dsp_band_editor == features.FF_DSP_BAND_EDITOR_FULL: - with ui.row(wrap=False).classes('items-center gap-2 w-full text-xs text-gray-500'): - ui.label('On').classes('w-10 text-center') - ui.label('Type').classes('w-32') - ui.label('Freq (Hz)').classes('w-24') - ui.label('Gain (dB)').classes('w-24') - ui.label('Q').classes('w-20') - ui.label('').classes('w-10') - for band in bands: + # Only the band rows live here — the full-mode column headers and the '+ Add band' + # button sit in the fixed region above so they stay put while these rows scroll. + for band in state['staged'].bands: if dsp_band_editor == features.FF_DSP_BAND_EDITOR_FULL: with ui.row(wrap=False).classes('items-center gap-2 w-full'): ui.checkbox(value=band.enabled, on_change=lambda e, b=band: _on_enabled(b, e.value)).classes('w-10') @@ -469,7 +489,6 @@ def _band_table() -> None: # the modal rather than growing the card inline. with ui.card().classes('w-full p-3 gap-2'): _compact_row(band, on_edit=_open_band_dialog) - ui.button('+ Add band', on_click=_add_band).props('flat dense').classes('mt-2') with ui.row().classes('items-center justify-between w-full'): with ui.row().classes('items-center gap-2 text-sm'): @@ -480,7 +499,11 @@ def _band_table() -> None: ui.label('DSP').classes('text-gray-800 font-medium') # the current segment leads ui.button(icon='arrow_back', on_click=lambda: ui.navigate.to('/')).props('flat dense round size=sm').mark('dsp-back') - with ui.column().classes('w-full gap-3'): + # Viewport-bounded flex column: everything above the band table is pinned in the fixed + # region, and only the `grow` scroll area beneath it consumes the remaining height and + # scrolls (height tuned so the editor seats within the viewport under the breadcrumb). + with ui.column().classes('w-full gap-3 flex flex-col').style('height: calc(100vh - 9rem)'): + # --- Fixed region: presets, pre-amp, chart, info line, headers all stay put --- # Presets and Config are the two "sources" that build a pipeline, so they lead on their # own row; the pre-amp and the Reset/Save actions sit in line beneath them. with ui.row().classes('items-center gap-2 w-full'): @@ -517,14 +540,35 @@ def _band_table() -> None: ui.label('Info').classes('text-sm font-semibold text-gray-800') ui.html('Load a preset or click + ADD BAND to build a DSP pipeline.').classes('text-sm text-gray-700') - _band_table() - - with ui.row().classes('items-center gap-4 w-full mt-2 text-xs text-gray-500'): + # Band info + Add band sit above the scrolling table so they stay fixed with the chart. + with ui.row().classes('items-center gap-4 w-full text-xs text-gray-500'): count_label = ui.label() ui.label('IIR biquads · ~0% CPU') dirty_label = ui.label('Unsaved changes ●').classes('text-amber-500') clip_label = ui.label('').classes('text-red-500') clip_label.set_visibility(False) # hidden until the clip poll reports a nonzero count + ui.button('+ Add band', on_click=_add_band).props('flat dense') + + # Full mode's column headers stay pinned above the scrolling rows; their visibility + # tracks `has_bands` in `_mark_changed` (mode is fixed per page, so only that can change). + header_row = None + if dsp_band_editor == features.FF_DSP_BAND_EDITOR_FULL: + with ui.row(wrap=False).classes('items-center gap-2 w-full text-xs text-gray-500') as header_row: + ui.label('On').classes('w-10 text-center') + ui.label('Type').classes('w-32') + ui.label('Freq (Hz)').classes('w-24') + ui.label('Gain (dB)').classes('w-24') + ui.label('Q').classes('w-20') + ui.label('').classes('w-10') + + # --- Scrollable region: only the band rows scroll; `grow` claims the leftover height --- + # `grow min-h-0` lets this box shrink below its content and claim the column's leftover + # height; `relative` + the scroll area's `absolute inset-0` give QScrollArea a concrete + # box to fill (it collapses to nothing at short viewports when sized by flex alone). + with ui.element('div').classes('w-full grow min-h-0 relative'): + with ui.scroll_area().classes('absolute inset-0'): + _band_table() + _mark_changed() ui.timer(3.0, _poll_clips) diff --git a/tests/ui/test_streamer.py b/tests/ui/test_streamer.py index 822afff..3b8e4c4 100644 --- a/tests/ui/test_streamer.py +++ b/tests/ui/test_streamer.py @@ -663,6 +663,67 @@ async def test_dsp_save_current_as_preset_persists(audera_home, mock_snapserver_ assert len(saved[0].bands) == 2 +async def test_dsp_save_preset_name_collision_replace_overwrites( + audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User +): + # A trimmed, case-insensitive name match prompts a confirm; Replace overwrites the same + # preset (reusing its id) rather than appending a duplicate. + presets_dal.save_preset( + Preset(id='p1', name='My Preset', bands=[Band(id='b0', type='Peaking', freq=500.0, gain=2.0, q=1.0)]) + ) + Page().load() + await user.open('/player/abc123/dsp') + user.find(marker='preset-loudness').click() # stage two bands to capture + await user.should_see('Bands (2)') + user.find(marker='preset-save-as').click() + with user: + user.find(kind=ui.input, marker='preset-save-name').elements.pop().value = 'my preset' # differs only by case + user.find(marker='preset-save-run').click() + await user.should_see('already exists') # the collision confirm + user.find(marker='preset-replace-confirm').click() + await user.should_see('Replaced preset') + + saved = presets_dal.get_all_presets() + assert len(saved) == 1 # overwrote in place — no duplicate + assert saved[0].id == 'p1' # reused the existing id so references stay stable + assert len(saved[0].bands) == 2 # bands swapped to the staged loudness set + + +async def test_dsp_save_preset_name_collision_cancel_keeps_original( + audera_home, mock_snapserver_with_client, mock_camilladsp_dsp, user: User +): + # Cancel on the confirm leaves the existing preset untouched (and the save dialog open). + presets_dal.save_preset( + Preset(id='p1', name='My Preset', bands=[Band(id='b0', type='Peaking', freq=500.0, gain=2.0, q=1.0)]) + ) + Page().load() + await user.open('/player/abc123/dsp') + user.find(marker='preset-loudness').click() + await user.should_see('Bands (2)') + user.find(marker='preset-save-as').click() + with user: + user.find(kind=ui.input, marker='preset-save-name').elements.pop().value = 'My Preset' + user.find(marker='preset-save-run').click() + await user.should_see('already exists') + user.find(marker='preset-replace-cancel').click() + + saved = presets_dal.get_all_presets() + assert len(saved) == 1 + assert saved[0].id == 'p1' + assert len(saved[0].bands) == 1 # original bands untouched + + +def test_response_plot_options_axes(): + # Max is fixed display headroom (the auto pre-amp keeps the curve ≤ 0 dB); min floors at + # -18 dB but auto-extends downward when a deep cut dips below it. + flat = DSPConfig(player_id='cfg1', bands=[]) + flat_axis = components.response_plot.options(flat)['yAxis'] + assert flat_axis['max'] == 5 + assert flat_axis['min'] == -18 # floor holds with no deep cut + deep = DSPConfig(player_id='cfg1', bands=[Band(id='b1', type='Peaking', freq=1000.0, gain=-24.0, q=1.0)]) + assert components.response_plot.options(deep)['yAxis']['min'] < -18 + + # --- DSP band-editor UX (full / expand / dialog) ----------------------------------------- From 730832d191b87fd4f129b69661388e99f71ef79d Mon Sep 17 00:00:00 2001 From: thomaseleff Date: Fri, 24 Jul 2026 12:45:44 -0500 Subject: [PATCH 18/18] refactor: extract reusable fill_viewport layout primitive for DSP editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the DSP editor's hand-tuned `height: calc(100dvh - 6rem)` root-column constant with a reusable, page-scoped primitive (audera/ui/components/layout.py). `fill_viewport()` turns `.q-page`/`.nicegui-content` into a filling flex chain so the editor's `grow min-h-0` column inherits Quasar's header-aware, dvh-equivalent managed height — no per-page pixel/rem constant, and it re-derives on resize. The primitive also sets `w-full min-w-0` on `.nicegui-content`: as a flex item it otherwise sizes to its content (e.g. the chart canvas), pushing the page past the viewport and forcing a mobile zoom-out. `ui.query()` is page-scoped, so no other route is affected. Also folds in the DSP add-band UX tweaks: prepend new bands (always in view, ready to edit) and move `+ Add band` to the right of the info line for one-handed use. Verified via mobile CDP capture (390x844): fixed region stays put, only the band rows scroll, no horizontal overflow; players list (/) untouched. Co-Authored-By: Claude Opus 4.8 --- audera/ui/components/__init__.py | 4 +-- audera/ui/components/layout.py | 25 +++++++++++++ audera/ui/streamer/pages/dsp.py | 61 +++++++++++++++++++------------- 3 files changed, 64 insertions(+), 26 deletions(-) create mode 100644 audera/ui/components/layout.py diff --git a/audera/ui/components/__init__.py b/audera/ui/components/__init__.py index 125be52..a7a4cf7 100644 --- a/audera/ui/components/__init__.py +++ b/audera/ui/components/__init__.py @@ -1,5 +1,5 @@ """Shared NiceGUI UI primitives""" -from audera.ui.components import header, response_plot, theme +from audera.ui.components import header, layout, response_plot, theme -__all__ = ['header', 'response_plot', 'theme'] +__all__ = ['header', 'layout', 'response_plot', 'theme'] diff --git a/audera/ui/components/layout.py b/audera/ui/components/layout.py new file mode 100644 index 0000000..2bd1a5d --- /dev/null +++ b/audera/ui/components/layout.py @@ -0,0 +1,25 @@ +"""Reusable page-layout primitives""" + +from nicegui import ui + + +def fill_viewport() -> None: + """Makes the current page fill the viewport so a `grow` child can own the only scroll region. + + NiceGUI wraps each page in `q-layout → q-page-container → q-page → .nicegui-content`. Quasar + already sizes `q-page` to the viewport minus the fixed header (a JS-managed `min-height` that + tracks `window.innerHeight`, so it is `dvh`-equivalent and updates on resize/rotation), but + neither wrapper propagates that as a definite height, so a page's `grow` child only grows within + its content. Making both wrappers filling flex columns hands the whole header-offset + viewport + math to Quasar: a page that calls this and gives its root column `grow min-h-0` fills the leftover + height with no per-page pixel/rem constant. + + `ui.query()` targets only the current page's DOM (each client renders its own tree), so this is + opt-in per page and never affects other routes. + """ + ui.query('.q-page').classes('flex flex-col') # cooperate with Quasar's managed min-height + # `grow`/`min-h-0` fill the leftover height (flex `min-height: auto` would otherwise pin it to content). + # `w-full`/`min-w-0` are the width twins: as a flex item this box otherwise sizes to its *content* — a + # wide element (e.g. the DSP chart canvas) would push it (and the page) past the viewport, forcing a + # mobile zoom-out. `w-full` re-imposes the definite `width: 100%` it had as a block, clamped to `q-page`. + ui.query('.nicegui-content').classes('grow min-h-0 w-full min-w-0') diff --git a/audera/ui/streamer/pages/dsp.py b/audera/ui/streamer/pages/dsp.py index d1f5a05..f541ff3 100644 --- a/audera/ui/streamer/pages/dsp.py +++ b/audera/ui/streamer/pages/dsp.py @@ -44,6 +44,7 @@ def render(page: 'Page', player_id: str) -> None: (add/delete/type/preset/reset) refresh the band table. """ components.header.render(audera.NAME, 'Streamer') + components.layout.fill_viewport() # page fills the viewport; the editor's `grow` column owns the scroll region snap = _snapserver(page.settings) try: @@ -121,7 +122,10 @@ def _add_band() -> None: # A default Peaking band can't be edited from a compact row alone, so the # compact modes auto-reveal its editor (accordion for expand, modal for dialog). band = Band(id=uuid.uuid4().hex, type='Peaking', freq=1000.0, gain=0.0, q=0.707) - state['staged'].bands.append(band) + # Prepend, not append: biquads cascade so their order doesn't change the response, and a + # new band at the top is always in view and ready to edit — appended it would land below + # the fold of the scroll area once several bands exist. + state['staged'].bands.insert(0, band) if dsp_band_editor == features.FF_DSP_BAND_EDITOR_EXPAND: expanded['id'] = band.id _band_table.refresh() @@ -490,19 +494,22 @@ def _band_table() -> None: with ui.card().classes('w-full p-3 gap-2'): _compact_row(band, on_edit=_open_band_dialog) - with ui.row().classes('items-center justify-between w-full'): - with ui.row().classes('items-center gap-2 text-sm'): - ui.link('Players', '/').classes('text-gray-500 no-underline hover:text-gray-700') - ui.label('›').classes('text-gray-400') - ui.label(live.name).classes('text-gray-500') - ui.label('›').classes('text-gray-400') - ui.label('DSP').classes('text-gray-800 font-medium') # the current segment leads - ui.button(icon='arrow_back', on_click=lambda: ui.navigate.to('/')).props('flat dense round size=sm').mark('dsp-back') - - # Viewport-bounded flex column: everything above the band table is pinned in the fixed - # region, and only the `grow` scroll area beneath it consumes the remaining height and - # scrolls (height tuned so the editor seats within the viewport under the breadcrumb). - with ui.column().classes('w-full gap-3 flex flex-col').style('height: calc(100vh - 9rem)'): + # One flex column owns the whole editor: the breadcrumb and everything above the band table keep + # their natural height, and the `grow` scroll area beneath them flexes to fill the rest — so the + # band list always sizes to the screen with no per-element height math. `components.layout. + # fill_viewport()` (called at the top of `render`) bounds this column to the viewport minus the + # fixed header, so `grow min-h-0` here just claims that leftover height — no `dvh`/`6rem` constant. + with ui.column().classes('w-full gap-3 flex flex-col grow min-h-0'): + # Breadcrumb leads the column at its natural height; the current segment ("DSP") is bolded. + with ui.row().classes('items-center justify-between w-full'): + with ui.row().classes('items-center gap-2 text-sm'): + ui.link('Players', '/').classes('text-gray-500 no-underline hover:text-gray-700') + ui.label('›').classes('text-gray-400') + ui.label(live.name).classes('text-gray-500') + ui.label('›').classes('text-gray-400') + ui.label('DSP').classes('text-gray-800 font-medium') # the current segment leads + ui.button(icon='arrow_back', on_click=lambda: ui.navigate.to('/')).props('flat dense round size=sm').mark('dsp-back') + # --- Fixed region: presets, pre-amp, chart, info line, headers all stay put --- # Presets and Config are the two "sources" that build a pipeline, so they lead on their # own row; the pre-amp and the Reset/Save actions sit in line beneath them. @@ -540,15 +547,17 @@ def _band_table() -> None: ui.label('Info').classes('text-sm font-semibold text-gray-800') ui.html('Load a preset or click + ADD BAND to build a DSP pipeline.').classes('text-sm text-gray-700') - # Band info + Add band sit above the scrolling table so they stay fixed with the chart. - with ui.row().classes('items-center gap-4 w-full text-xs text-gray-500'): - count_label = ui.label() - ui.label('IIR biquads · ~0% CPU') - dirty_label = ui.label('Unsaved changes ●').classes('text-amber-500') - clip_label = ui.label('').classes('text-red-500') - clip_label.set_visibility(False) # hidden until the clip poll reports a nonzero count - - ui.button('+ Add band', on_click=_add_band).props('flat dense') + # Band info (left) and Add band (right) share one fixed row above the scrolling table: + # right-aligning the button favours one-handed phone use, and merging the two rows leaves + # more height for the band list below. + with ui.row(wrap=False).classes('items-center justify-between w-full gap-4'): + with ui.row().classes('items-center gap-4 text-xs text-gray-500 min-w-0'): + count_label = ui.label() + ui.label('IIR biquads · ~0% CPU') + dirty_label = ui.label('Unsaved changes ●').classes('text-amber-500') + clip_label = ui.label('').classes('text-red-500') + clip_label.set_visibility(False) # hidden until the clip poll reports a nonzero count + ui.button('+ Add band', on_click=_add_band).props('flat dense') # Full mode's column headers stay pinned above the scrolling rows; their visibility # tracks `has_bands` in `_mark_changed` (mode is fixed per page, so only that can change). @@ -566,8 +575,12 @@ def _band_table() -> None: # `grow min-h-0` lets this box shrink below its content and claim the column's leftover # height; `relative` + the scroll area's `absolute inset-0` give QScrollArea a concrete # box to fill (it collapses to nothing at short viewports when sized by flex alone). + # `height: auto` is required: NiceGUI's `.nicegui-scroll-area` hard-codes `height: 16rem`, + # which — being a real `height` — otherwise wins over `inset-0`'s `bottom: 0` and pins the + # scroll area to 256px, leaving the rest of this box empty. Clearing it lets `inset-0` + # stretch the scroll area to fill the wrapper. with ui.element('div').classes('w-full grow min-h-0 relative'): - with ui.scroll_area().classes('absolute inset-0'): + with ui.scroll_area().classes('absolute inset-0').style('height: auto'): _band_table() _mark_changed()