No description
  • Rust 99.1%
  • Dockerfile 0.5%
  • HTML 0.4%
Find a file
Vincent Sidot 254493a6b9 feat!: rewrite the soundboard in Rust, with accounts and legacy adoption
Replace the Python/Vue application with a Cargo workspace: an Axum REST API
that owns commands and durable queue state, and a Leptos CSR frontend that
turns an open browser into the playback device. A REST call can never produce
sound on its own, and a play is only counted once the browser acknowledges
that audio started.

The website and the future MCP adapter speak the same /api/v1 contract, so
core operations stay explicit Axum handlers rather than Leptos server
functions. Shared DTOs live in crates/contracts and compile into both sides.

Authentication
--------------
Argon2id passwords, HttpOnly SameSite=Lax session cookies for the browser, and
"Authorization: Bearer sb_..." tokens for scripts. Every credential is a random
hex secret, disclosed once, stored only as its SHA-256. Accounts come solely
from single-use invite links; passwords are reset the same way, since there
are no email addresses. Protection is carried by CurrentUser/AdminUser
extractors on each handler rather than a path list that can drift from the
router. Ownership is always the authenticated caller: created_by and user_id
are gone from request bodies, and POST /users is gone entirely.

Legacy adoption
---------------
On startup the server detects a previous library and queues one import job per
file, so a 319-file folder never delays boot. A folder beside the old Python
soundboard.db carries titles, tags, enabled flags, timestamps and play counts
across; a bare folder falls back to filenames. The originals are never moved
or deleted, adoption is recorded so it cannot run twice, an interrupted run
resumes, and a file whose normalized audio already exists is folded into that
sound. Historic plays land in sounds.legacy_play_count, apart from
play_events, so acknowledged-play statistics stay honest.

Notable fixes found while building this
---------------------------------------
- wasm-bindgen strips the target_features section, so wasm-opt rejected the
  bulk-memory ops rustc emits; trunk build --release had never succeeded.
- The board was empty on first paint: refresh() was only wired to the
  WebSocket handler, so nothing fetched sounds until a server event arrived.
- ServeDir::not_found_service forces a 404 onto the response, so the SPA shell
  and every unknown /api/v1 path returned HTML with a 404.
- REST stop left the session stuck in "playing"; the browser acknowledges
  nothing for a stop.
- A late "completed" ack could revive a cancelled queue item and inflate
  statistics, because the ack matched on command_id with no status guard.
- request_play credited the play to the session owner, not the requester.
- Uploads were titled after their temp path, leaking a ULID into the title,
  and a duplicate upload stranded its temp file.
- The import worker slept 500ms between every job.

23 integration tests cover the playback state machine over a real WebSocket,
the auth surface, and legacy detection, metadata carry-over and idempotency.
The vertical slice and the full auth flow were verified in real Chrome.

Known gaps are recorded in status.md. The largest are: no rate limiting on
login, the Safari audio-unlock design, and an unbuilt Docker image.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 17:19:48 +02:00
crates feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00
data feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00
migrations feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00
.dockerignore feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00
.env.example feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00
.gitignore feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00
Cargo.lock feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00
Cargo.toml feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00
docker-compose.yml feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00
Dockerfile feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00
README.md feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00
status.md feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00
Trunk.toml feat!: rewrite the soundboard in Rust, with accounts and legacy adoption 2026-07-10 17:19:48 +02:00

Soundboard

A clean-sheet, browser-native soundboard written in Rust. Axum exposes the public REST API and coordinates durable playback queues; a Leptos WebAssembly application turns an open browser into an audio playback session.

The website uses the same /api/v1 contract intended for automation and a future MCP adapter. There are no native clients, hardware agents, daemon modes, or platform-specific sound controls.

