The Soundboard lets an operator register named audio clips in the server web interface and trigger them — from the dashboard or from the Steam Deck controller — to play on a robot node's speaker.
It lives under Boards → Sounds in the web UI, alongside Animations and Poses.
A sound belongs to one node — the node that plays it.
- The audio file must already exist on that node's own storage (SD card / internal disk). Files are not copied between nodes or uploaded from your browser.
- Playback comes out of that node's selected audio output (ALSA device), defaulting to the node's default output.
- Only one clip plays per node at a time: triggering a new sound stops whatever that node was already playing (stop-and-replace).
Because playback is python-vlc → ALSA, sounds can only be assigned to Raspberry Pi nodes (the only node type that runs VLC).
The node that will play sound needs VLC and ALSA tools installed. On Raspberry Pi OS:
sudo apt install vlc-bin libvlc-dev python3-vlc alsa-utilsvlc-bin/libvlc-dev/python3-vlc— the playback engine.alsa-utils— providesaplay, used to enumerate output devices.
Plug in / confirm your speaker or amp. If it's a USB DAC, HDMI, or an I²S
HAT, make sure it shows up in aplay -l on the node.
These are already present on a standard SAINT.OS Pi image; you only need this step on a hand-built node.
Copy the clips you want onto the node's filesystem. Any location works — the file picker browses the whole filesystem — but a dedicated folder is tidiest, e.g.:
mkdir -p /var/lib/saint-os/audio
# copy fanfare.mp3, hello.wav, … into itSupported formats are anything libVLC handles: .wav, .mp3, .ogg,
.flac, .aac, .m4a, .opus.
The node must be adopted in SAINT.OS (Nodes page) and online. Only adopted Raspberry Pi nodes appear in the sound editor's node picker.
- Open Boards in the top navigation.
- In the left sidebar under Sounds, click New Sound.
- Fill in the modal:
- Icon / Name — how the entry appears in the list and on the controller.
- Node — which Pi will play it.
- File — click Browse to open the node's file picker, navigate to your clip, and select it. (Tick Show all files if a file isn't detected as audio.)
- Output device — the node's speaker/output. Leave on Node default output unless you have multiple outputs.
- Volume — 0–100 %.
- Start time — seconds to skip at the start of the clip.
- Loop — off = play once. On = repeat forever, or untick Loop forever and set a play count.
- Group — optional; groups appear as sub-folders in the Sounds sidebar (like pose groups).
- Click Create.
- Reorder within a group with the ▲/▼ buttons on each row. This order is what the Steam Deck controller shows.
- Rename / regroup inline by clicking the name or the group field.
- Delete with the trash button.
- From the dashboard: click ▶ on a sound row to play it, ■ to stop that node. Errors (missing file, offline node) surface as a message under the list.
- From the Steam Deck controller: press START to open the Sounds panel. It lists every sound on the server in your saved order; select one to play it. (The panel shows Connect to the robot… / Loading… / No sounds saved until the list arrives.)
Three tiers cooperate; the server is the hub.
┌── Web UI / Steam Deck ──┐ ┌── Server (ROS2 + web) ──┐ ┌── Pi node ──┐
│ Boards → Sounds │ WS │ SoundStore (JSON) │ ROS │ soundboard │
│ New Sound / ▶ / reorder │ ─────▶ │ websocket_handler │ ─────▶ │ .py │
│ Steam Deck START panel │ │ server_node publishers │ /command│ VLC → ALSA │
└─────────────────────────┘ └─────────────────────────┘ └─────────────┘
▲ library-sounds / soundboard_fs|devices|result (keyed by request_id) │
└───────────────────────────────────────────────────────────────────────┘
Each sound is one JSON file at {config_dir}/sounds/<slug>.json
(/etc/saint-os/sounds/ in production). Managed by SoundStore
(server/saint_server/animation/store.py), mirroring PoseStore but with
an explicit position field; list() returns entries sorted by
(group, position, name).
management actions handled in
server/saint_server/webserver/websocket_handler.py:
| Action | Purpose |
|---|---|
list_sounds |
summaries for the list / controller panel |
get_sound / save_sound / delete_sound |
CRUD |
reorder_sounds {ordered_ids} |
rewrite position |
sound_list_nodes |
adopted Pi nodes for the node picker |
sound_list_dir {node_id, path, request_id} |
browse node filesystem (async) |
sound_list_devices {node_id, request_id} |
enumerate node ALSA outputs (async) |
play_sound {id} |
resolve entry → play on its node |
stop_sound {node_id} |
stop that node's current clip |
The server publishes a JSON command on /saint/nodes/<id>/command
(RELIABLE QoS) via server_node.send_soundboard_command():
{ "action": "soundboard_play",
"request_id": "sb-…",
"args": { "path": "/var/lib/saint-os/audio/fanfare.mp3",
"device": "default", "volume": 0.8,
"start_time_s": 0.0, "loop": false, "loop_count": 0 } }action is one of soundboard_list_dir, soundboard_list_devices,
soundboard_play, soundboard_stop.
The node replies on dedicated topics, echoing the request_id so the
caller can correlate the response (the same async pattern as BLE scan):
| Reply topic | For |
|---|---|
/saint/nodes/<id>/soundboard_fs |
directory listings |
/saint/nodes/<id>/soundboard_devices |
ALSA device lists |
/saint/nodes/<id>/soundboard_result |
play/stop {status, message} |
server_node re-broadcasts each reply to WebSocket subscribers as
soundboard_fs/<node_id> etc. The web store
(server/web/src/stores/sounds.js) subscribes to the topic, fires the
request with a fresh request_id, and resolves on the matching frame.
firmware/raspberrypi/saint_node/soundboard.py:
list_directory(path)— dirs + files, flags audio by extension.list_audio_devices()— parsesaplay -l; always includesdefault.SoundboardPlayer— one voice per node. Caches avlc.Instanceper ALSA device (the output device is an Instance-level argument), creates a freshMediaPlayerper play, appliesstart-timeandinput-repeat(finite =loop_count − 1; infinite = a large sentinel) media options, sets volume, and stops+releases any prior player first.
The Steam Deck's Sounds panel is source: 'sounds'
(controller/src/composables/useBindings.ts). useLibrary fetches via
invoke('list_sounds') and listens for the library-sounds Tauri event;
selecting an item calls invoke('play_sound', {id}). The Rust side
(src-tauri/src/protocol/) forwards these to the same server WebSocket
actions listed above.
| Field | Meaning |
|---|---|
name, icon |
display in list + controller |
group |
sidebar grouping ("" → Ungrouped) |
node_id |
node that plays it |
file_path |
absolute path on that node |
output_device |
ALSA device id (""/default → node default) |
volume |
0.0–1.0 |
start_time |
seek offset (seconds) |
loop |
repeat when true |
loop_count |
total plays when looping; 0 = infinite |
position |
explicit order within its group |
- No nodes in the picker — the node must be an adopted, online Raspberry Pi. Teensy/RP2040 nodes can't play audio.
- Browse/device list spins then errors — the node didn't respond
within 15 s: it's offline, or
aplay/VLC isn't installed. - Play does nothing / "VLC refused to play" — check the file still
exists at that path on the node, the format is supported, and the chosen
output device is the one your speaker is on. Test on the node directly
with
cvlc --play-and-exit <file>. - Wrong speaker — pick a specific Output device instead of Node
default; confirm the target shows in
aplay -l. - Clip won't stop — Stop is per node; if two dashboards are open, the last play wins. Press ■ again.
The Pi-side code (soundboard.py, node.py) ships through the normal
SAINT.OS dist build + install — not by copying files onto the Pi. After
updating node code, rebuild/redeploy the node package so the running node
picks up the new handlers.