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)
│ ├─ hass-toggle-menu → 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 |
|---|---|
menu_expand | Hamburger / sidebar toggle (hass-toggle-menu) |
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_red | Alert mode set to red_alert |
alert_yellow | Alert mode set to yellow_alert |
alert_blue | Alert mode set to blue_alert |
alert_gray | Alert mode set to gray_alert |
alert_black | Alert mode set to black_alert |
alert_clear | Alert mode cleared (back to normal) |
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 |
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 |
Enable model: The master toggle (sound_enabled) uses opt-in — if the helper entity doesn't exist, _isEnabled() returns false and all sounds are silent. The user must explicitly create the helper and set it to on. Category toggles use opt-out from master — if a category helper doesn't exist, that category is on as long as the master is. Only an explicit 'off' state on a category helper disables it. This split means sounds are silent by default on a fresh install, and users get full category control once they've deliberately enabled the system.
Scheme fallback: _getActiveScheme() treats both a missing entity and the 'none' sentinel as "not yet configured" and falls back to the first registered scheme. The 'none' option in the UI scheme picker means "use the default scheme", not "silence all sounds". To silence all sounds, use the master sound_enabled toggle.
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 Policy
Modern browsers block audio playback until the user has interacted with the page. SoundManager does not implement its own interaction guard — it relies on the browser's native policy. Practically this means:
- Card tap / hold / navigation sounds work immediately after any first user touch or click.
- The
system_readystartup sound requires the HA dashboard URL to be added to the browser's "allowed to autoplay" list (see Sound Effects → Browser Audio Policy for per-browser instructions).
Sidebar Menu Toggle Handler
Listens for hass-toggle-menu on window. HA's ha-menu-button dispatches this event when the hamburger is tapped. Some HA versions dispatch it twice per click (button + host component), so the handler deduplicates events within a 200 ms window — only the first firing plays menu_expand. This approach is more reliable than click-path detection, which would also fire for icon buttons inside sidebar navigation links and cause double sounds.
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/my-sound-pack/tap.mp3',
description: 'Tap beep',
},
},
// Registered with SoundManager
sound_schemes: {
my_scheme: {
card_tap: 'my_tap',
menu_expand: '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.
Startup Sound Sequencing
system_ready and error are fired by LCARdSCore._performInitialization() at the end of the core init sequence. Because overrides and scoped settings are loaded asynchronously (they depend on the integration probe completing after HASS is distributed), play() must not be called until that load finishes — otherwise per-event overrides and scoped scheme selections are not yet in memory.
The solution is a non-blocking fire-and-forget:
// In LCARdSCore._performInitialization():
this.soundManager.ensureReady().then(() => {
if (!window._lcardsSystemReadyPlayed) {
window._lcardsSystemReadyPlayed = true;
this.soundManager.play('system_ready');
}
});ensureReady() returns the promise from _ensureOverridesLoaded(). If updateHass() already triggered the load earlier, the same in-flight or completed promise is returned — no duplicate load. The .then() chain is non-blocking, so core init completes immediately and cards mount normally.
window._lcardsSystemReadyPlayed is a page-load flag. window is completely reset on any true page load (including hard refresh), so the sound plays once per load. It survives SPA navigation and module re-initialization, preventing system_ready from replaying when LCARdS re-inits mid-session (e.g. navigating to the Config Panel).
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
sm.playAsset('my_asset'); // respects master sound_enabled,
// skips per-category gate — used by
// the lcards.play_sound HA action
// 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
// Startup sequencing — await before playing sounds that must respect overrides
await sm.ensureReady(); // resolves when overrides/scoped settings are loaded
// 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(); // cleanup
// Diagnostics — dumps enable/category state, helper cache contents, computed results
sm.diagnose()Console 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.previewEvent('system_ready') // preview event (bypasses enable checks)
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
await sm.ensureReady() // wait for overrides to be fully loaded