Current foundation

  • Leptos CSR dashboard built from scratch
  • Axum REST API with generated OpenAPI 3.1 document
  • browser playback sessions over WebSocket
  • immediate, enqueue, play-next, stop, remove, reorder, and clear queue semantics
  • playback acknowledgements; statistics count audio only after the browser reports started
  • SQLite persistence through SQLx migrations
  • password login with HttpOnly browser sessions and bearer API tokens
  • invite-only signup, admin user management, and single-use password reset links
  • automatic adoption of a previously installed soundboard library
  • upload, import-directory, MyInstants, and YouTube import jobs
  • ffmpeg normalization to browser-compatible MP3
  • YouTube start/end trimming
  • sound search, metadata editing, and deletion endpoints
  • single-container deployment

Every /api/v1 endpoint except health, openapi.json, and the login/invite/reset routes requires authentication. Scoped API-token permissions and rate limiting are the next security milestones; run behind TLS and set SOUNDBOARD_COOKIE_SECURE=1 before exposing it.

Requirements

  • Rust 1.93+
  • wasm32-unknown-unknown Rust target
  • Trunk
  • ffmpeg and ffprobe
  • yt-dlp for YouTube imports
rustup target add wasm32-unknown-unknown
cargo install --locked trunk

First run

Nobody can sign in until an administrator exists. Seed one from the environment:

SOUNDBOARD_ADMIN_USERNAME=admin \
SOUNDBOARD_ADMIN_PASSWORD='a-long-password' \
cargo run -p soundboard-server

The password is applied when the account is created, or when it has none. It is not reapplied on every boot, so a password changed in the UI survives a restart. Set SOUNDBOARD_ADMIN_PASSWORD_FORCE_RESET=1 to force it back — the recovery path when the password is lost.

Everyone else joins through a single-use invite link that an administrator generates under Admin → Invite someone. Passwords are reset the same way.

Configuration

Variable Default Purpose
SOUNDBOARD_ADDRESS 127.0.0.1:8420 Listen address
SOUNDBOARD_DATA_DIR ./data Database, media, imports, temp
SOUNDBOARD_WEB_DIR ./dist Built frontend
SOUNDBOARD_ADMIN_USERNAME admin Seeded administrator
SOUNDBOARD_ADMIN_PASSWORD Required for anyone to sign in
SOUNDBOARD_ADMIN_PASSWORD_FORCE_RESET 0 Reapply the password on boot
SOUNDBOARD_SESSION_TTL_DAYS 30 Browser session lifetime
SOUNDBOARD_COOKIE_SECURE 0 Send the session cookie over HTTPS only
SOUNDBOARD_LEGACY_DIR $DATA/sounds Previous library to adopt
SOUNDBOARD_LEGACY_DB $DATA/soundboard.db Its metadata database, if any
SOUNDBOARD_LEGACY_MIGRATION 1 Set to 0 to never adopt anything

Migrating a previous soundboard

On startup the server looks for a legacy folder. If it finds audio files it queues one import job per file and lets the normal worker convert them in the background, so a large library never delays boot and progress is visible under Imports.

Two shapes are detected automatically:

  • legacy_v2 — the folder sits beside the old Python soundboard's soundboard.db. Titles, tags, enabled flags, timestamps, and play counts are carried across. Historic plays land in a separate legacy_play_count so that acknowledged-play statistics stay honest.
  • plain_directory — a bare folder of audio files. Titles come from filenames.

The originals are never moved or deleted. Adoption is recorded, so it never runs twice, and an interrupted run resumes where it stopped. A file whose normalized audio already exists is folded into the existing sound rather than duplicated.

Development

Run the API on port 8420:

cargo run -p soundboard-server

In another terminal, run the Leptos development server. Trunk.toml proxies REST and WebSocket traffic to Axum:

trunk serve --open

For a production-style local run:

trunk build --release
cargo run --release -p soundboard-server

The application is available at http://127.0.0.1:8420. The OpenAPI document is available at http://127.0.0.1:8420/api/v1/openapi.json.

REST API

Important endpoints:

GET    /api/v1/health                       (public)
GET    /api/v1/openapi.json                 (public)

