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:
| Limit | Plan column | Free | Pro |
|---|---|---|---|
| Max sounds | max_sounds | 10 | 50 |
| Max sound file size | max_sound_file_size | 5 MB | 20 MB |
| Max total sound storage | max_sound_storage_bytes | 100 MB | 1 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 carriessound_id,stream_path(/api/sounds/{id}/stream),name, the resolvedvolume, andtarget.sound:stop— stop playback of a specific sound. The payload carriessound_idandtarget.
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
targetobject. 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
| Permission | Description | Owner | Admin | Mod | Viewer |
|---|---|---|---|---|---|
sounds:read | View the sound library | x | x | x | x |
sounds:create | Upload new sounds | x | x | ||
sounds:edit | Rename and update sounds | x | x | ||
sounds:delete | Delete sounds from the library | x | x | ||
sounds:play | Trigger and stop sound playback | x | x | x |
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/soundspage) - 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:updatedevent 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:
- Per-extension override — set by admin on the extension itself (
extensions.max_sounds) - Per-developer override — set by admin on the developer profile (
developer_limit_overrideswith keymax_sounds_per_extension) - Platform default —
DEFAULT_MAX_SOUNDS_PER_EXTENSION = 50sounds 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 }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
file | string | yes | Relative path in dist/sounds/. 1-255 characters. |
name | string | yes | Display name. 1-255 characters. |
default_volume | number | no | Default 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
| Operation | Guards | Description |
|---|---|---|
sounds(offset: Int! = 0, limit: Int! = 20, search: String): SoundsResult! | feature:sounds + sounds:read | List sounds with pagination and search. Returns the rows plus the resolved plan limits (totalCount, userSoundCount, maxSounds, maxSoundFileSize, maxSoundStorageBytes, usedStorageBytes). |
sound(id: UUID!): SoundGql | feature:sounds + sounds:read | Get a single sound |
updateSound(id: UUID!, input: UpdateSoundInput!): SoundGql | feature:sounds + sounds:edit | Update name, duration and waveform |
deleteSound(id: UUID!): Boolean! | feature:sounds + sounds:delete | Delete a sound |
playSound(id: UUID!, volume: Float! = 1.0, target: SoundTargetInput): Boolean! | feature:sounds + sounds:play | Trigger playback |
stopSound(id: UUID!, target: SoundTargetInput): Boolean! | feature:sounds + sounds:play | Stop 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
| Method | Path | Guards | Description |
|---|---|---|---|
GET | /v1/sounds | feature:sounds + sounds:read | List sounds (offset, limit, search query params) |
GET | /v1/sounds/{id} | feature:sounds + sounds:read | Get single sound |
POST | /v1/sounds | feature:sounds + sounds:create | Upload sound (multipart: file required, name optional — defaults to the filename without its extension) |
PATCH | /v1/sounds/{id} | feature:sounds + sounds:edit | Update metadata |
DELETE | /v1/sounds/{id} | feature:sounds + sounds:delete | Delete sound. Returns 204. |
POST | /v1/sounds/{id}/play | feature:sounds + sounds:play | Trigger playback (body: volume, target) |
POST | /v1/sounds/{id}/stop | feature:sounds + sounds:play | Stop playback (body: target) |
GET | /v1/sounds/{id}/stream | account ownership only | Stream 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
| Path | Description |
|---|---|
apps/api/src/routes/sounds.rs | REST endpoints (list, get, upload, update, delete, play, stop, stream) |
apps/api/src/graphql/sounds.rs | GraphQL queries and mutations |
apps/api/src/db/sounds.rs | Database operations, get_resolved_sound_limits |
apps/api/src/services/bundled_sounds.rs | Provisioning of extension-bundled sounds at install time |
apps/api/src/db/developer_extensions.rs | resolve_max_sounds and the per-extension sound-limit defaults |
crates/lo-websocket/src/gate.rs | sounds channel gate (AccountScoped) + feature:sounds |
apps/web/src/components/editor/sounds-panel.tsx | Sounds Panel (library, filter, playback) |
apps/web/src/components/editor/sound-upload-modal.tsx | Upload dialog |
apps/web/src/components/sound-select.tsx | sound-type config-field picker |
apps/api/migrations/20260528000007_sounds.up.sql | Schema migration + plan/account limit columns |