Skip to main content

Sounds

Overview

The Sounds module provides a per-account audio library. Sounds are uploaded once and can be played directly in browser sources without leaving the dashboard. Extensions can bundle their own sounds and reference them via config fields, and automations can trigger playback in response to stream events.

Sounds are stored in the account's library and played back via the sounds:{accountId} WebSocket channel. Browser sources subscribed to this channel receive sound:play and sound:stop commands and execute them locally, keeping audio latency minimal.

The whole surface is gated on the feature:sounds feature flag in addition to the sounds:* permissions — on REST, on GraphQL, and on the WebSocket channel.

Upload limits

Upload limits are enforced per plan, resolved as COALESCE(account_limits.<col>, plans.<col>) so a per-account override wins over the plan value:

LimitPlan columnFreePro
Max soundsmax_sounds1050
Max sound file sizemax_sound_file_size5 MB20 MB
Max total sound storagemax_sound_storage_bytes100 MB1 GB

Free and Pro are the only plans that exist.

The count and total-storage checks run inside the insert as a single atomic CTE, so two concurrent uploads cannot both slip past the cap. Extension-bundled sounds (source_extension_id IS NOT NULL) are excluded from both counters.

Accepted audio content types (ALLOWED_AUDIO_TYPES in apps/api/src/routes/sounds.rs): audio/mpeg, audio/wav, audio/x-wav, audio/wave, audio/ogg, audio/webm, audio/mp4, audio/aac, audio/flac. The type is detected from the file's magic bytes, not from the client-supplied Content-Type — an unrecognised file is rejected with 400 Unsupported audio format.

Playback in browser sources

Browser sources (layers) that subscribe to sounds:{accountId} receive real-time playback commands. When a sound is played from the dashboard or via automation, the server publishes a sound:play event to the channel and all subscribed browser sources execute the audio locally.

Playback commands include:

  • sound:play — start playing a specific sound. The payload carries sound_id, stream_path (/api/sounds/{id}/stream), name, the resolved volume, and target.
  • sound:stop — stop playback of a specific sound. The payload carries sound_id and target.

A third event, sound:list:updated, is published on the same channel when the library changes (payload { action, sound_id }), so open editors and pickers can re-sync.

Targeting

When triggering sound playback, you can either broadcast to all browser sources or address one surface:

  • All browser sources (default) — omit target; every browser source subscribed to the account's sounds channel receives the command.
  • A single surface — pass a target object. It is not an overlay key but a two-field record:
{ "type": "overlay", "id": "01912345-6789-7abc-def0-123456789abc" }

type must be exactly "widget" or "overlay" and id must be a UUID. Anything else is rejected with 400 target.type must be 'widget' or 'overlay' — identically on REST (target) and GraphQL (SoundTargetInput).

This is useful when you have multiple overlays (e.g. a gameplay overlay and a facecam overlay) and want sounds to play from one specific source.

The volume argument is clamped server-side into 0.0–1.0 and defaults to 1.0.

RBAC

PermissionDescriptionOwnerAdminModViewer
sounds:readView the sound libraryxxxx
sounds:createUpload new soundsxx
sounds:editRename and update soundsxx
sounds:deleteDelete sounds from the libraryxx
sounds:playTrigger and stop sound playbackxxx

Extension-bundled sounds

Extensions can ship their own sounds in the dist/sounds/ directory. When an extension is installed, its bundled sounds are registered in the account's sound library and can be referenced in config fields of type sound.

The lumio deploy command automatically detects audio files placed in dist/sounds/ and includes them in the upload. Bundled sounds are scoped to the extension install — uninstalling the extension removes its sounds from the library.

Bundled sound behavior

  • Bundled sounds appear alongside user-uploaded sounds in the Sounds Panel (the overlay/widget-editor panel at apps/web/src/components/editor/sounds-panel.tsx; there is no standalone /dashboard/sounds page)
  • Users cannot delete or rename bundled sounds — they are controlled by the extension developer
  • Bundled sounds do not count against the account's plan sound limit or storage total (the limit CTE filters on source_extension_id IS NULL)
  • The Sounds Panel shows an extension badge (source_extension_name) on bundled sounds and provides a filter dropdown with All / Uploaded / one entry per contributing extension
  • Installing or uninstalling an extension broadcasts a sound:list:updated event for live sync in the editor

Sound limits for extensions

Each extension has a configurable limit on how many sounds it can bundle. The limit resolves in priority order:

  1. Per-extension override — set by admin on the extension itself (extensions.max_sounds)
  2. Per-developer override — set by admin on the developer profile (developer_limit_overrides with key max_sounds_per_extension)
  3. Platform defaultDEFAULT_MAX_SOUNDS_PER_EXTENSION = 50 sounds per extension

The resolver returns the source alongside the value (extension_override / developer_override / default), which is what the admin UI shows next to each limit.

Two sibling per-extension limits resolve the same way, with the same three-level priority: max_sound_file_size (default 10 MB) and max_sound_storage_bytes (default 200 MB). A max_sounds_per_extension override is capped at 500.

System extensions install with skip_limits = true, so none of the three limits is resolved or applied to them. For a regular extension that declares more sounds than its resolved count limit, the install logs a warning and truncates the list rather than failing.

