No description
  • Go 64.6%
  • Svelte 20.6%
  • TypeScript 14.5%
  • CSS 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Angus Kelsey 1fdd0d72e8 Keep the phone awake, and stop other guilds erasing presence
The board now requests a Screen Wake Lock while the page is visible, releases it
when hidden, and reacquires it on visibility/first input. Unsupported browsers
are a no-op.

The voice presence listener was per bot client, but a bot token receives guild
events for every guild it is in. OnGuildReady replaces the per-server member
map, so the last guild to load could erase members already sitting in this
server's channel; a member who never changed state afterwards stayed invisible.
Both GuildReady and GuildVoiceStateUpdate are now filtered to the configured
guild. A restart reseeds the presence and deNs appears.
2026-10-20 13:02:56 +10:30
.forgejo/workflows Add a user guide and make the CI sample an inert template 2026-10-17 22:21:19 +10:30
apps/web Keep the phone awake, and stop other guilds erasing presence 2026-10-20 13:02:56 +10:30
cmd/orebuoys Desktop pairing: approve a client in the browser instead of minting a token 2026-10-18 17:57:47 +10:30
deploy/kubernetes Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
docs One editor: timeline recipes, save, preview and render 2026-10-19 10:54:39 +10:30
internal Keep the phone awake, and stop other guilds erasing presence 2026-10-20 13:02:56 +10:30
.dockerignore Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
.env.example Personal normalisation, PLAY AGAIN, disableable cooldown, and a bulk tag table 2026-10-17 22:06:49 +10:30
.gitignore Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
.npmrc Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
AGENTS.md Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
compose.yaml Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
Dockerfile Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
go.mod Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
go.sum Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
LICENSE Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
Makefile Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
package.json Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
pnpm-lock.yaml Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
pnpm-workspace.yaml Orebuoys Soundboard: initial public release 2026-10-17 20:19:18 +10:30
README.md Desktop pairing: approve a client in the browser instead of minting a token 2026-10-18 17:57:47 +10:30
SPEC.md One editor: timeline recipes, save, preview and render 2026-10-19 10:54:39 +10:30
user-guide.md One editor: timeline recipes, save, preview and render 2026-10-19 10:54:39 +10:30

Orebuoys Soundboard

A self-hosted Discord soundboard. Members sign in with Discord, browse, search and tag a library of short audio clips in a compact web UI, and drop them into a Discord voice channel through one bot. A second switch keeps clicks local to the browser so nobody spams the channel by accident.

The library is the point: clips are indexed where they live, tagged from their filename prefix on import, and curated tags can be added on top. The backend is Go and the frontend is a SvelteKit static build embedded into the same binary, so one process serves the JSON API, the audio and the UI on one port.

New here? The user guide walks through using the board — servers, libraries, playing, capture, editing and administration. This README is for running it.

Features

  • Library index. Walks a folder recursively, probes durations with ffprobe, derives a tag from each filename prefix, and skips unchanged files on a rescan. A file that disappears from disk is hidden rather than deleted, so its play history survives.
  • Board UI. Search, tag chips (any/all), sorts including a seeded random, several view modes and page sizes, favourites, pinned tags, saved sections, browser preview, and tag editing on one clip or across a selected set in one atomic request.
  • Playback into Discord voice. A FIFO queue feeds one mixed Opus stream per server. Up to MAX_CONCURRENT_PLAYS clips play at once (default 3; 1 restores strict serialisation), and a play request may ask to overlap the queue within its own ceiling. DAVE end-to-end encryption is provided by a pure-Go session, so the binary stays static.
  • Authentication. Discord OAuth2 with PKCE, server-side sessions stored hashed, guild membership verified with the caller's own token and rechecked periodically. Admin access is an allowlist of numeric Discord user IDs; roles granted on the board can add narrower permissions without making somebody an admin.
  • Multi-server libraries. Libraries are first-class and independent of servers, with two independent grants per server: play (list, hear, queue) and import (upload, import, edit). One server can play several libraries; one library can be shared with several servers. The board shows one server at a time, and every clip action is checked against that server's grants.
  • Upload, import and editing. Admin upload with two-pass FFmpeg loudness levelling, directory import driven through the same API, clip composition, and voice capture with per-speaker lanes.
  • API and automation. Every action available in the UI is available to an authenticated principal, including personal access tokens that carry their owner's capabilities. Desktop apps pair through a short-lived code approved in the browser, so nobody copies a token. The OpenAPI document is served at /api/openapi.json.

Requirements

  • Go 1.26 to build the server.
  • Node 22 with pnpm 10 to build the SPA.
  • ffmpeg and ffprobe on PATH at runtime.
  • A Discord application and bot for anything beyond development mode.

The produced binary is static (CGO_ENABLED=0) when built with the provided Makefile or Dockerfile.

Discord application setup

Create an application in the Discord Developer Portal, add a bot, and copy the application ID, client secret and bot token. Enable no privileged intents: the bot never reads messages and membership is checked over REST with the user's own OAuth token.

Add this redirect URI on the OAuth2 page, replacing the host with the address the board is served from:

https://soundboard.example.com/auth/discord/callback

The app requests the identify and guilds.members.read scopes and uses the authorization code flow with state and PKCE (S256). Invite the bot with View Channel, Connect and Speak in the target voice channel. The full walkthrough, including the permissions integer and the invite URL, is in docs/discord-setup.md.

