Add UX optionality settings (#42)#47
Open
thomaseleff wants to merge 10 commits into
Open
Conversation
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>
) 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>
… (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>
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>
…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>
af1e53b to
9320ca5
Compare
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements all 4 workstreams of the UX-optionality plan for issue #42.
WS1 — feature-flag foundation
audera/ui/features.py):Feature/Optiondataclasses, aFEATURESregistry, andget_feature/default_selections/selected/flag_enabledhelpers.Settings.features: dict[str, str]persistence field; the existing DAL round-trips it with zero code changes, and oldsettings.jsonfiles without the key still load fine (defaults to{}).FF_*option-value constants, 2-3 options with first-as-default, user-selected + DAL-persisted flags,Settingsstores raw selections only).audera/ui/AGENTS.md.WS2 — Settings tab: Features button groups
ui.togglebutton group per registeredaudera.ui.featuresentry (Player Selection,Volume).Settingsmodel (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 tosettings_dal.get_or_create(...)seeded withfeatures.default_selections(), so~/.audera/settings.jsonexists 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
player_selectionflag (features.flag_enabled(...)) once per render and branches each Snapcast client card accordingly.mute(default, unchanged): full card — name, settings button, volume slider, and aMutecheckbox that disables the slider when checked.disabled(new): an enable/disableui.switchappears left of the name, derived fromclient.muted. Toggling it callsset_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.client.mutedstate 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 themuteexperience will also show as "disabled" after switching experiences).WS4 — Players tab: Volume experience (percent vs. dB)
volumefeature flag into the Players tab's volume control, which now readsfeatures.flag_enabled(..., VOLUME_KEY, FF_VOLUME_PERC_OR_DB)per render.volume_upicon 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, showingpercent_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 throughset_percent_volumebefore callingdsp_dal.update(...)and Snapcast'sset_client_volume.dB experience fixes (follow-up)
Testing WS4 on a re-provisioned streamer surfaced three defects, all now fixed:
min=MIN_DB, max=MAX_DB), so the handle jumped when toggling modes — the same volume sits atpercent/100of 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 phantomupdate:model-valueand re-snap the handle. (db_to_percentstill returns a precise float andDSPConfig.volumeis afloat— no rename, no migration,25parses to25.0— soget_percent_volumestays exact; the slider path no longer depends on it.)percent == 0, and the olddb_to_percenttruncated anything below ~-40.5 dB to0, so a normal mid-range dB edit could silently persist0→ Snapcast mute, noticed only when the 10s refresh re-read Snapcast state. Mute is now anchored to the slider floor (percent <= 0, shown asMIN_DBin dB mode), never on lossy zero-rounding — and since the slider is percent-scaled, only the far-left0%mutes.MIN_DBis-50.0andMAX_DBis0.0, matching CamillaGUI's default slider range (volume_range=50/volume_max=0); attenuation-only, no gain above 0 dB.apply_loudnessnow inserts anaudera_preamp_attenuationGain filter at-10.0 dB(-_LOUDNESS_LOW_BOOST) before theaudera_loudnessLoudness 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_loudnessstrips both the loudness and preamp filters (and their pipeline steps). Verified on-device: enabling loudness yieldspreamp_gain=-10.0 dBwith 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:
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.disabledexperience, 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.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 byFF_. Recorded as decision 7 in ADR 004, and a new Architecture decisions section inAGENTS.mdpoints agents atdocs/adrs/so the convention is discoverable.HDMI audio-device provisioning fixes + bundled configs into code (follow-up)
PR #46 added
--audio-device hdmiprovisioning 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.--playback-format), defaulting toS32LE, and provisioning passesS16LEfor 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 bothconfsubparsers inaudera/cli/audera.py, threaded throughcommands.py) selects the format;os/dietpi/{player,streamer}/automation/setup.shpassesS16LEwhen--audio-device hdmiis set, via a newcamilladsp_playback_formathelper inos/dietpi/lib/config.sh, andS32LEotherwise. 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 staysS32LEregardless, to match Snapclient's 32-bit loopback output.dtoverlay=vc4-kms-v3d→vc4-fkms-v3din thehdmi)branch ofconfigure_audio_device()(os/dietpi/lib/config.sh). Full KMS ignores the legacyhdmi_*config.txt settings the same branch writes; firmware-KMS honors them, which headless HDMI audio needs. Thehdmi_*,dtparam=audio=on, andvc4.force_hotplug=3lines are unchanged.audera/conf/data files intoaudera/cli/conf.pyrender functions (render_camilladsp,render_snapserver,render_asound) — a single source of truth.commands.pydispatches by filename to the render functions instead of reading files viaimportlib.resources. The embedded content is reproduced byte-for-byte verbatim: each render was generated programmatically from the originals'read_text()output and diffed againstgit HEAD(CRLF-normalized to matchread_text()behavior) — the snapserver, asound, and both (byte-identical) camilladsp originals all match, and theS16LEvariant changes only the single playback-format line.audera/conf/directory (player/camilladsp.yml,streamer/{asound.conf,camilladsp.yml,snapserver.conf}); nothing else reads them and hatchling shipsauderaregardless.tests/conftest.py'ssnapserver_containerfixture now writesconf.render_snapserver()to atmp_path_factoryfile and mounts that (also validating the render against a real snapserver).tests/cli/test_commands.pydrops theimportlib.resourcesmocking in favor of asserting real render output, including parameterized camilladsp cases: default → playback + capture bothS32LE;S16LE→ playbackS16LE, capture stillS32LE,HDMI STABILITYcomment preserved.S16LE" subsection; ADRs 001/002/003 update theiraudera/conf/*path references to theaudera/cli/conf.pyrender functions;docs/dev/CLI.mddocuments the--playback-formatflag;docs/dev/PROVISION.mdextends the-a, --audio-devicerow (hdmi usesvc4-fkms-v3dandS16LE).[tool.ruff.lint.per-file-ignores]entry exemptsaudera/cli/conf.pyfromE501/W291/W293, since the embedded config content must preserve long lines and trailing/blank-line whitespace verbatim.Test plan
uv run ruff check --fix— cleanuv run ruff format— cleanuv run ty check— cleanuv run pytest tests/ui/test_streamer.py -v— 27 passeduv 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 regressionsuv run pytest tests/cli/test_commands.py -v— rewritten + new render cases pass (no Docker)audera/cli/conf.pyrender diffed againstgit HEAD:audera/conf/*(CRLF-normalized) — empty diff for snapserver, asound, and both camilladsp originalsManual 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.txtshowsdtoverlay=vc4-fkms-v3d;/etc/camilladsp/config.ymlshowsformat: S16LEunderplayback:/S32LEundercapture:after provisioning with--audio-device hdmi) still to be exercised on-device.🤖 Generated with Claude Code