Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ee0694d
feat: reshape DSP to parametric bands; daemon-owned volume (#48)
thomaseleff Jul 8, 2026
1836613
feat: add DSP domain package — compiler, headroom, presets (#48)
thomaseleff Jul 8, 2026
75b7a80
feat: add CamillaDSP validate_config + clipped-sample counters (#48)
thomaseleff Jul 8, 2026
de66afa
feat: add Advanced DSP EQ editor page + Save orchestration (#48)
thomaseleff Jul 8, 2026
15e60ef
fix: drop redundant --gain seed that crashed camilladsp on boot (#48)
thomaseleff Jul 8, 2026
1822a90
feat: add live frequency-response chart + auto-protected pre-amp (#50)
thomaseleff Jul 8, 2026
3634e8d
feat: add REW paste import + export via Config ▾ menu (#50)
thomaseleff Jul 9, 2026
e55613f
feat: add named user presets, save-as + append via Presets ▾ menu (#50)
thomaseleff Jul 9, 2026
a241cc9
refactor: key DSP config by Snapcast id, drop dsp_id FK, retire playe…
thomaseleff Jul 9, 2026
cca23e3
refactor: retire dead groups/streams DALs, re-home model round-trips …
thomaseleff Jul 9, 2026
b7f45a7
refactor: address PR #49 review — one taxonomy, YAML REW, pages/ spli…
thomaseleff Jul 9, 2026
2787317
docs: trim module docstrings to one-liners, fix "Auder" typo
thomaseleff Jul 10, 2026
7aa302f
feat: add cached env-settings singleton (audera/settings.py)
thomaseleff Jul 10, 2026
08667d2
refactor: polish DSP editor + player-card UI, sanitize config filenames
thomaseleff Jul 10, 2026
a075057
feat: user-selectable DSP band-editor UX (full / expanded / dialog)
thomaseleff Jul 23, 2026
16e0228
fix: derive DSP pre-amp from bands, normalize negative-zero ceiling
thomaseleff Jul 24, 2026
c1a208f
fix: address PR #49 final review — chart axes, sticky layout, preset …
thomaseleff Jul 24, 2026
730832d
refactor: extract reusable fill_viewport layout primitive for DSP editor
thomaseleff Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
11 changes: 4 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 7 additions & 5 deletions audera/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from audera import dal, models
from audera.services import ap, logging, netifaces, platform
from audera.settings import settings

__all__ = [
'platform',
Expand All @@ -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
39 changes: 39 additions & 0 deletions audera/clients/camilladsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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')
Expand Down
4 changes: 2 additions & 2 deletions audera/dal/__init__.py
Original file line number Diff line number Diff line change
@@ -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']
8 changes: 4 additions & 4 deletions audera/dal/dsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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'])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)))
176 changes: 0 additions & 176 deletions audera/dal/groups.py

This file was deleted.

24 changes: 24 additions & 0 deletions audera/dal/path.py
Original file line number Diff line number Diff line change
@@ -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])
Loading