The lumio deploy CLI checks the resolved limit before uploading. The server validates the count again during the upload and at install time (defense in depth).

Declaring sounds in lumio.config.json

{
"sounds": [
{ "file": "alert.mp3", "name": "Alert" },
{ "file": "chime.wav", "name": "Chime", "default_volume": 0.8 }
]
}
FieldTypeRequiredDescription
filestringyesRelative path in dist/sounds/. 1-255 characters.
namestringyesDisplay name. 1-255 characters.
default_volumenumbernoDefault volume (0.0-1.0).

Supported formats: MP3, WAV, OGG, WebM, M4A, AAC, FLAC.

Referencing sounds in config

Use a config field of type sound to let users pick from the sound library:

{
"config_schema": [
{
"key": "alertSound",
"type": "sound",
"label": "Alert sound"
}
]
}

The SchemaEditor renders a dropdown picker listing all sounds in the account's library (including extension-bundled sounds). The stored value is a sound ID (UUID string).

API

GraphQL

OperationGuardsDescription
sounds(offset: Int! = 0, limit: Int! = 20, search: String): SoundsResult!feature:sounds + sounds:readList sounds with pagination and search. Returns the rows plus the resolved plan limits (totalCount, userSoundCount, maxSounds, maxSoundFileSize, maxSoundStorageBytes, usedStorageBytes).
sound(id: UUID!): SoundGqlfeature:sounds + sounds:readGet a single sound
updateSound(id: UUID!, input: UpdateSoundInput!): SoundGqlfeature:sounds + sounds:editUpdate name, duration and waveform
deleteSound(id: UUID!): Boolean!feature:sounds + sounds:deleteDelete a sound
playSound(id: UUID!, volume: Float! = 1.0, target: SoundTargetInput): Boolean!feature:sounds + sounds:playTrigger playback
stopSound(id: UUID!, target: SoundTargetInput): Boolean!feature:sounds + sounds:playStop playback

SoundGql fields: id, accountId, name, filename, contentType, sizeBytes, durationMs, waveform, createdAt, sourceExtensionName.

There is no GraphQL upload mutation — creating a sound is REST-only because the payload is multipart.

See GraphQL for the full schema.

REST

MethodPathGuardsDescription
GET/v1/soundsfeature:sounds + sounds:readList sounds (offset, limit, search query params)
GET/v1/sounds/{id}feature:sounds + sounds:readGet single sound
POST/v1/soundsfeature:sounds + sounds:createUpload sound (multipart: file required, name optional — defaults to the filename without its extension)
PATCH/v1/sounds/{id}feature:sounds + sounds:editUpdate metadata
DELETE/v1/sounds/{id}feature:sounds + sounds:deleteDelete sound. Returns 204.
POST/v1/sounds/{id}/playfeature:sounds + sounds:playTrigger playback (body: volume, target)
POST/v1/sounds/{id}/stopfeature:sounds + sounds:playStop playback (body: target)
GET/v1/sounds/{id}/streamaccount ownership onlyStream audio bytes

GET /v1/sounds/{id}/stream deliberately carries no RBAC check: it has to be readable by Widget and Overlay tokens, which hold no account-level permissions. Access is bounded by the query itself, which filters on the caller's account_id. Because the payload is account-scoped and carries no Vary, responses are served with Cache-Control: private, max-age=86400, immutable — cacheable long-term in the caller's own browser, but never in a shared/CDN cache that could serve one account's sound to another.

REST payloads are snake_case (account_id, content_type, size_bytes, duration_ms, source_extension_name, total_count, user_sound_count, max_sounds, used_storage_bytes).

These routes are registered under /v1 but carry no utoipa::path annotations, so they are not present in apps/api/openapi.json — the table above is the contract.

WebSocket

Subscribe to sounds:{accountId} to receive real-time playback commands. The gate is ChannelGate::AccountScoped plus the feature:sounds flag (channel_gate_for / channel_feature_for in crates/lo-websocket/src/gate.rs) — not a sounds:read permission check. Account-scoping rather than RBAC is what lets a Widget or Overlay token subscribe, while still confining every identity to its own account's channel.

See WebSocket for the channel protocol.

Key files

PathDescription
apps/api/src/routes/sounds.rsREST endpoints (list, get, upload, update, delete, play, stop, stream)
apps/api/src/graphql/sounds.rsGraphQL queries and mutations
apps/api/src/db/sounds.rsDatabase operations, get_resolved_sound_limits
apps/api/src/services/bundled_sounds.rsProvisioning of extension-bundled sounds at install time
apps/api/src/db/developer_extensions.rsresolve_max_sounds and the per-extension sound-limit defaults
crates/lo-websocket/src/gate.rssounds channel gate (AccountScoped) + feature:sounds
apps/web/src/components/editor/sounds-panel.tsxSounds Panel (library, filter, playback)
apps/web/src/components/editor/sound-upload-modal.tsxUpload dialog
apps/web/src/components/sound-select.tsxsound-type config-field picker
apps/api/migrations/20260528000007_sounds.up.sqlSchema migration + plan/account limit columns