Skip to content

feat: Advanced DSP configuration (#48)#49

Open
thomaseleff wants to merge 18 commits into
v0.1.0-beta.1from
v0.1.0-beta.1-48
Open

feat: Advanced DSP configuration (#48)#49
thomaseleff wants to merge 18 commits into
v0.1.0-beta.1from
v0.1.0-beta.1-48

Conversation

@thomaseleff

@thomaseleff thomaseleff commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Implements #48 — reshapes DSP from an app-owned, hand-mutated CamillaDSP pipeline into a parametric-EQ editor where bands are the source of truth, compiled to CamillaDSP biquads on Save. Loudness becomes a static preset; volume ownership moves to the CamillaDSP daemon (persisted via --statefile).

The branch lands the v1 reshape (#48) and then the v2 feature plan (#50) that promotes four features onto the shipped editor. Full design lives in plans/advanced-dsp-configuration.md (v1) and -v2.md (v2).

What ships

Editor — a full-page parametric-EQ editor at /player/{id}/dsp, reached from an EQ icon on each player card. A pre-amp field, a band table (On / Type / Freq / Gain / Q), and an actions row: Presets ▾ · Config ▾ · Reset · Save. Per-page state is staged in closures (never on self, which is shared across clients); Save compiles → validates → pushes → persists.

Domain layer (audera/domains/dsp/, imports only models, zero I/O) —

  • compiler — compiles preamp_db + bands into a CamillaDSP config, idempotent by construction (strips managed audera_-prefixed filters/steps, re-adds one per band). Foreign filters/steps survive untouched.
  • headroom — the true combined magnitude peak/curve via camilladsp_plot, reusing the compiler's shaper so eval and compile can't drift. Drives the live response chart and the auto-protected pre-amp ceiling.
  • presetsloudness_preset (static shelf bands), clone_bands (deep copy + fresh ids).
  • rew — REW ↔ CamillaDSP YAML import/export (the format REW v5.20.14+ natively round-trips).

ClientCamillaDSPClient gains validate_config (Save-time gate), clipped-sample read/reset.

Feature checklist

v1 (#48): models/DAL reshape · DSP domain package · CamillaDSP client · EQ editor page · Save + headroom safety

v2 (#50): live response chart + auto-protected pre-amp · REW paste import + export (Config ▾) · named user presets · player-ownership refactor (config keyed by Snapcast id, dsp_id FK + dal/players.py retired) · retire dead groups/streams DALs (surviving DALs are all Audera-owned, plain-json)

Review fixes (this review round)

Addressed all 12 inline comments: reverted the dsp DAL to key on player_id; adopted CamillaDSP casing on Band.type and deleted _TYPE_MAP; centralized DEFAULT_Q / PASS_TYPES onto one source of truth; rewrote REW interop to structured CamillaDSP YAML (importable back into REW) in place of the regex parser; stripped plan artifacts (WS-* refs, unshipped backwards-compat code, why-not comments); and split the 850-line streamer/pages.py into a pages/ sub-package.

Local-dev environment settings

Adds a cached pydantic-settings singleton (audera/settings.py) that loads all env-driven deployment config once at startup, with backwards-compatible defaults (today's constants). audera/__init__.py re-exports the port values from it, so every audera.*_PORT call site is unchanged but now overridable via AUDERA_* env vars (and .env):

  • Hosts (AUDERA_SNAPSERVER_HOST / AUDERA_PLEXAMP_HOST) and ports (AUDERA_SERVER_PORT, AUDERA_SNAPSERVER_PORT, AUDERA_CAMILLADSP_PORT, AUDERA_PLEXAMP_PORT) now drive the streamer/setup run() binds and client factories. Folds the scattered os.getenv reads, the SERVER_PORT = 80 constant, and the stray port=80 setup literal into one source of truth.
  • _load_settings lets an explicitly-set env host override a stale persisted ~/.audera/settings.json (detected via model_fields_set); unset leaves the file authoritative, so behavior is unchanged when no var is set.
  • Unblocks driving the Windows UI against a real node — e.g. AUDERA_SNAPSERVER_HOST=192.168.1.36 AUDERA_PLEXAMP_HOST=192.168.1.36 AUDERA_SERVER_PORT=8080 audera streamer start (port 8080 sidesteps the port-80 elevation cost). Knobs documented in .env.example.

UI polish + cross-platform filenames

A round of UI refinement on the shipped editor and player cards, plus the filename fix that unblocked driving the real UI on Windows:

  • DSP editor — breadcrumb header (Players › {name} › DSP) + back button replacing the plain title/link; Presets ▾ / Config ▾ promoted to their own row as dropdown buttons with tightened labels (Loudness, Clear, New preset, Import, Export); the auto-protected pre-amp field widened so its label no longer truncates (wrap=False keeps Reset/Save inline); the band-table column header renders only once a band exists; and the empty-state hint restyled as a Material for MkDocs "info" admonition (blue, info icon, bold + ADD BAND).
  • Player cards — the DSP button now uses sym_o_airwave (sound waves) and leads the settings gear; the bordered circle is dropped (it rendered a sub-pixel oval at some widths); the settings dialog's latency field gains a hint.
  • Response chart — compact styling: tighter grid margins, endpoint-only axis labels (20 Hz / 20k, ±18 dB), shorter height.
  • Cross-platform filenamesdal/path.to_filename() sanitizes reserved characters so a MAC-address player id (whose colons are illegal in a Windows filename) maps to a valid dsp/{id}.json; the dsp/presets DALs route through it. Covered by a MAC-address round-trip test.
  • Docs — the screenshot-loop UI-preview workflow is documented in audera/ui/AGENTS.md.

Testing

ruff / ruff format / ty clean; the daemon-free suite (tests/{models,dal,domains,ui}) is green, plus Docker-gated tests/clients against the real CamillaDSP v3 container. REW round-trip is property-tested (parse_rew(format_rew(...)) ≈ bands).

🤖 Generated with Claude Code

Workstream 1 of #48 (Advanced DSP Configuration): make EQ bands the
source of truth and move volume ownership to the CamillaDSP daemon.

- models/dsp: add Band; reshape DSPConfig to {id, preamp_db, bands,
  enabled}; delete the dynamic-loudness machinery
- models/player: add dsp_id FK with None->'' coercion for the mixed
  old/new read_json_auto window
- dal/dsp: re-key by config id; add resolve_for_player
  (get-or-mint-and-link)
- clients/camilladsp: add DEFAULT_PERCENT_VOLUME = 25
- ui/streamer: de-wire loudness; seed volume from the daemon, drop the
  app-side replica and push-on-render
- os/dietpi: seed a fresh statefile at --gain -12.04 (= percent_to_db(25))

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@thomaseleff
thomaseleff changed the base branch from main to v0.1.0-beta.1 July 8, 2026 01:41
thomaseleff and others added 3 commits July 7, 2026 21:00
WS-2 lands the pure computation layer behind its own import seam
(`audera/domains/dsp/`), importing only `models` so it is unit-testable
with zero I/O (no disk, daemon, or Docker).

- compiler — `compile_pipeline(current_config, config)` compiles
  `preamp_db + bands` into a CamillaDSP config: deep-copies the base,
  strips managed `audera_`-prefixed filters/steps, re-adds one preamp
  Gain + one Biquad per band (own stereo `channels: [0, 1]` step,
  `bypassed = not band.enabled`). Foreign filters/devices survive;
  idempotent by construction.
- headroom — `response_peak_db(config)` sums each enabled band's
  `eval_filter` magnitude element-wise over the shared grid + scalar
  preamp, matching the daemon's math to drive the headroom guard.
- presets — `loudness_preset()` mints two editable shelf bands.

Adds `camilladsp-plot @ v3.0.2` as a core dep (numpy-free at runtime);
hatchling needs `allow-direct-references` for the git ref.

Tests: 19 pure, Docker-free tests in `tests/domains/dsp/`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WS-3: add three v3 websocket methods to CamillaDSPClient so Save can be
gated on validation and runtime clipping surfaced in the editor footer.

- validate_config(config) -> None: sends the config as a JSON-as-YAML
  string via the v3 ValidateConfig command (v4-only ValidateConfigJson
  avoided); raises RuntimeError on any non-Ok result, stricter than
  _call's literal-Error check since v3 reports failures as a message.
- get_clipped_samples() -> int: unwraps the {result, value} count,
  defaulting to 0 (mirrors get_volume's defensive default).
- reset_clipped_samples() -> None: zeroes the daemon's clip counter.

Docker-gated tests against the real v3.0.1 daemon: valid config
validates + round-trips with filters/steps intact; invalid config
raises (parameterized over missing-filter-name and invalid-biquad-type);
clipped-sample read/reset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WS-4 · EQ editor page at /player/{id}/dsp — pre-amp, band table
(On/Type/Freq/Gain/Q), Loudness/Flat presets, live headroom read-out,
Reset. Per-page staged state lives in closures; scalar edits mutate in
place while structural changes refresh the band table.

WS-5 · Save orchestration (Pattern B, live apply): get_config →
compile_pipeline → validate_config (gate) → set_config → persist via
dsp_dal.update → reset_clipped_samples. Adds a "protect headroom" guard
and a 3s clip poll. Recovers the persisted dsp_id before resolving so
opening the page never mints an orphan config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
thomaseleff and others added 6 commits July 8, 2026 09:21
The camilladsp systemd unit passed `--gain -12.04` to seed a cold-boot node
at 25%. clap parses the leading `-` of the space-separated `-12.04` as short
flags, so camilladsp exited (`unexpected argument '-1'`) before binding the
websocket port; `Restart=always` just crash-looped it. The DSP editor's Save
then hit `ws://<host>:1234` with nothing listening and failed with
`[Errno 111] Connection refused`.

The daemon already persists volume via `--statefile`, so the `--gain` seed
was redundant. Remove it: camilladsp now starts, binds 1234, and self-creates
the statefile (fader restored across restarts). Verified against the real
CamillaDSP v3.0.1 container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WS-6 of the advanced DSP v2 plan. Split the response curve from the peak in
the headroom domain, add an auto-protecting pre-amp ceiling, and surface the
combined EQ curve as a live ECharts line in the editor.

- domains/dsp/headroom: factor `_summed_magnitude`; add `response_curve`
  (npoints=400) and `auto_preamp_db(bands, ...)`; keep `response_peak_db`
  numerically identical (delegates at npoints=1000)
- ui/components/response_plot: new module — log x-axis 20 Hz-20 kHz, fixed
  -18..+18 dB box, single clipped accent line
- ui/streamer/pages: draw the chart above the band table with a band-less
  info empty-state; clamp pre-amp to the clip-safe ceiling on load and in
  `_mark_changed`; drop the manual protect button, `_on_protect`, and the
  clip-warning branch; debounce band number inputs
- tests: extend headroom + streamer suites for the curve, auto ceiling, and
  chart/empty-state visibility

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Import and export are inverses over bands, so they ship together. A new
pure-domain rew.py adds parse_rew/format_rew (mapped to a RewImport model)
that round-trip modulo fresh ids and float-format precision. The editor gains
a Config ▾ menu beside Presets: Import REW… appends parsed bands and
re-clamps the auto pre-amp; Export… surfaces the saved config as re-importable
REW text with Copy/Download and an unsaved-changes banner. Pre-amp is
deliberately not round-tripped — the auto-ceiling owns it on import.

Note: ui.clipboard.write is synchronous in NiceGUI 3.11.1 (not async as the
plan assumed), so the Copy handler is a plain sync function.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WS-8 (v2 plan) lets a user save the current EQ's bands as a named,
reusable preset and append it to any player. Apply = append + clone
(never replace); cloned bands get fresh uuids so audera_peq_<id> filter
names can't collide within a config.

- models/dsp — new Preset BaseModel (id, name, bands); serializes via
  pydantic model_dump/model_validate (no hand-written to_dict).
- domains/dsp — pure clone_bands (deep copy + fresh uuids), reused by
  both apply and save; exported from domains/dsp/__init__.py.
- dal/presets — own namespace ~/.audera/dsp/presets/{id}.json, plain
  json + glob: get_all_presets (missing-dir → [], skip-malformed,
  name-sorted), save_preset (wrapped {'preset': ...}), delete_preset.
  Namespace-isolated from player configs; registered in dal/__init__.py.
- ui/streamer/pages — refreshable Presets ▾ menu with save-as dialog,
  append-on-click, and per-row delete (click.stop). Actions row stays
  at four buttons; DSPConfig schema and the dsp_id load path untouched.

Docs: AGENTS.md notes the dsp models are pydantic BaseModel; Preset
serializes via model_dump/model_validate. Tests: Preset round-trip,
clone_bands independence/unique-ids, DAL round-trip + name-sort +
skip-malformed + namespace isolation, and two streamer tests (seeded
preset appears + appends; save-current persists + notifies).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rs DAL (#50)

Player is now a live-only DTO. A player's DSPConfig is keyed by the player's
own Snapcast id (dsp/{player_id}.json) — the filename is the link — so the
dsp_id FK, the write-once-never-read player shadow file, the page-load recovery
hack, and the entire dal/players.py module (FK + three unused DuckDB queries)
are removed.

resolve_for_player reduces to a keyed get-or-create; the page resolves straight
from the live player id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…50)

Delete the unused groups/streams persistence (zero production callers) and
re-home the Group/Stream to_dict/from_dict round-trips under tests/models/,
preserving the only model coverage the DAL tests held.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@thomaseleff thomaseleff left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address the review comments.

Comment thread audera/dal/dsp.py Outdated
Comment thread audera/dal/dsp.py Outdated
Comment thread audera/dal/presets.py
Comment thread audera/domains/dsp/compiler.py Outdated
Comment thread audera/domains/dsp/compiler.py
Comment thread audera/domains/dsp/rew.py Outdated
Comment thread audera/domains/dsp/rew.py Outdated
Comment thread audera/ui/streamer/pages.py Outdated
Comment thread audera/ui/streamer/pages.py Outdated
Comment thread os/dietpi/lib/common.sh Outdated
thomaseleff and others added 4 commits July 9, 2026 14:56
#49)

Land all 12 inline review comments with a net-simpler codebase:

- DAL: revert `dsp` to key on `DSPConfig.player_id`, drop `resolve_for_player`
  and the artifact docstring note (#1, #2).
- Models: adopt CamillaDSP casing on `Band.type`
  (Peaking/Lowshelf/Highshelf/Lowpass/Highpass) and delete `compiler._TYPE_MAP` (#4).
- Constants: centralize `DEFAULT_Q` (with Butterworth rationale) and `PASS_TYPES`
  in `models/dsp.py`; remove the three duplicate copies (#7, #9).
- REW: rewrite `domains/dsp/rew.py` to speak CamillaDSP YAML — the only format
  REW v5.20.14+ round-trips — replacing the regex parser; declare `pyyaml` (#8).
- Cleanup: strip `WS-*` plan refs, the over-hot load clamp, "legacy" framing,
  and the dietpi why-not comment (#6, #10, #12).
- Docs: strengthen the presets plain-json rationale in the DAL + AGENTS.md (#3).
- Split `streamer/pages.py` into a `pages/` package (`__init__`, `index`, `dsp`,
  `_plex`, `_clients`); document the pattern in `ui/AGENTS.md` (#11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Condense the verbose module-level docstrings across the DSP domain, UI, and
streamer packages to concise summaries; fix "Auder app" -> "Audera app" in
`audera/ui/__init__.py`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Centralize env-driven deployment config in one pydantic-settings singleton
loaded once at startup, with backwards-compatible defaults (today's constants).
`audera.__init__` re-exports the port values from the singleton so every
`audera.*_PORT` call site is unchanged but now overridable via `AUDERA_*` env
vars (and `.env`) — e.g. `AUDERA_SERVER_PORT=8080` for local Windows dev.

- New `audera/settings.py`: `Settings(BaseSettings)` — hosts, service ports,
  and web-UI bind, all `AUDERA_`-prefixed.
- Migrate scattered `os.getenv` host reads and hardcoded ports: streamer/setup
  `run()` bind to `server_host`/`server_port`; `_clients` seeds hosts from the
  singleton and lets an explicitly-set env host override a stale persisted
  `settings.json` (detected via `model_fields_set`).
- Add `pydantic-settings>=2.0`; document the knobs in `.env.example`.
- Docs: trim retired Group/Stream refs from AGENTS.md; README wording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UI — DSP editor (streamer/pages/dsp.py):
- Breadcrumb header (Players › {name} › DSP) + back button, replacing the plain
  title/link
- Presets ▾ / Config ▾ promoted to their own row as dropdown buttons; menu items
  relabeled (Loudness, Clear, New preset, Import, Export)
- Widen the auto-protected pre-amp field so its label no longer truncates;
  wrap=False keeps Reset/Save inline at narrow widths
- Band-table column header renders only once a band exists
- Empty-state hint restyled as a Material for MkDocs "info" admonition (blue,
  info icon, bold + ADD BAND)

UI — player cards (streamer/pages/index.py):
- DSP button uses sym_o_airwave (sound waves) and leads the settings gear; drop
  the bordered circle (rendered a sub-pixel oval); add a latency hint

UI — response chart (components/response_plot.py):
- Compact styling: tighter grid margins, endpoint-only axis labels
  (20 Hz / 20k, ±18 dB), shorter height

Fix — cross-platform filenames (dal/path.py, dal/dsp.py, dal/presets.py):
- to_filename() sanitizes reserved characters so a MAC-address player id (colons)
  maps to a valid Windows filename; the dsp/presets DALs route through it

Docs/tests:
- Document the screenshot-loop workflow in audera/ui/AGENTS.md
- Cover the MAC-address round-trip; update streamer assertions for the new
  breadcrumb + info tip

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@thomaseleff thomaseleff left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address the review comments.

Comment thread audera/ui/components/response_plot.py Outdated
Comment thread audera/ui/streamer/pages/dsp.py
Comment thread audera/ui/streamer/pages/dsp.py Outdated
Comment thread audera/ui/components/response_plot.py
Comment thread audera/ui/streamer/pages/dsp.py Outdated
thomaseleff and others added 4 commits July 23, 2026 17:09
Turn the parametric-EQ band-row rendering into a runtime feature flag
(audera/ui/features.py DSP_BAND_EDITOR_KEY) with three options, defaulting
to `full`:

- full     — the existing inline On/Type/Freq/Gain/Q row with column headers
- expanded — compact one-line rows in per-band cards; ✏ grows the card to
             reveal labeled controls inline (one open at a time)
- dialog   — compact rows in per-band cards; ✏ raises a modal with the
             controls stacked vertically full-width for narrow phone screens

Refactor dsp.py around shared `_band_controls` (label/width switches) and
`_compact_row` builders plus a `_band_summary` helper; `_on_type` now takes
a refresh target so a type-change re-renders whichever tree owns the gain
field (table body vs. dialog body). Add per-mode page tests and value pins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The auto-protected pre-amp used a min() clamp that only ratcheted down, so
removing a boost band (or reducing gain / disabling a band / widening Q) left
the pre-amp stuck low. Make the pre-amp a pure function of the bands — a
read-only field recomputed on every change — and drop the speculative manual
sub-ceiling attenuation. Legacy pre-amp values are normalized on load so the
editor opens clean.

Also snap float-noise ceilings to positive 0.0: eval_filter leaves a ~1e-14 dB
residual on a flat/unity response (e.g. the default 0 dB Peaking band), which
negated to a tiny -0.0 and rendered as "-0.0" in the field.

Docs: fill in the ui/AGENTS.md "Running a local dev server" section, rename the
preview harness to _preview.py, and unwrap the hard-wrapped prose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…collision

Response chart (response_plot.py):
- y-axis max fixed at +5 dB display headroom (auto pre-amp keeps the curve
  ≤ 0 dB); min floors at -18 dB and auto-extends downward in 6 dB steps when a
  deep cut dips below, tracking only the visible [20, 20 kHz] window.
- x-axis: full vertical decade lines at 100/1k/10k, minor ticks at the primary
  sub-decade frequencies, and the axis line + 20/20k labels dropped to the
  grid bottom (onZero=False).

DSP editor layout (dsp.py):
- Sticky editor: the chart, info line, "+ Add band", and full-mode column
  headers are pinned; only the band rows scroll. Wrapped the scroll area in a
  relative grow box with an absolute-inset child so QScrollArea keeps a
  concrete height instead of collapsing at short viewports.

Preset save (dsp.py):
- Case-insensitive, trimmed name-collision check before saving. On a match a
  confirm dialog offers Replace (reuses the existing id, overwrites in place)
  or Cancel (leaves the save dialog open to rename).

Tests: preset-collision Replace/Cancel flows + a response_plot axis unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the DSP editor's hand-tuned `height: calc(100dvh - 6rem)` root-column
constant with a reusable, page-scoped primitive (audera/ui/components/layout.py).
`fill_viewport()` turns `.q-page`/`.nicegui-content` into a filling flex chain so
the editor's `grow min-h-0` column inherits Quasar's header-aware, dvh-equivalent
managed height — no per-page pixel/rem constant, and it re-derives on resize.

The primitive also sets `w-full min-w-0` on `.nicegui-content`: as a flex item it
otherwise sizes to its content (e.g. the chart canvas), pushing the page past the
viewport and forcing a mobile zoom-out. `ui.query()` is page-scoped, so no other
route is affected.

Also folds in the DSP add-band UX tweaks: prepend new bands (always in view, ready
to edit) and move `+ Add band` to the right of the info line for one-handed use.

Verified via mobile CDP capture (390x844): fixed region stays put, only the band
rows scroll, no horizontal overflow; players list (/) untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant