Skip to content

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

FilePurpose
src/core/sound/SoundManager.jsCore singleton — all playback, listeners, scheme management
src/core/packs/sounds/builtin-sounds.jslcards_default scheme + asset definitions
src/core/helpers/lcards-helper-registry.jsHelper definitions for 6 sound config helpers

Event Types & Categories

Event types are grouped into three categories, each controlled by an independent input_boolean helper.

cardsinput_boolean.lcards_sound_cards

EventTrigger
card_tapAny tap on a LCARdS card or standard HA card
card_holdHold action
card_double_tapDouble-tap action
card_hoverHover (desktop pointer)
button_tapLCARdS button element tap
toggle_on / toggle_offToggle state change
slider_drag_start / slider_drag_endSlider grab / release
slider_changePer-tick slider value change (silenced in default scheme)
more_info_openMore-info panel opened

uiinput_boolean.lcards_sound_ui

EventTrigger
nav_sidebarSidebar navigation item click
menu_expandHamburger / expand icon click
nav_pagelocation-changed event (view/page navigation)
dialog_openAny HA dialog opens (except more-info)
dialog_closeAny HA dialog dismissed (save or cancel)
dashboard_edit_startDashboard edit mode entered
dashboard_edit_saveDashboard edit mode exited

alertsinput_boolean.lcards_sound_alerts

EventTrigger
alert_activateAlert mode set to red / yellow / blue
alert_clearAlert mode set to green (cleared)
alert_escalateAlert escalation (reserved)
system_readyLCARdS initialization complete
errorSystem error condition
notificationGeneral 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 keyEntity IDTypePurpose
sound_enabledinput_boolean.lcards_sound_enabledbooleanMaster on/off (default off — opt-in)
sound_cards_enabledinput_boolean.lcards_sound_cardsbooleanCard interaction category
sound_ui_enabledinput_boolean.lcards_sound_uibooleanUI navigation category
sound_alerts_enabledinput_boolean.lcards_sound_alertsbooleanAlerts category
sound_volumeinput_number.lcards_sound_volumenumber 0–1Master volume
sound_schemeinput_select.lcards_sound_schemeselectActive 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_storage capability)

All four per-event settings support user and device scopes via the ScopedSettingsService waterfall:

SettingScopesGlobal fallback
sound_enableddevice, user, globalinput_boolean.lcards_sound_enabled
sound_volumedevice, user, globalinput_number.lcards_sound_volume
sound_schemedevice, user, globalinput_select.lcards_sound_scheme
sound_overridesdevice, user, globalbackend 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:

CacheWhat it holds
_cachedEnabledScoped sound_enabled value (device → user → global waterfall)
_cachedVolumeScoped sound_volume (device → user → global waterfall)
_cachedSchemeScoped sound_scheme (device → user → global waterfall)
_globalOverridesCachePer-event overrides from global scope (sound_overrides, global tier)
_overridesCachePer-event overrides from user scope (sound_overrides, user tier)
_deviceOverridesCachePer-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:

javascript
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:

  1. audio.volume is set from _getVolume()
  2. audio.currentTime = 0 (allows rapid re-trigger)
  3. audio.play().catch(() => {}) suppresses AbortError from 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:

javascript
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:

TierStorage pathWho sees it
globalintegration-level flat keyall users, all devices
userper-user scoped storagethat user only, all their devices
deviceper-device scoped storagethat 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

  1. Add the key to EVENT_CATEGORY in SoundManager.js with the appropriate category ('cards', 'ui', or 'alerts').
  2. Add a human-readable label to SOUND_EVENT_LABELS.
  3. Add a mapping to LCARDS_DEFAULT_SCHEME in builtin-sounds.js (use null to silence by default).
  4. Fire it via soundManager.play('my_new_event') at the appropriate call site.

Public API

javascript
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();                                      // cleanup

Console Access

javascript
window.lcards.debug.singleton('soundManager')
// → { type: 'SoundManager', initialized: true, schemesCount: 3, activeScheme: 'lcards_default', overrideCount: 0 }
javascript
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