Skip to content

Add UX optionality settings (#42)#47

Open
thomaseleff wants to merge 10 commits into
v0.1.0-beta.1-43from
v0.1.0-beta.1-42
Open

Add UX optionality settings (#42)#47
thomaseleff wants to merge 10 commits into
v0.1.0-beta.1-43from
v0.1.0-beta.1-42

Conversation

@thomaseleff

@thomaseleff thomaseleff commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements all 4 workstreams of the UX-optionality plan for issue #42.

WS1 — feature-flag foundation

  • Adds the feature-flag catalog pattern (audera/ui/features.py): Feature/Option dataclasses, a FEATURES registry, and get_feature/default_selections/selected/flag_enabled helpers.
  • Adds Settings.features: dict[str, str] persistence field; the existing DAL round-trips it with zero code changes, and old settings.json files without the key still load fine (defaults to {}).
  • Adds ADR 004 documenting the design decisions (catalog as source of truth, FF_* option-value constants, 2-3 options with first-as-default, user-selected + DAL-persisted flags, Settings stores raw selections only).
  • Documents the pattern in audera/ui/AGENTS.md.

WS2 — Settings tab: Features button groups

  • Replaces the PlexAmp/Snapserver host text inputs and manual Save button in the Settings tab with one auto-saving ui.toggle button group per registered audera.ui.features entry (Player Selection, Volume).
  • Selecting an option persists immediately via the DAL (no Save click) and refreshes the Players tab.
  • Host fields remain on the Settings model (still needed to construct clients) but are now sourced from env vars only, no longer editable from the UI.
  • _load_settings() switches from exists-then-get to settings_dal.get_or_create(...) seeded with features.default_selections(), so ~/.audera/settings.json exists with resolved default flags from the very first dashboard load — a decision explicitly deferred from WS1 that WS3/WS4's players-tab branching depends on.

WS3 — Players tab: Player Selection experience

  • The Players tab now reads the player_selection flag (features.flag_enabled(...)) once per render and branches each Snapcast client card accordingly.
  • mute (default, unchanged): full card — name, settings button, volume slider, and a Mute checkbox that disables the slider when checked.
  • disabled (new): an enable/disable ui.switch appears left of the name, derived from client.muted. Toggling it calls set_client_volume(id, 100, muted=not enabled) and refreshes the tab. Muted clients collapse to a minimized single-line header (switch + name + settings button, no volume row); unmuted clients show the full card minus the now-redundant Mute checkbox.
  • "Disabled" is deliberately derived from Snapcast's live client.muted state rather than a new persisted field — an accepted trade-off per issue Add UX optionality settings #42 (a client muted via dragging the volume slider to 0 in the mute experience will also show as "disabled" after switching experiences).
  • The settings-gear button stays visible even on minimized cards — it's the only path to rename/latency/loudness/Snapcast-reset for that client.

WS4 — Players tab: Volume experience (percent vs. dB)

  • Wires the previously inert volume feature flag into the Players tab's volume control, which now reads features.flag_enabled(..., VOLUME_KEY, FF_VOLUME_PERC_OR_DB) per render.
  • Both modes gain a leading volume_up icon and a live trailing value label bound to the slider.
  • percent (default, mostly unchanged): today's 0-100 slider, now with the icon/label pair (NN%).
  • db (new): the same percent (0-100) slider as percent mode — only the trailing label changes, showing percent_to_db(value) as -N.N dB. Keeping one shared percent scale means the handle sits at the same physical spot when toggling between modes (a dB-scaled slider would move the handle, since dB is logarithmic in the percent domain).
  • DSPConfig.volume (percent) remains the single canonical persisted value regardless of UI mode, and both modes drive CamillaDSP through set_percent_volume before calling dsp_dal.update(...) and Snapcast's set_client_volume.

dB experience fixes (follow-up)

Testing WS4 on a re-provisioned streamer surfaced three defects, all now fixed:

  • dB slider position mismatch (root fix). The dB experience originally used a dB-scaled slider (min=MIN_DB, max=MAX_DB), so the handle jumped when toggling modes — the same volume sits at percent/100 of the track in percent mode but at (db-MIN_DB)/(MAX_DB-MIN_DB) in dB mode. Fixed by making the slider always percent-scaled and only relabeling in dB mode (see WS4 above), so the handle position is identical across modes. Seeds are integer-percent aligned so the periodic refresh doesn't fire a phantom update:model-value and re-snap the handle. (db_to_percent still returns a precise float and DSPConfig.volume is a float — no rename, no migration, 25 parses to 25.0 — so get_percent_volume stays exact; the slider path no longer depends on it.)
  • Auto-mute on refresh. The persist path muted whenever percent == 0, and the old db_to_percent truncated anything below ~-40.5 dB to 0, so a normal mid-range dB edit could silently persist 0 → Snapcast mute, noticed only when the 10s refresh re-read Snapcast state. Mute is now anchored to the slider floor (percent <= 0, shown as MIN_DB in dB mode), never on lossy zero-rounding — and since the slider is percent-scaled, only the far-left 0% mutes.
  • dB range. MIN_DB is -50.0 and MAX_DB is 0.0, matching CamillaGUI's default slider range (volume_range=50 / volume_max=0); attenuation-only, no gain above 0 dB.
  • Preamp attenuation Gain filter (loudness headroom). As an artifact of the volume/headroom work, apply_loudness now inserts an audera_preamp_attenuation Gain filter at -10.0 dB (-_LOUDNESS_LOW_BOOST) before the audera_loudness Loudness filter, on both channels, so the two together never exceed 0 dB of headroom (clip protection against the Loudness filter's worst-case +10 dB low_boost). remove_loudness strips both the loudness and preamp filters (and their pipeline steps). Verified on-device: enabling loudness yields preamp_gain=-10.0 dB with 2 loudness + 2 preamp steps; disabling removes all four.

UI polish & conventions (follow-up)

Two Players-tab nit-picks plus a readability convention, surfaced while wrapping up:

  • dB slider length no longer shifts. The dB value label had no fixed width, so the percent-scaled slider (grow) elongated/shortened as the label text changed digit count (e.g. -9.0 dB-10.0 dB), visibly nudging the handle. The label now has a fixed width + right-align (text-right w-16), which comfortably fits the widest string in either mode (-50.0 dB, 100%), so the slider length — and handle position — stays constant.
  • Disabled players read as disabled. In the disabled experience, a disabled (muted) client now grays out its name (text-gray-400) and disables its settings-gear button, reinforcing the "disabled" intent. The gear stays in the DOM (still the only path to its settings), just non-interactive.
  • Flag-resolution locals named after their FF_* constant. Call sites that resolve a flag into a local boolean now name that variable identically to the constant (FF_DISABLED_VS_MUTE = flag_enabled(...), FF_VOLUME_PERC_OR_DB = flag_enabled(...)), so every flag-gated UI branch is instantly recognizable and greppable by FF_. Recorded as decision 7 in ADR 004, and a new Architecture decisions section in AGENTS.md points agents at docs/adrs/ so the convention is discoverable.

HDMI audio-device provisioning fixes + bundled configs into code (follow-up)

PR #46 added --audio-device hdmi provisioning support; two of the settings it applied were wrong for real HDMI sinks. Fixing the first cleanly meant parameterizing the config-emitting CLI, so to avoid a split architecture (one config rendered from code, the rest read from data files) all bundled configs are now rendered from code.

  • CamillaDSP playback format is now a CLI flag (--playback-format), defaulting to S32LE, and provisioning passes S16LE for HDMI. Many HDMI sinks (TVs, AVRs) reject 32-bit PCM and respond with silence/dropouts. audera {player,streamer} conf camilladsp.yml --playback-format {S16LE,S32LE} (added to both conf subparsers in audera/cli/audera.py, threaded through commands.py) selects the format; os/dietpi/{player,streamer}/automation/setup.sh passes S16LE when --audio-device hdmi is set, via a new camilladsp_playback_format helper in os/dietpi/lib/config.sh, and S32LE otherwise. This loses no quality — the pipeline's effective ceiling is already 16-bit/48 kHz (ADR 002), so the 32-bit playback path is lossless zero-padding. The capture format stays S32LE regardless, to match Snapclient's 32-bit loopback output.
  • dtoverlay=vc4-kms-v3dvc4-fkms-v3d in the hdmi) branch of configure_audio_device() (os/dietpi/lib/config.sh). Full KMS ignores the legacy hdmi_* config.txt settings the same branch writes; firmware-KMS honors them, which headless HDMI audio needs. The hdmi_*, dtparam=audio=on, and vc4.force_hotplug=3 lines are unchanged.
  • All bundled configs moved from audera/conf/ data files into audera/cli/conf.py render functions (render_camilladsp, render_snapserver, render_asound) — a single source of truth. commands.py dispatches by filename to the render functions instead of reading files via importlib.resources. The embedded content is reproduced byte-for-byte verbatim: each render was generated programmatically from the originals' read_text() output and diffed against git HEAD (CRLF-normalized to match read_text() behavior) — the snapserver, asound, and both (byte-identical) camilladsp originals all match, and the S16LE variant changes only the single playback-format line.
  • Deleted the audera/conf/ directory (player/camilladsp.yml, streamer/{asound.conf,camilladsp.yml,snapserver.conf}); nothing else reads them and hatchling ships audera regardless.
  • Tests & fixtures migrated. tests/conftest.py's snapserver_container fixture now writes conf.render_snapserver() to a tmp_path_factory file and mounts that (also validating the render against a real snapserver). tests/cli/test_commands.py drops the importlib.resources mocking in favor of asserting real render output, including parameterized camilladsp cases: default → playback + capture both S32LE; S16LE → playback S16LE, capture still S32LE, HDMI STABILITY comment preserved.
  • Docs. ADR 003 gains a "HDMI playback uses S16LE" subsection; ADRs 001/002/003 update their audera/conf/* path references to the audera/cli/conf.py render functions; docs/dev/CLI.md documents the --playback-format flag; docs/dev/PROVISION.md extends the -a, --audio-device row (hdmi uses vc4-fkms-v3d and S16LE).
  • A scoped [tool.ruff.lint.per-file-ignores] entry exempts audera/cli/conf.py from E501/W291/W293, since the embedded config content must preserve long lines and trailing/blank-line whitespace verbatim.

Test plan

  • uv run ruff check --fix — clean
  • uv run ruff format — clean
  • uv run ty check — clean
  • uv run pytest tests/ui/test_streamer.py -v — 27 passed
  • uv run pytest tests/clients/test_camilladsp.py::test_percent_to_db tests/clients/test_camilladsp.py::test_db_to_percent -v — 2 passed (pure math, no Docker)
  • uv run pytest tests/dal/ tests/models/test_dsp.py -v — 63 passed, no regressions
  • uv run pytest tests/cli/test_commands.py -v — rewritten + new render cases pass (no Docker)
  • Byte-equivalence: each audera/cli/conf.py render diffed against git HEAD:audera/conf/* (CRLF-normalized) — empty diff for snapserver, asound, and both camilladsp originals

Manual end-to-end on the streamer (percent drag, dB round-trip across mode switches, mid-range dB edit stays unmuted through the 10s refresh, far-left floor mutes/unmutes) still to be exercised on-device. HDMI-specific manual verification (config.txt shows dtoverlay=vc4-fkms-v3d; /etc/camilladsp/config.yml shows format: S16LE under playback: / S32LE under capture: after provisioning with --audio-device hdmi) still to be exercised on-device.

🤖 Generated with Claude Code

Introduces the catalog pattern (audera/ui/features.py) so streamer-dashboard
features can offer 2-3 user-selectable UX options instead of one guessed
default. Adds the Settings.features persistence field (round-trips through
the existing DAL with no code changes), ADR 004 documenting the design
decisions, and DAL/catalog tests. No tab-rendering changes yet -- those land
in WS2/WS3/WS4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@thomaseleff thomaseleff changed the title WS1: UX optionality feature-flag foundation (#42) Add UX optionality settings (#42) Jul 4, 2026
)

Settings tab now renders one auto-saving toggle group per registered
audera.ui.features entry (Player Selection, Volume) instead of the
PlexAmp/Snapserver host inputs and manual Save button. Host fields stay
on Settings for client construction but are now env-var only.

_load_settings() switches to settings_dal.get_or_create() seeded with
features.default_selections(), so settings.json exists with resolved
flags from the first dashboard load.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@thomaseleff thomaseleff changed the title Add UX optionality settings (#42) Add UX optionality settings (WS1+WS2, #42) Jul 4, 2026
… (WS3, #42)

Reads the player_selection feature flag to switch Snapcast client cards
between the default mute checkbox and a header enable/disable switch that
drives client.muted directly, collapsing muted clients to a minimized
single-line card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@thomaseleff thomaseleff changed the title Add UX optionality settings (WS1+WS2, #42) Add UX optionality settings (WS1+WS2+WS3, #42) Jul 4, 2026
Wires the previously inert 'volume' feature flag into the Players tab:
adds a volume_up icon and live NN%/-N.N dB label to the existing
percent slider, and a new dB-scaled slider (routed through
CamillaDSPClient.set_volume) when the 'db' option is selected.
DSPConfig.volume remains the canonical persisted percent value in
both modes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@thomaseleff thomaseleff changed the title Add UX optionality settings (WS1+WS2+WS3, #42) Add UX optionality settings (WS1+WS2+WS3+WS4, #42) Jul 4, 2026
@thomaseleff thomaseleff changed the title Add UX optionality settings (WS1+WS2+WS3+WS4, #42) Add UX optionality settings (#42) Jul 4, 2026
…R 004

Adds explicit guidance to audera/ui/AGENTS.md and ADR 004 that agents
implementing UI features should propose/ask about feature-flag
optionality by default rather than hard-coding a single UX. Also
reflows ADR 004's paragraphs to one line each, matching the
line-per-paragraph convention used by ADRs 001-003.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
thomaseleff and others added 5 commits July 4, 2026 20:25
…ge (#42)

Players tab: Mute checkbox moves out of the volume row (which wrapped on
narrow viewports) into the card header, and both percent/dB sliders now
grow to fill the row instead of a fixed w-48.

CamillaDSPClient: volume is now attenuation-only, mapping 0-100% onto a
fixed -80..0 dB range (MIN_DB/MAX_DB) instead of deriving dB from percent
and separately clamping the top to a self-imposed -3.0 dB, which squeezed
the practical 1-100% range into the top ~17dB of an 87dB-wide slider.

Clip protection for the Loudness filter's own automatic boost moves from
that fader clamp to a dedicated Gain preamp-attenuation filter, inserted
into the pipeline only while Loudness is enabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Players-tab dB experience had three defects surfaced on a re-provisioned
streamer:

- dB slider re-seeded at a different spot after an edit, because
  db_to_percent truncated to int and DSPConfig.volume was an int, so a dB
  edit round-tripped lossily.
- A normal mid-range dB edit silently muted the client: db_to_percent
  truncated anything below ~-40.5 dB to 0, and the persist path muted
  whenever percent == 0.

Fixes:
- db_to_percent returns a precise float (0.0 only at/below the floor);
  DSPConfig.volume is now a float so dB edits round-trip without drift.
- MIN_DB -80.0 -> -50.0 to match CamillaGUI's default slider range.
- Mute is anchored at the slider floor (db <= MIN_DB / percent <= 0), not
  on lossy zero-rounding.
- Both slider seeds are step-aligned so the periodic refresh does not fire
  a phantom update:model-value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…'t move (#42)

The dB experience used a dB-scaled slider (min=MIN_DB, max=MAX_DB), so the
handle jumped when toggling modes: the same volume sits at percent/100 of the
track in percent mode but at (db-MIN_DB)/(MAX_DB-MIN_DB) in dB mode.

Per the agreed design, the slider is now ALWAYS a percent (0-100) control; the
'volume' feature selection only swaps the value label (NN% vs
percent_to_db(value) as -N.N dB). The handle position is therefore identical
across modes, and both modes drive CamillaDSP through set_percent_volume with
DSPConfig.volume (percent) as the single canonical value. Mute stays anchored
at the floor (percent <= 0, shown as MIN_DB in dB mode).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Give the dB value label a fixed width + right-align so the volume
  slider length stays constant as the text changes digit count
  (e.g. -9.0 dB -> -10.0 dB) instead of shifting the handle.
- In the disabled experience, gray out a disabled player's name and
  disable its settings icon to reinforce the "disabled" intent.
- Name flag-resolution locals after their FF_* constant so every
  flag-gated UI branch is obvious; record the convention as decision 7
  in ADR 004.
- Add an Architecture decisions section to AGENTS.md pointing agents at
  docs/adrs/.

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

HDMI sinks reject S32LE playback (silence/dropouts) and ignore the legacy
hdmi_* config.txt settings under full KMS. Fix both in provisioning:

- CamillaDSP playback format is now a `conf --playback-format` flag
  (default S32LE); provisioning passes S16LE for `--audio-device hdmi`
  via a `camilladsp_playback_format` helper. Capture stays S32LE to match
  Snapclient's 32-bit loopback. No quality loss: the pipeline ceiling is
  already 16-bit/48 kHz (ADR 002), so the 32-bit path is zero-padding.
- `dtoverlay=vc4-kms-v3d` -> `vc4-fkms-v3d` so the hdmi_* settings apply.

To avoid a split architecture, all bundled configs move from data files
(`audera/conf/`) into render functions in `audera/cli/conf.py`, the single
source of truth. snapserver.conf / asound.conf are reproduced byte-for-byte
(verified against HEAD); camilladsp.yml is parameterized on playback format.

The Docker snapserver fixture now renders the config to a temp file, and the
conf tests assert real render output (no more importlib mocking).

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