POST   /api/v1/auth/login                   (public)
POST   /api/v1/auth/logout
GET    /api/v1/auth/me
POST   /api/v1/auth/password
GET    /api/v1/auth/invite/{token}          (public)
POST   /api/v1/auth/signup                  (public)
GET    /api/v1/auth/reset/{token}           (public)
POST   /api/v1/auth/reset                   (public)
GET    /api/v1/auth/tokens
POST   /api/v1/auth/tokens
DELETE /api/v1/auth/tokens/{token_id}

GET    /api/v1/users                        (admin)
PATCH  /api/v1/users/{user_id}              (admin)
DELETE /api/v1/users/{user_id}              (admin)
POST   /api/v1/users/{user_id}/reset-link   (admin)
GET    /api/v1/invites                      (admin)
POST   /api/v1/invites                      (admin)
DELETE /api/v1/invites/{invite_id}          (admin)

GET    /api/v1/sounds
GET    /api/v1/sounds/{sound_id}
PATCH  /api/v1/sounds/{sound_id}
DELETE /api/v1/sounds/{sound_id}
GET    /api/v1/sounds/{sound_id}/audio

GET    /api/v1/sessions
POST   /api/v1/sessions
GET    /api/v1/sessions/{session_id}
POST   /api/v1/sessions/{session_id}/play
POST   /api/v1/sessions/{session_id}/stop
GET    /api/v1/sessions/{session_id}/queue
DELETE /api/v1/sessions/{session_id}/queue
PUT    /api/v1/sessions/{session_id}/queue/reorder
DELETE /api/v1/sessions/{session_id}/queue/{item_id}

GET    /api/v1/imports
GET    /api/v1/imports/{import_id}
POST   /api/v1/imports/upload
POST   /api/v1/imports/myinstants
POST   /api/v1/imports/youtube
POST   /api/v1/imports/scan

GET    /api/v1/statistics/overview
GET    /api/v1/statistics/sounds
WS     /api/v1/ws?session_id={session_id}

Calling the API

The website authenticates with an HttpOnly cookie. Scripts and the future MCP adapter use a bearer token, created under Account → API tokens. The secret is shown once.

curl -X POST http://127.0.0.1:8420/api/v1/sessions/SESSION_ID/play \
  -H "authorization: Bearer $SOUNDBOARD_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"sound_id":"SOUND_ID","mode":"interrupt"}'

Queue instead by changing mode to enqueue or play_next.

A play still only counts once the browser acknowledges that audio started; a REST call alone never moves the statistics.

Import directory

Files copied into data/imports/ remain available as an explicit intake path. Queue them with:

curl -X POST http://127.0.0.1:8420/api/v1/imports/scan \
  -H "authorization: Bearer $SOUNDBOARD_TOKEN"

Imports are durable jobs. The in-process worker claims pending jobs, runs the media tools, stores normalized files under data/media/, and records success or failure in SQLite.

Quality checks

cargo fmt --all -- --check
cargo test
cargo clippy --workspace --all-targets -- -D warnings
cargo check  -p soundboard-web --target wasm32-unknown-unknown
cargo clippy -p soundboard-web --target wasm32-unknown-unknown -- -D warnings
trunk build --release

Docker

cp .env.example .env      # then set SOUNDBOARD_ADMIN_PASSWORD
docker compose up --build

The container listens on 8420 and is published on 40888. The ./data bind mount holds soundboard-v3.db, media/, imports/, temp/, and — on first boot — the legacy sounds/ and soundboard.db that get adopted in the background.

SOUNDBOARD_ADMIN_PASSWORD is required; compose refuses to start without it. Put it in .env, which is gitignored — never in docker-compose.yml. Set SOUNDBOARD_COOKIE_SECURE=1 once the service is behind an HTTPS reverse proxy.

Without compose:

docker build -t soundboard .
docker run --rm -p 40888:8420 -v "$PWD/data:/app/data" \
  -e SOUNDBOARD_ADMIN_PASSWORD='a-long-password' soundboard