Sound System Architecture
UI audio coordination for LCARdS —
window.lcards.core.soundManager
The Sound System provides event-driven audio feedback for all LCARdS interactions and HA UI events. It is a single-instance BaseService that lives at window.lcards.core.soundManager.
System Overview
Architecture Components
SoundManager (BaseService singleton)
│
├─ Tier 1 — Card interactions
│ └─ LCARdSActionHandler.setupActions()
│ calls soundManager.play(eventType, { cardOverride })
│
├─ Tier 2 — Global HA UI listeners (document-level)
│ ├─ click (capture) → nav_sidebar, menu_expand
│ ├─ location-changed → nav_page
│ ├─ hass-action → card_tap / card_hold / card_double_tap
│ ├─ show-dialog → dialog_open
│ ├─ hass-more-info → more_info_open
│ ├─ dialog-closed → dialog_close
│ └─ history.replaceState patch → dashboard_edit_start / dashboard_edit_save
│
├─ Scheme Registry (Map<name, eventMap>)
│ └─ Registered via PackManager.registerPack() → registerSchemes()
│
├─ Audio Cache (Map<assetKey, HTMLAudioElement>)
│ └─ URL resolved from AssetManager registry (no async preload needed)
│
└─ Override Store
└─ Integration persistent storage key: 'sound_overrides' (JSON eventType→assetKey map)Key Files
| File | Purpose |
|---|---|
src/core/sound/SoundManager.js | Core singleton — all playback, listeners, scheme management |
src/core/packs/sounds/builtin-sounds.js | lcards_default scheme + asset definitions |
src/core/helpers/lcards-helper-registry.js | Helper definitions for 6 sound config helpers |
Event Types & Categories
Event types are grouped into three categories, each controlled by an independent input_boolean helper.
cards — input_boolean.lcards_sound_cards
| Event | Trigger |
|---|---|
card_tap | Any tap on a LCARdS card or standard HA card |
card_hold | Hold action |
card_double_tap | Double-tap action |
card_hover | Hover (desktop pointer) |
button_tap | LCARdS button element tap |
toggle_on / toggle_off | Toggle state change |
slider_drag_start / slider_drag_end | Slider grab / release |
slider_change | Per-tick slider value change (silenced in default scheme) |
more_info_open | More-info panel opened |
ui — input_boolean.lcards_sound_ui
| Event | Trigger |
|---|---|
nav_sidebar | Sidebar navigation item click |
menu_expand | Hamburger / expand icon click |
nav_page | location-changed event (view/page navigation) |
dialog_open | Any HA dialog opens (except more-info) |
dialog_close | Any HA dialog dismissed (save or cancel) |
dashboard_edit_start | Dashboard edit mode entered |
dashboard_edit_save | Dashboard edit mode exited |
alerts — input_boolean.lcards_sound_alerts
| Event | Trigger |
|---|---|
alert_activate | Alert mode set to red / yellow / blue |
alert_clear | Alert mode set to green (cleared) |
alert_escalate | Alert escalation (reserved) |
system_ready | LCARdS initialization complete |
error | System error condition |
notification | General notification event |
HA Helper Integration
Six input helpers store the global sound configuration. They are created via the Sound Config Panel or manually via YAML.
| Helper key | Entity ID | Type | Purpose |
|---|---|---|---|
sound_enabled | input_boolean.lcards_sound_enabled | boolean | Master on/off (default off — opt-in) |
sound_cards_enabled | input_boolean.lcards_sound_cards | boolean | Card interaction category |
sound_ui_enabled | input_boolean.lcards_sound_ui | boolean | UI navigation category |
sound_alerts_enabled | input_boolean.lcards_sound_alerts | boolean | Alerts category |
sound_volume | input_number.lcards_sound_volume | number 0–1 | Master volume |
sound_scheme | input_select.lcards_sound_scheme | select | Active scheme name |
Category opt-out model: If a category helper doesn't exist yet, _isCategoryEnabled() returns true (play sounds). The master sound_enabled helper defaults to false (opt-in — no sounds until explicitly turned on).
Scheme options sync: When packs register schemes via registerSchemes(), SoundManager calls helperManager.updateSelectOptions('sound_scheme', schemeNames) to keep the input_select options in sync with loaded packs. This is non-fatal if the helper doesn't exist yet.
Per-User / Per-Device Overrides
Requires: LCARdS integration v1.12+ (
scoped_storagecapability)
All four per-event settings support user and device scopes via the ScopedSettingsService waterfall:
| Setting | Scopes | Global fallback |
|---|---|---|
sound_enabled | device, user, global | input_boolean.lcards_sound_enabled |
sound_volume | device, user, global | input_number.lcards_sound_volume |
sound_scheme | device, user, global | input_select.lcards_sound_scheme |
sound_overrides | device, user, global | backend flat key |
Note: Per-category toggles (sound_cards_enabled, etc.) are global-only — they cannot be scoped per user or device.
How it works
SoundManager._ensureOverridesLoaded() is called once after the integration probe resolves. It loads all scoped settings asynchronously and populates five in-memory caches:
| Cache | What it holds |
|---|---|
_cachedEnabled | Scoped sound_enabled value (device → user → global waterfall) |
_cachedVolume | Scoped sound_volume (device → user → global waterfall) |
_cachedScheme | Scoped sound_scheme (device → user → global waterfall) |
_globalOverridesCache | Per-event overrides from global scope (sound_overrides, global tier) |
_overridesCache | Per-event overrides from user scope (sound_overrides, user tier) |
_deviceOverridesCache | Per-event overrides from device scope (sound_overrides, device tier) |
The synchronous read methods (_isEnabled(), _getVolume(), _getActiveScheme()) return the cached values when available, falling back to the live HA helper otherwise.
_getOverrides() (private, used inside play()) returns the merged result: { ...globalOverridesCache, ...userOverridesCache, ...deviceOverridesCache } — device-scope wins on a per-event basis.
refreshScopedCache() (public) clears all five caches and re-reads from the backend. The config panel calls this after every write so play() picks up changes immediately without a page reload.
This means:
- First render: uses the HA helpers (synchronous, available instantly).
- After first probe & cache load: uses the scoped values (highest-priority tier wins).
Config Panel
Users configure their scoped overrides in LCARdS Config Panel → Sounds → Per-User / Per-Device Overrides (collapsible section at the bottom of the Sounds tab).
Admins manage all users and devices from LCARdS Config Panel → Users & Devices.
For a complete reference see the Scoped Settings Service and Device Identity Manager docs.
Sound Resolution Order
play(eventType, context) resolves the asset key in strict priority order:
1. context.cardOverride
│ null → explicitly silenced (return immediately)
│ string → use this asset key directly
│
2. Merged per-event override (device-scope wins, then user, then global)
│ _getOverrides() = { ...globalOverridesCache, ...userOverridesCache, ...deviceOverridesCache }
│ eventType in overrides:
│ null → explicitly muted (return immediately)
│ string → use this asset key
│
3. Active scheme mapping (scheme resolved from device/user/global waterfall)
│ scheme[eventType] === null → scheme-silenced (return)
│ scheme[eventType] === string → use this asset key
│
4. No mapping found → silence (return)Global Listener Architecture
mountGlobalUIListener() attaches all Tier 2 listeners. It is idempotent (safe to call multiple times; exits early if already mounted). Called by LCARdSCore after initialization.
Browser Autoplay Guard
A one-shot capture-phase click listener sets _userInteracted = true. Until this fires, all _playAsset() calls are silently dropped (browser autoplay policy). preview() bypasses this guard via force=true.
Click Handler (sidebar sounds)
Capture-phase click on document. Checks composedPath() for HA-SIDEBAR or role="navigation", then discriminates between hamburger buttons (HA-ICON-BUTTON) → menu_expand and nav items (PAPER-ICON-ITEM, role=option/menuitem) → nav_sidebar.
hass-action Handler
Catches tap/hold/double_tap on non-LCARdS HA cards (Mushroom, built-in HA cards, etc.) that fire the composed hass-action event. Guards against double-firing by skipping events whose composedPath() passes through any LCARDS-* element (LCARdS cards handle their own sounds via LCARdSActionHandler).
show-dialog Handler
Fires on any HA dialog open except ha-more-info-dialog (which is handled separately as more_info_open to give it a distinct event type).
dialog-closed Handler
Fires on any HA dialog dismiss (save, cancel, ESC, or close button). Skips dialogs whose localName starts with lcards- to avoid double-sounds from LCARdS-owned panels.
Dashboard Edit Mode (history.replaceState patch)
HA uses history.replaceState (not a DOM event) to toggle ?edit=1 in the URL when entering/exiting dashboard edit mode. SoundManager patches window.history.replaceState, compares the edit=1 param before/after, and fires dashboard_edit_start or dashboard_edit_save on change. The original function is saved as _historyReplaceStateOrig and restored in destroy().
Audio Asset Resolution
Asset URLs are resolved synchronously from AssetManager's internal registry:
const registry = this._core?.assetManager?.getRegistry('audio');
const entry = registry?.assets?.get(assetKey);
const url = entry?.url;Audio elements are created once per asset key and cached in _audioCache. On each play:
audio.volumeis set from_getVolume()audio.currentTime = 0(allows rapid re-trigger)audio.play()—.catch(() => {})suppressesAbortErrorfrom fast replays
No async preloading is required — the browser fetches the URL on first play.
Pack Integration
Sound packs declare sound_schemes and audio_assets in their pack definition:
export const MY_SOUND_PACK = {
id: 'my_sound_pack',
version: '1.0.0',
name: 'My Sound Pack',
// Registered with AssetManager
audio_assets: {
my_tap: {
url: '/hacsfiles/lcards/sounds/my_pack/tap.mp3',
description: 'Tap beep',
},
},
// Registered with SoundManager
sound_schemes: {
my_scheme: {
card_tap: 'my_tap',
nav_sidebar: 'my_tap',
card_hover: null, // Silence this event in this scheme
// Omitted events → silence
},
},
};PackManager.registerPack() routes audio_assets to AssetManager and sound_schemes to SoundManager.registerSchemes(). The sound_scheme input_select options are updated automatically after registration.
Override Storage
Per-event overrides are stored as a flat { eventType: assetKey } JSON object under the key sound_overrides in scoped storage. Two independent tiers exist:
| Tier | Storage path | Who sees it |
|---|---|---|
global | integration-level flat key | all users, all devices |
user | per-user scoped storage | that user only, all their devices |
device | per-device scoped storage | that device only, regardless of user |
User-scope overrides win on a per-event basis (individual events can be user-overridden while others fall through to global).
null asset key values silence the event — they are stored (as null) in the override map. To remove an override entirely (revert to scheme), call setOverride(eventType, undefined, scope) or use the clear operation. _getOverrides() returns {} on any read failure (never throws).
Adding a New Event Type
- Add the key to
EVENT_CATEGORYinSoundManager.jswith the appropriate category ('cards','ui', or'alerts'). - Add a human-readable label to
SOUND_EVENT_LABELS. - Add a mapping to
LCARDS_DEFAULT_SCHEMEinbuiltin-sounds.js(usenullto silence by default). - Fire it via
soundManager.play('my_new_event')at the appropriate call site.
Public API
const sm = window.lcards.core.soundManager;
// Playback
sm.play('card_tap');
sm.play('card_tap', { cardOverride: 'my_asset' }); // card-level override
sm.play('card_tap', { cardOverride: null }); // silence this event
sm.preview('my_asset'); // bypass enable checks
sm.previewScheme('lcars_classic', 'card_tap'); // preview a scheme
// Global overrides (shared across all users and devices)
const overrides = sm.getOverrides('global'); // { eventType: assetKey }
await sm.setOverride('card_tap', 'my_asset', 'global'); // set global override
await sm.setOverride('card_tap', null, 'global'); // silence globally
await sm.clearAllOverrides('global'); // clear all global overrides
// Device-scoped overrides
const deviceOverrides = sm.getOverrides('device'); // { eventType: assetKey }
await sm.setOverride('card_tap', 'my_asset', 'device'); // set device override
await sm.clearAllOverrides('device'); // clear all device overrides
// Cache invalidation — call after any external storage write
await sm.refreshScopedCache(); // re-reads all 5 caches from backend
// Scheme introspection
const names = sm.getSchemeNames(); // ['none', 'lcards_default', ...]
const events = sm.getEventTypes(); // [{ key, label, category }, ...]
// Lifecycle
sm.mountGlobalUIListener(); // called by LCARdSCore
sm.subscribeToAlertMode(); // called by LCARdSCore
sm.destroy(); // cleanupConsole Access
window.lcards.debug.singleton('soundManager')
// → { type: 'SoundManager', initialized: true, schemesCount: 3, activeScheme: 'lcards_default', overrideCount: 0 }const sm = window.lcards.core.soundManager
sm.play('card_tap') // fire an event
sm.preview('my_asset') // play a specific asset directly
sm.getSchemeNames() // ['none', 'lcards_default', ...]
sm.getEventTypes() // [{ key, label, category }, ...]
sm.getOverrides('global') // global per-event overrides
sm.getOverrides('user') // user-scoped per-event overrides
await sm.setOverride('card_tap', 'my', 'global') // set global persistent override
await sm.setOverride('card_tap', 'my', 'user') // set user-scoped override
await sm.clearAllOverrides('global') // wipe all global overrides
await sm.refreshScopedCache() // force cache reload after admin writes