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 dc78bd5..7f0a39c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,17 +40,14 @@ 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` — 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. +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`. -- `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` 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/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/clients/camilladsp.py b/audera/clients/camilladsp.py index e6c8ade..79badbf 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: @@ -29,6 +32,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. @@ -72,6 +76,41 @@ 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. + """ + 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/audera/dal/__init__.py b/audera/dal/__init__.py index 277d12d..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, players, settings, streams +from audera.dal import dsp, presets, settings -__all__ = ['players', 'groups', 'streams', 'dsp', 'settings'] +__all__ = ['dsp', 'presets', 'settings'] 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/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/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/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/dal/presets.py b/audera/dal/presets.py new file mode 100644 index 0000000..8f513ae --- /dev/null +++ b/audera/dal/presets.py @@ -0,0 +1,60 @@ +"""Preset DSP configuration-layer""" + +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, path.to_filename(preset.id)) + 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, path.to_filename(id)) + if os.path.isfile(file_path): + os.remove(file_path) 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/audera/domains/__init__.py b/audera/domains/__init__.py new file mode 100644 index 0000000..dd9f8ee --- /dev/null +++ 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 new file mode 100644 index 0000000..08892bc --- /dev/null +++ b/audera/domains/dsp/__init__.py @@ -0,0 +1,22 @@ +"""DSP domain layer + +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 +from audera.domains.dsp.headroom import auto_preamp_db, response_curve, response_peak_db +from audera.domains.dsp.presets import clone_bands, loudness_preset +from audera.domains.dsp.rew import format_rew, parse_rew + +__all__ = [ + 'compile_pipeline', + 'auto_preamp_db', + 'response_curve', + 'response_peak_db', + 'clone_bands', + 'loudness_preset', + 'format_rew', + 'parse_rew', +] diff --git a/audera/domains/dsp/compiler.py b/audera/domains/dsp/compiler.py new file mode 100644 index 0000000..005f7c6 --- /dev/null +++ b/audera/domains/dsp/compiler.py @@ -0,0 +1,77 @@ +"""DSP compiler""" + +import copy + +from audera.models.dsp import PASS_TYPES, Band, DSPConfig + +_MANAGED_PREFIX = 'audera_' +_PREAMP_KEY = 'audera_preamp' +_PEQ_PREFIX = 'audera_peq_' + + +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': band.type, + 'freq': band.freq, + 'q': band.q, + } + if band.type not in PASS_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..9778d5a --- /dev/null +++ b/audera/domains/dsp/headroom.py @@ -0,0 +1,114 @@ +"""DSP headroom""" + +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 Band, DSPConfig + +_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]: + """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. + + 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). + """ + 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) + 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/domains/dsp/presets.py b/audera/domains/dsp/presets.py new file mode 100644 index 0000000..13c8eeb --- /dev/null +++ b/audera/domains/dsp/presets.py @@ -0,0 +1,31 @@ +"""System DSP presets""" + +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), + ] + + +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/domains/dsp/rew.py b/audera/domains/dsp/rew.py new file mode 100644 index 0000000..86eaf88 --- /dev/null +++ b/audera/domains/dsp/rew.py @@ -0,0 +1,153 @@ +"""Import/export parametric-EQ bands as CamillaDSP YAML + +Compatible with REW (v5.20.14+). +""" + +import uuid +from typing import Any, Optional, get_args + +import yaml +from pydantic import BaseModel, Field + +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 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 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 CamillaDSP YAML (or JSON) export. + + `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 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 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 CamillaDSP YAML fragment for a set of bands, importable by REW. + + 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. 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. + """ + 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 db3fc33..04884ab 100644 --- a/audera/models/dsp.py +++ b/audera/models/dsp.py @@ -3,43 +3,89 @@ 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 +# 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 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, cased to match CamillaDSP's own parameter names. + 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 = DEFAULT_Q 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 + ---------- + 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]` + The parametric-EQ bands (source of truth). + enabled: `bool` + Whether the DSP configuration is active. + """ + + player_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': @@ -49,13 +95,10 @@ 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, - '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: @@ -63,51 +106,25 @@ def __repr__(self) -> 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. +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`) — a brand-new type with no hand-written 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. """ - 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 + + id: str + name: str + bands: list[Band] = Field(default_factory=list) diff --git a/audera/models/player.py b/audera/models/player.py index f19d50c..24f8332 100644 --- a/audera/models/player.py +++ b/audera/models/player.py @@ -46,22 +46,6 @@ 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, - } - - 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): 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/AGENTS.md b/audera/ui/AGENTS.md index dfa0052..41fcbf7 100644 --- a/audera/ui/AGENTS.md +++ b/audera/ui/AGENTS.md @@ -14,6 +14,11 @@ 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: @@ -56,3 +61,32 @@ 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 `_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 `_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/__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/__init__.py b/audera/ui/components/__init__.py index 57f4f18..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, theme +from audera.ui.components import header, layout, response_plot, theme -__all__ = ['header', '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/components/response_plot.py b/audera/ui/components/response_plot.py new file mode 100644 index 0000000..6af0952 --- /dev/null +++ b/audera/ui/components/response_plot.py @@ -0,0 +1,95 @@ +"""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. + + Parameters + ---------- + config: `audera.models.dsp.DSPConfig` + 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': 24}, + 'tooltip': {'trigger': 'axis'}, + 'xAxis': { + 'type': 'log', + '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': { + 'showMinLabel': True, + 'showMaxLabel': True, + ':formatter': "v => v <= 20 ? '20' : v >= 20000 ? '20k' : ''", + }, + # 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': 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'}}, + }, + '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-40') 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/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 b29953a..fc3a1cc 100644 --- a/audera/ui/streamer/__init__.py +++ b/audera/ui/streamer/__init__.py @@ -1,20 +1,21 @@ -"""Audera streamer web UI""" +"""Audera app""" from nicegui import app, ui import audera +from audera.settings import settings from audera.ui import components from audera.ui.streamer.pages import Page def run() -> None: - """Runs the streamer dashboard.""" + """Runs the Audera app.""" page = Page() page.load() 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.py b/audera/ui/streamer/pages.py deleted file mode 100644 index 930b757..0000000 --- a/audera/ui/streamer/pages.py +++ /dev/null @@ -1,513 +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 - -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 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 - -load_dotenv() - -_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) - - 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) - - @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) - - if minimized: - continue - - 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) - except Exception: - pass - slider = self._build_volume_controls(client.id, dsp_config, 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 - 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') - - 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-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}') - 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') - - 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() - - 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, - dsp_config: DSPConfig, - initial_volume: float, - client_host: str = '', - ) -> ui.slider: - """Renders a volume icon, slider, and live value label (routed through CamillaDSP). - - 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 - 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: - dsp_dal.update(dsp_config.model_copy(update={'volume': percent})) - 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..275cac2 --- /dev/null +++ b/audera/ui/streamer/pages/__init__.py @@ -0,0 +1,43 @@ +"""Audera app pages""" + +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..3061dd3 --- /dev/null +++ b/audera/ui/streamer/pages/_clients.py @@ -0,0 +1,37 @@ +"""Client factories and settings loader shared across the streamer pages.""" + +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: + cfg = settings_dal.get_or_create( + Settings( + 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) + + +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..f541ff3 --- /dev/null +++ b/audera/ui/streamer/pages/dsp.py @@ -0,0 +1,587 @@ +"""Remote audio device parametric-EQ editor page""" + +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, features +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 _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. + + 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, derived pre-amp, and live response chart; structural changes + (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: + 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)) + # 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). + expanded = {'id': None} + + def _dirty() -> bool: + return state['staged'] != state['saved'] + + def _mark_changed() -> None: + """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) + 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. + 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_enabled(band: Band, value: bool) -> None: + band.enabled = bool(value) + _mark_changed() + + 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 + 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: + 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: + # 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) + # 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() + _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() + + 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 _save_preset(preset: Preset, notice: str) -> None: + presets_dal.save_preset(preset) + _presets_menu.refresh() + 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( + 'preset-save-run' + ) + + dialog.open() + + @ui.refreshable + def _presets_menu() -> None: + 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() + 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('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. + + 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) + + 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: + # 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') + _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) + + # 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. + 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', value=state['staged'].preamp_db, format='%.1f') + .props('dense outlined readonly') + .classes('w-64') + ) + 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 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']) + 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 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). + 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). + # `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').style('height: auto'): + _band_table() + + _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..f5a8787 --- /dev/null +++ b/audera/ui/streamer/pages/index.py @@ -0,0 +1,383 @@ +"""Audera app index""" + +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) + ) + # 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=sym_o_airwave flat dense round size=sm') + .mark('player-dsp') + ) + # 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 + + 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') + .props('hint="Adds playback delay."') + ) + + # 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 025ca99..4dcf265 100644 --- a/os/dietpi/lib/common.sh +++ b/os/dietpi/lib/common.sh @@ -50,7 +50,7 @@ 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. write_camilladsp_service() { local config_path="$1" local statefile_path="$2" diff --git a/pyproject.toml b/pyproject.toml index 24d9e11..c41b34a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,11 +18,14 @@ 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", "httpx>=0.28.1", "websockets>=16.0", + "pyyaml>=6.0", + "camilladsp-plot @ git+https://github.com/HEnquist/pycamilladsp-plot@v3.0.2", ] [project.optional-dependencies] @@ -43,6 +46,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/clients/test_camilladsp.py b/tests/clients/test_camilladsp.py index c72fe57..9adfdc3 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) @@ -63,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 diff --git a/tests/conftest.py b/tests/conftest.py index 6357514..d70ef91 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,10 +43,8 @@ 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'), + ('audera.dal.presets', 'dsp/presets'), ('audera.dal.settings', 'settings'), ]: dest = str(tmp_path / subdir) diff --git a/tests/dal/test_dsp.py b/tests/dal/test_dsp.py index 77fcaab..eaac7fa 100644 --- a/tests/dal/test_dsp.py +++ b/tests/dal/test_dsp.py @@ -1,33 +1,14 @@ +import os + 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: +from audera.models.dsp import Band, DSPConfig + + +def _make_dsp(player_id='abc123') -> DSPConfig: return DSPConfig( - id='dsp-1', player_id=player_id, - pipeline={'filters': {}, 'mixers': {}, 'pipeline': []}, + preamp_db=-6.0, + bands=[Band(id='b1', type='Lowshelf', freq=90.0, gain=10.0)], enabled=True, ) @@ -50,17 +31,11 @@ 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) assert result.enabled is False - assert 'lp' in result.pipeline.get('filters', {}) def test_dsp_delete(audera_home): @@ -70,17 +45,38 @@ def test_dsp_delete(audera_home): assert not dsp_dal.exists(config.player_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, + 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='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 + assert result == config + 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): @@ -91,6 +87,16 @@ def test_dsp_get_or_create_creates(audera_home): 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): config = _make_dsp() dsp_dal.create(config) @@ -99,27 +105,11 @@ 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_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 +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 + edited = _make_dsp(player_id='abc123') + dsp_dal.update(edited) -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 + # A second open re-reads the edited config — it never re-mints an empty one. + assert dsp_dal.get_or_create(DSPConfig(player_id='abc123')) == edited 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_players.py b/tests/dal/test_players.py deleted file mode 100644 index ffc3fe0..0000000 --- a/tests/dal/test_players.py +++ /dev/null @@ -1,97 +0,0 @@ -import audera.dal.players as players -from audera.models.player import Player - - -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' diff --git a/tests/dal/test_presets.py b/tests/dal/test_presets.py new file mode 100644 index 0000000..15b1136 --- /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(player_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/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/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..ff0b72a --- /dev/null +++ b/tests/domains/dsp/test_compiler.py @@ -0,0 +1,132 @@ +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( + 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), + ], + ) + 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( + 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), + ], + ) + 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_type_passes_through_to_camilladsp(): + # `Band.type` already carries CamillaDSP's own casing, so the compiler emits it verbatim. + config = DSPConfig( + 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), + ], + ) + 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( + 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), + ], + ) + 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( + 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), + 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(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'] + assert {'type': 'Filter', 'channels': [0, 1], 'names': ['user_filter']} in compiled['pipeline'] + + +def test_does_not_mutate_caller_config(): + base = _empty_config() + 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( + player_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(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 new file mode 100644 index 0000000..cc9230e --- /dev/null +++ b/tests/domains/dsp/test_headroom.py @@ -0,0 +1,111 @@ +import math + +import pytest +from camilladsp_plot import eval_filter +from camilladsp_plot.eval_filterconfig import logspace + +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 + + +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(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(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(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(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( + player_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(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(player_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(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 + 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(player_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(player_id='x', preamp_db=ceiling, bands=[band]) + 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]) + with_margin = auto_preamp_db([band], margin_db=3.0) + assert with_margin == pytest.approx(no_margin - 3.0) diff --git a/tests/domains/dsp/test_presets.py b/tests/domains/dsp/test_presets.py new file mode 100644 index 0000000..f509e01 --- /dev/null +++ b/tests/domains/dsp/test_presets.py @@ -0,0 +1,63 @@ +from audera.domains.dsp import clone_bands, loudness_preset, response_peak_db +from audera.models.dsp import Band, 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(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='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/domains/dsp/test_rew.py b/tests/domains/dsp/test_rew.py new file mode 100644 index 0000000..6b2a2f0 --- /dev/null +++ b/tests/domains/dsp/test_rew.py @@ -0,0 +1,184 @@ +import json + +import pytest +import yaml + +from audera.domains.dsp import format_rew, parse_rew +from audera.models.dsp import DEFAULT_Q, Band, DSPConfig + + +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(): + (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('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_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('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 result.skipped == ['weird'] + + +@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 result.skipped == ['foreign'] + + +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 == ['f1'] + + +@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_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 == ['this is not a camilladsp document'] + + +def test_parse_yaml_syntax_error_is_skipped_raw(): + result = parse_rew('filters: [unclosed') + assert result.bands == [] + assert result.skipped == ['filters: [unclosed'] + + +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(): + 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_filters_and_pipeline(): + text = format_rew(-6.0, [Band(id='b1', type='Peaking', freq=1000.0, gain=3.0, q=1.0)]) + 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(): + 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_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: + 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( + 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='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)) diff --git a/tests/models/test_dsp.py b/tests/models/test_dsp.py index 38bc324..a6cc562 100644 --- a/tests/models/test_dsp.py +++ b/tests/models/test_dsp.py @@ -1,130 +1,102 @@ -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, Preset + + +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_apply_loudness_inserts_filter(applied_pipeline): - assert _LOUDNESS_FILTER_KEY in applied_pipeline['filters'] - - -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_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_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_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_dsp_config_defaults(): + 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(player_id='x') + assert set(config.to_dict().keys()) == {'player_id', 'preamp_db', 'bands', 'enabled'} + + +def test_dsp_config_bands_round_trip(): + config = DSPConfig( + 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), + ], + ) + 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_dsp_config_legacy_dict_drops_retired_keys(): + legacy = { + 'player_id': 'player-1', + 'id': 'dsp-1', + 'dsp_id': 'dsp-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()) == {'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_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(player_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/models/test_player.py b/tests/models/test_player.py new file mode 100644 index 0000000..0d89bdd --- /dev/null +++ b/tests/models/test_player.py @@ -0,0 +1,33 @@ +from audera.models.player import Group, Player + + +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') + + +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 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 37d8dda..3b8e4c4 100644 --- a/tests/ui/test_streamer.py +++ b/tests/ui/test_streamer.py @@ -7,14 +7,17 @@ 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 from audera.dal import settings as settings_dal +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 from audera.ui.streamer.pages import Page +from audera.ui.streamer.pages.dsp import _band_summary @pytest.fixture @@ -43,14 +46,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 @@ -62,17 +70,38 @@ def _set_volume(self, level: float) -> None: @pytest.fixture -def mock_camilladsp_config(monkeypatch): +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 _get_config(self): - return {'filters': {}, 'pipeline': []} + def _validate_config(self, config: dict) -> None: + calls['validate_config'] = config - def _set_config(self, 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 @@ -87,8 +116,20 @@ 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') + monkeypatch.setattr(streamer_plex, '_plexamp_state', lambda: 'inactive') Page().load() await user.open('/') await user.should_see('Players') @@ -183,7 +224,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() @@ -191,9 +232,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') @@ -201,7 +242,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() @@ -254,7 +295,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() @@ -264,18 +305,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 +325,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 +349,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 +364,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 +376,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 +411,400 @@ 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)') + await user.should_see('Snapcast Volume') + await user.should_not_see('Loudness') + await user.should_not_see('Reference level (dB)') -async def test_loudness_toggle_enables_and_persists( - audera_home, - mock_snapserver_with_client, - mock_camilladsp_config, - user: User, -): +# --- Advanced DSP editor ----------------------------------------------------------------- + + +async def test_players_tab_shows_dsp_icon(audera_home, mock_snapserver_with_client, mock_camilladsp, 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 + await user.should_see(marker='player-dsp') -async def test_loudness_toggle_passes_reference_level_to_config( - audera_home, - mock_snapserver_with_client, - mock_camilladsp_config, - user: User, +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('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') + 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) + + +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 `get_or_create` needs to open it instead of a fresh empty one. + """ + dsp_dal.save(config.model_copy(update={'player_id': 'abc123'})) + + +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('/') - user.find(marker='player-settings').click() - user.find(kind=ui.switch).click() + await user.open('/player/abc123/dsp') + await user.should_see('Load a preset') # the empty-state tip + 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_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(marker='dsp-band-delete').click() await asyncio.sleep(0.1) - assert mock_camilladsp_config.get('set_config') is None + 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): + # 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') + 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('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): + 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 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('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 keyed by the player id, carrying the two bands. + assert dsp_dal.exists('abc123') + assert len(dsp_dal.get('abc123').bands) == 2 + + +# --- CamillaDSP YAML import/export via Config ▾ ------------------------------------------ + + +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() + 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 = 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_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() + 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 = 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 filter(s)') + + +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('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(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('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(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 + await user.should_see('Bands (2)') + user.find(marker='config-export').click() + await user.should_see(marker='export-unsaved-banner') + + +# --- Named user presets ------------------------------------------------------------------ + + +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 + + +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) ----------------------------------------- + + +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 diff --git a/uv.lock b/uv.lock index b55a6dd..5341eb0 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-03T18:29:39.8456585Z" exclude-newer-span = "P1W" [[package]] @@ -114,12 +114,15 @@ name = "audera" version = "0.1.0b1" source = { editable = "." } dependencies = [ + { name = "camilladsp-plot" }, { name = "duckdb" }, { name = "httpx" }, { name = "netifaces" }, { name = "nicegui" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "websockets" }, ] @@ -136,15 +139,18 @@ 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" }, { 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" }, + { 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" }, @@ -162,6 +168,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 +463,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" @@ -771,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" @@ -897,6 +953,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 +993,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"