Quick start with Docker Compose

The default compose service runs a development instance: no Discord credentials, requests handled as a synthetic admin, and fake play counts so sorting has something to show. It is refused at startup when PUBLIC_BASE_URL is https.

cp .env.example .env
mkdir -p library          # put audio clips here, or set LIBRARY_PATH
docker compose up --build

Open http://localhost:3000. The first start indexes the library folder; on later starts unchanged files are skipped by size and mtime.

The compose service is deliberately a development instance: DEV_NO_AUTH is set to true in compose.yaml. For a real deployment, run the image with DEV_NO_AUTH=false, fill in the Discord values, and terminate TLS in front of it; the Kubernetes example in deploy/kubernetes shows the full set.

Configuration

Everything is environment-driven; there is no config file. .env.example is the contract and internal/config/config.go is the authority. The settings that matter most:

Variable Purpose
PUBLIC_BASE_URL Public origin. The OAuth redirect URI is derived from it, and an https value refuses development auth.
BOARD_TITLE Name shown in the tab, header and login page. Defaults to Soundboard.
LIBRARY_DIR Folder holding the clips. Read-only is the intended mount.
SQLITE_PATH SQLite database file.
CACHE_DIR Uploads, levelled renditions and waveform caches. Back this up.
DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN Discord application credentials.
DISCORD_GUILD_ID, DISCORD_VOICE_CHANNEL_ID Seed server and voice channel for a first run. The board learns further servers from the bot.
ADMIN_DISCORD_IDS Comma-separated numeric user IDs. Usernames are ignored.
SESSION_SECRET Session signing material. Generate with openssl rand -base64 48 and keep it stable.
DEV_NO_AUTH, ALLOW_ANY_ORIGIN Development relaxations. Both are refused on an https public URL.
MAX_CLIP_SECONDS, MAX_UPLOAD_BYTES, MAX_QUEUE_LENGTH, MAX_CONCURRENT_PLAYS, MAX_OVERLAP_PLAYS, PLAY_COOLDOWN_MS Ingest and playback limits.
TRUST_PROXY Trust X-Forwarded-* only when a reverse proxy you control sets them.

A bad configuration exits at startup and lists every problem at once.

Local development

make run                       # builds the SPA, then serves a dev instance
make run LIBRARY_DIR=/path/to/clips
./bin/orebuoys --dev           # the same thing spelled out

--dev (or DEV_MODE=true) turns on development mode: no Discord login, a relaxed cross-origin guard and trusted forwarded headers. It exists to shape the UI, not to be deployed.

For the SPA with hot reload:

pnpm install
pnpm dev:web                   # Vite on :5173, proxying /api to the Go process

Point the proxy elsewhere with SOUNDBOARD_API=http://127.0.0.1:9994 pnpm dev:web when the API is not on port 3000.

Tests and checks

make test          # go test ./... plus the SPA unit tests
make lint          # gofmt check, go vet, ESLint and Prettier check

The Go tests cover auth boundaries, cross-origin guards, path traversal, the queue, dedupe, play counting, role escalation and ingest validation. Discord login and voice cannot be exercised in CI; those paths are tested against fakes and must be verified against a real guild.

.forgejo/workflows/ci.yaml.example is a template, deliberately inert because Forgejo only runs files ending in .yaml/.yml. Copy it to .forgejo/workflows/ci.yaml and set runs-on: to a runner label that exists on your instance; ubuntu-latest is the GitHub convention and queues forever on a self-hosted Forgejo with different labels.

Deployment sketch

The container is a static Go binary on debian:bookworm-slim with ffmpeg. The SPA is embedded at build time, so the image needs no Node runtime. Run one replica: the playback queues are in-process and SQLite is a local file. Terminate TLS at a reverse proxy and expose only the HTTP port. Discord voice needs outbound TCP 443 and outbound UDP to the voice server; a policy that allows HTTPS only will let the bot join and then fail to send audio.

Persist the database and the cache volume together. LIBRARY_DIR is either a read-only mount that is indexed in place or an empty folder inside the data volume filled through the API.

The Kubernetes example and its operator notes cover the shape; docs/deployment.md explains why it is one replica.

Repository layout

Path Owns
cmd/orebuoys Entry point and the push import client.
internal/config Environment parsing and validation.
internal/store Every SQL statement and the embedded migrations.
internal/library Filename conventions, ffprobe and the recursive index.
internal/ingest Upload, normalisation, composition and the background worker.
internal/httpapi JSON API, guards, sessions, playback routes and SPA serving.
internal/auth OAuth2, sessions, membership, roles and tokens.
internal/voice Queue, mixer, bot and the disgo/DAVE connection.
internal/capture Voice capture, snapshots and WAV/Ogg helpers.
internal/realtime, internal/limit, internal/prefs, internal/ops Websocket fan-out, rate limiting, per-user preferences and the integrity check.
internal/static The built SPA, embedded at build time.
apps/web SvelteKit static SPA.
deploy/kubernetes Generic manifest and operator notes.
docs Discord setup, API, permissions, capture and deployment design notes.

Licence

AGPL-3.0. See LICENSE.