No description
  • TypeScript 82.9%
  • JavaScript 6.5%
  • Python 3.5%
  • Go 3.4%
  • CSS 1.9%
  • Other 1.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Albert 24364cb385 docs(readme): the routing, the admin split, and everything since
Four things had gone stale and several features were missing entirely.

Corrected: `/` is an entry point that redirects (admin default -> sole
active room -> `/rooms`), not the directory; `/rooms` is the directory
and `/r/<slug>` a room overview. The `/admin` console described here no
longer exists — song administration lives at `/songs`, `/admin` is the
server-settings page. The player no longer has an "arming click": it
starts muted and playing with an unmute control, because a modal in
front of the show was the wrong answer to autoplay policy.

Added: the song library index, bulk upload, MP3+CDG disc ingest, the
self-hosted separation/transcription worker, and casting in both modes.
Each says what it costs — the disc path emits placeholder words, the
self-hosted path is flagged for review, and casting to a speaker will
not put lyrics on the TV.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:46:13 -07:00
.github chore: scaffold mune from vaporware template v1.1.0 (hue 300) 2026-07-22 04:48:56 -07:00
.scratch docs(cdg-ocr): spec + ticket series for reading the words off the discs 2026-08-02 23:41:39 -07:00
backups chore: scaffold mune from vaporware template v1.1.0 (hue 300) 2026-07-22 04:48:56 -07:00
cast feat(queue): the host can start the song over, and step back ten 2026-07-30 23:42:44 -07:00
db feat(ingest): the local queue — schema growth and the Node ingest-worker 2026-07-30 23:04:51 -07:00
docs docs(cast): the video-casting testing rows, judgement calls and tickets 2026-07-31 00:06:11 -07:00
e2e test(cast): render against demo_02, out of render.spec.ts's way 2026-07-31 00:10:08 -07:00
ingest-worker feat(ingest): the local queue — schema growth and the Node ingest-worker 2026-07-30 23:04:51 -07:00
mcp chore: scaffold mune from vaporware template v1.1.0 (hue 300) 2026-07-22 04:48:56 -07:00
observability chore: scaffold mune from vaporware template v1.1.0 (hue 300) 2026-07-22 04:48:56 -07:00
packages feat(cast): offer both cast modes, and say what each one costs 2026-07-31 00:01:38 -07:00
patches chore: scaffold mune from vaporware template v1.1.0 (hue 300) 2026-07-22 04:48:56 -07:00
proxy feat(karaoke): tus resumable uploads — server route, progress rows, proxy timeout (karaoke2 ticket 10) 2026-07-23 22:27:52 -07:00
scripts fix(cdg): measure the palette wipe instead of guessing it from the histogram 2026-07-30 21:10:16 -07:00
tests/magnitude test: retarget the suite off the deleted redirects 2026-07-28 14:08:59 -07:00
web fix(cast): select created_at, or SurrealDB refuses to order by it 2026-07-31 00:13:51 -07:00
worker docs(ingest-worker): name the package this entrypoint actually lives in 2026-07-30 23:45:31 -07:00
.dockerignore chore: exclude nested .env files from the Docker build context at any depth 2026-07-22 14:28:49 -07:00
.editorconfig chore: scaffold mune from vaporware template v1.1.0 (hue 300) 2026-07-22 04:48:56 -07:00
.gitignore feat(ingest-worker): separation, transcription, alignment and the emitter 2026-07-30 16:26:32 -07:00
.npmrc chore: scaffold mune from vaporware template v1.1.0 (hue 300) 2026-07-22 04:48:56 -07:00
.template-migrations chore: scaffold mune from vaporware template v1.1.0 (hue 300) 2026-07-22 04:48:56 -07:00
AGENTS.md test(web): a source-scan guard so orphaned test ids cannot come back 2026-07-30 04:05:23 -07:00
biome.json chore: scaffold mune from vaporware template v1.1.0 (hue 300) 2026-07-22 04:48:56 -07:00
CHANGELOG.md chore: scaffold mune from vaporware template v1.1.0 (hue 300) 2026-07-22 04:48:56 -07:00
CLAUDE.md chore: scaffold mune from vaporware template v1.1.0 (hue 300) 2026-07-22 04:48:56 -07:00
compose.dev.yaml fix(cast): the web app never received its own CAST_* env 2026-07-30 23:05:21 -07:00
compose.external-edge.yaml chore: scaffold mune from vaporware template v1.1.0 (hue 300) 2026-07-22 04:48:56 -07:00
compose.prod.yaml fix(cast): the web app never received its own CAST_* env 2026-07-30 23:05:21 -07:00
compose.yaml feat(cast): profiled compose service, typed env boundary and the availability route 2026-07-30 16:01:56 -07:00
CONTEXT.md docs(cdg-ingest): the operator guide, the two-queue worker, and the honesty note 2026-07-30 13:51:29 -07:00
DESIGN.md docs: the player is not chrome-less — DESIGN §6, ADR-0018/0019 and the map 2026-07-29 20:26:08 -07:00
example.env feat(cast): the cast service streams what the ROOM is playing 2026-07-30 20:41:31 -07:00
LICENSE docs,chore: open-questions review rulings, AGPL-3.0 license, magnitude .env parsing 2026-07-23 03:59:18 -07:00
lighthouserc.cjs chore(lhci): demote legacy-javascript-insight to advisory, like its legacy-javascript sibling 2026-07-22 13:27:49 -07:00
package.json feat(scripts): grant-admin.mjs operational role grant (pnpm admin:grant) 2026-07-22 06:03:09 -07:00
playwright.config.ts test(e2e): fix the harness defects behind the parallel-run failures 2026-07-25 21:47:53 -07:00
pnpm-lock.yaml feat(cast): CastClock over SendspinCore, and the operator procedure for the ±30 ms gate 2026-07-30 16:09:07 -07:00
pnpm-workspace.yaml feat(cast): karaoke-cast Go root with the media-path guard and a PCM stem source 2026-07-30 15:39:15 -07:00
README.md docs(readme): the routing, the admin split, and everything since 2026-08-02 23:46:13 -07:00
STANDARDS.md feat(ui): a back control top-left of Zone A on every page (R7) 2026-07-25 18:21:39 -07:00

mune

A self-hostable karaoke night app for a venue — one stage or many rooms, each with its own live queue, host, and interlude music. Admins upload songs and run the show; singers browse a shared library (hybrid full-text + vector search), queue themselves in the key they sing (the player really pitch-shifts the instrumental), and optionally tip for priority; a big-screen player window renders synchronized word/syllable lyrics over the instrumental. Everything runs on infrastructure you own — Next.js in front, SurrealDB as the database and GraphQL API and token authorizer, Docker Compose around it. No SaaS is required to run the baseline (Music.ai, Stripe, and the embedding provider are optional, env-gated extras).

The three flows

  • Singer (any phone, no install, no password): open the app → you're a guest instantly → browse/search Songs → pick one (with an optional key shift — the app remembers what you usually sing, and the player actually shifts the pitch) → give a stage name → you're in the live Queue. Your first song of the night is free; from the second (within a 12 h "tonight" window) the app asks you to secure your account (email code → passkey) and enable notifications — web push is how the venue calls you to the stage ("you're up next" / "you're up now").
  • Admin (staff, granted via pnpm admin:grant <email>): /songs is the song library — every song sorted by what needs a human, each row carrying its status and, for a refused ingest, the reason, one tap from the editor. Add songs one at a time or in bulk (drop a pile of files; MP3+CDG pairs are matched by filename and nothing you selected is ever dropped silently), ingest them through Music.ai, a karaoke disc, or a self-hosted pipeline, and retime them in the WYSIWYG lyrics editor. /admin is the server-settings page — room-creation policy, the default room, and the embeddings backfill. Capability is ADDITIVE, never a parallel UI: a room host drives their night from the room's own queue (/r/<room>/queue) — the same page every singer sees, which simply grows the host's controls: drag to reorder (a one-time nudge — pinning is a separate explicit toggle), transport (play / pause / skip), a per-room interlude playlist, and a share-this-room link.
  • Player window (the venue's big screen): open /r//player full-screen. It follows the room's playback state live — pitch-shifted instrumental audio, the active word highlighted syllable-by-syllable, a break-cue countdown through instrumental gaps, interlude music between singers, and an ambient canvas visualization behind a blurred lyrics panel. When a song's audio ends it auto-advances to the next singer (paused). It starts muted and playing — browsers refuse un-gestured audible autoplay, so an unmute control sits at the bottom until you tap it, and never a modal in front of the show. The app nav stays unless you take it fullscreen. From here you can also cast: audio to a speaker (follows the queue, lyrics stay on this screen) or a rendered video to a TV (lyrics on the TV, one song).

Implemented features

Baseline (always on, exercised by the e2e suite):

  • Anonymous-first identity — guests own real data; upgrade = email OTP → required passkey. No passwords. (docs/auth.md)
  • Admin role — one role field on the user, granted operationally with pnpm admin:grant <email> (--revoke to undo); enforced by server guards and row-level PERMISSIONS ($token.role) in the db.
  • Multi-roomroom is a first-class entity owning its queue, playback, and interlude music; the song library, verified flags, and embeddings stay global. Path-scoped /r/<slug>/… (the slug IS the URL). /rooms is the directory and always lists; / is only an entry point, redirecting to the admin-set default room, else the sole active room, else the directory. /r/<slug> is the room's overview — Queue and Player buttons, live now-playing, and tonight's statistics. Every stage surface lives under its room — there is no venue-level queue or player.
  • Song library (/r/<room>/songs) — one search box with fused ranked results: SurrealDB full-text (edgengram prefix) + a jaro-winkler typo leg, plus a vector leg when embeddings are configured (below); the room-targeted add flow; processing chips + "verified good" badges.
  • Queue self-add — key-shift picker (3…+3, recommendation from your history; the player really shifts the audio, below), a required stage name, and the per-night gate: the first song of a 12 h "tonight" window is free; the second requires account → passkey → ≥ 1 push subscription (push_required — skipped on keyless deployments). Server-side priority scoring (1000 + tip_cents × 2 recent × 250).
  • Live queue (/r/<room>/queue) — room-filtered GraphQL subscriptions, ordered by priority then arrival; own-entry highlight and removal (a status write that crosses every open context live — never a row DELETE); per-singer "first song tonight" / "N songs tonight" badges. Rows are venue-public, but tips are fully private (field-level PERMISSIONS — owner or server admin only; no public boost trace).
  • Directory & room overview (/rooms, /r/<room>) — the directory lists every room with its host, live now-playing line and waiting count, marking the default room and the one you are in; the overview gives a room's now-playing hero, derived up-next, and tonight's counts over the same 12 h window as the priority penalty. Navigation is contextual: an odd-count nav (3 or 5) that keeps Queue dead-centre, with the room's own name as the first item once you are in one.
  • Player (/r/<room>/player) — big-screen stage, keeping the app nav with an opt-in fullscreen control that hides it; follows the room's playback_state over a live query; rAF word/syllable highlighting from timed lyrics_json; real pitch shifting; a break-cue countdown through gaps; interlude rendering; canvas visualization; reduced-motion aware.
  • Roles & host controls — server admin (role = 'admin', operationally granted) vs room host (the room's creator). Admin capability is ADDITIVE, not a parallel UI: /r/<room>/queue grows DnD reorder (a one-time nudge — never auto-pins), an explicit per-row pin toggle, transport (play / pause / start over / back 10s / skip — a restart writes position 0 through the same seek the cast stream follows), the interlude panel and a share-this-room link for a host or admin, and nothing at all for anyone else. /songs is the admin library and /admin the server-settings page; there is no separate console.
  • Song library index (/songs, admin) — every song ordered by what needs a human (awaiting review, awaiting lyrics, failed, refused, processing, ready) with search, a status filter, the refusal reason on the row, and the whole row linking into that song's editor.
  • Bulk upload — select or drop many files at once; .mp3/.flac/ .wav/.m4a/.ogg pair with .cdg by filename stem, metadata is read from tags then the filename and shown in an editable table before anything is created, uploads run capped-concurrent, and one failure never loses the batch. Anything unpaired or unusable is reported with its reason — nothing you selected disappears quietly.
  • MP3 + CDG karaoke discs — drop a commercial disc pair and the media worker decodes the CD+G subcode itself (no OCR, no cloud): it finds the lyric bands, classifies the palette spatially (never by brightness), and reads word timings off the highlight sweep — measured at a 0.33 ms pooled median against the disc's own instruction stream. Phase 1 emits those timings with placeholder words for an operator to type against the disc's own picture of each line; a disc it cannot time honestly is refused with the reason, not silently mistimed.
  • WYSIWYG lyrics editor — the player's own karaoke rendering, synced to a stem, everything editable in place: click-a-word seek, an inspector (text + start/end steppers + stamp-at-playhead), and tap-to-time retiming — word- OR syllable-granular. Save re-validates and re-embeds.
  • Media store — files on disk under MEDIA_DIR, streamed by /api/media/[name] with HTTP Range support (audio seeking); server-named UUID files; resumable tus uploads with real progress (@tus/server, staging dir + atomic rename; size/extension caps).
  • Venue activity feed (/r/<room>/activity) — live room events (queued / now-playing / secured) the caller's own, plus the web-push opt-in.
  • Offline outbox — the activity composer writes through the IndexedDB outbox (pending/failed chips, cross-tab single drainer, Background Sync).
  • Demo night out of the boxpnpm db:seed synthesizes playable demo WAVs and seeds two hand-timed public-domain songs + queue rows, so the full loop (queue → play → lyrics) works with no external service.
  • Template platform — PWA (installable, offline fallback), typed i18n catalog, OKLCH theming with per-user personalization, typed env boundaries, four-layer test rig, one-command prod compose stack.

Env-gated (off ⇒ the feature is invisible and its routes answer 503; see docs/karaoke.md for setup):

  • Music.ai song processing — upload an original, get stems + timed lyrics ingested via webhook or the dashboard re-check button. Flags: MUSICAI_API_KEY, MUSICAI_WORKFLOW, MUSICAI_WEBHOOK_SECRET. Without a key, manual mode is the always-working baseline.
  • Room creationROOM_CREATION_ENABLED (default off) is the deployment default for whether any enrolled account may create a room; off ⇒ server-admin-only creation. A server admin flips the policy at runtime from /admin — the server settings page (stored setting ?? env default) — and creates rooms from the venue directory /rooms regardless of it. The /admin refusal surface doubles as a growth pitch — "create your own room" (enabled) or "host your own instance" with the AGPL-3.0 source link from build-time NEXT_PUBLIC_SOURCE_URL (disabled). A room is created via a server command, then active → archived, never deleted.
  • Stripe tipping ("Boost") — preset tip amounts via Stripe Checkout; the webhook credits tip_cents and recomputes priority (pinned rows keep their spot). Tips are fully private — the only trace is a tip-received push to the tipper. Flags: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, TIPS_CURRENCY, TIPS_PRESETS_CENTS.
  • Web push stage calls — "you're up next" / "you're up now", admin song-processing pushes, and a private tip-received push, all through the VAPID web-push channel (push replaced SMS). Also the third leg of the second-song gate (push_required). Flags: PUSH_VAPID_PUBLIC, PUSH_VAPID_PRIVATE, PUSH_SUBJECTkeyless degrades loudly (both the stage-call channel and the gate leg are off, and the /admin dashboard says so).
  • Hybrid vector search — with an embedding provider configured, the song search adds a SurrealDB HNSW vector leg fused (search::rrf) with the always-on full-text + typo legs; keyless deployments degrade to full-text only (never error). Backfill from the /admin Embeddings card. Flags: EMBEDDING_PROVIDER (vllm/google/openai-compatible/none), EMBEDDING_MODEL, EMBEDDING_BASE_URL, EMBEDDING_API_KEY, EMBEDDING_DIMS. (docs/embeddings.md)
  • ffmpeg video export — the render-worker compose service (--profile render) burns ASS karaoke subtitles + a generated background + the chosen stem into a downloadable .mp4. Knobs: RENDER_POLL_MS, RENDER_STALL_TIMEOUT_MS. (docs/rendering.md)
  • Self-hosted song processing — an opt-in ingest-worker (--profile ingest) that separates and transcribes on your own CPU, with no cloud and no per-song cost: Mel-Band RoFormer for the backing track and faster-whisper large-v2 for the words, all MIT-licensed weights. Measured against the incumbent on the same songs it reaches parity on one and ~1.5 points behind on a call-and-response track, and its instrumental beats the incumbent's on both. Every result is flagged for review before it can reach the stage; a bad alignment is refused by a validated ctc_score floor rather than shipped. ~47 CPU-min/song.
  • Casting (CAST_ENABLED, CAST_PUBLIC_URL, CAST_TOKEN_SECRET, --profile cast) — two modes, each stating its cost, neither a default. Audio to a speaker via Sendspin: a Go service follows the room's playback state (the database stays authoritative, so closing the player tab does not silence the TV and pausing the room pauses the cast) and the lyric cursor runs off its clock. Video to a TV: the pre-rendered MP4 with lyrics burned into the frames, played by Google's stock receiver — lyrics on the TV, but one song, not the queue. A Chromecast carries no session, so both use an HMAC capability token in the URL path, and the two token kinds are domain-separated so neither can be spent as the other.
  • Matrix sign-in — optional OIDC against a configured homeserver (MATRIX_OIDC_*), inherited from the template.

Deliberately not implemented (recorded with reasons in docs/open-questions.md): SIWE, Uppy (tus is used instead), Lottie assets, per-room song libraries / room theming / private rooms / room un-archive, host-scoped uploads, client-rendered room QR codes, and the buffer-mode pitch graph (shape B — the live-input path shipped).

Quickstart (dev)

Requires Node ≥ 24, pnpm 11, and Docker (for SurrealDB + Mailpit).

pnpm install
pnpm dev            # compose stack (SurrealDB + Mailpit) + Next dev server
pnpm db:seed        # demo songs (synthesized WAVs + timed lyrics), demo queue

Open http://localhost:3200 — the WEB_PORT this checkout's .env assigns (each generated app gets its own contiguous port block; Mailpit's UI, where OTP emails land in dev, is at MAILPIT_UI_PORT = http://localhost:3202). The app boots you as a guest — pick a song and you're in the queue. To see the admin side: secure your account on /account, then pnpm admin:grant your@email and reload.

Verify a change before you commit:

pnpm check          # Biome lint + typecheck + unit tests — must be green
pnpm e2e            # Playwright against the compose stack

Documentation

Start with AGENTS.md — the operating manual (also symlinked as CLAUDE.md). The binding contracts: CONTEXT.md (domain language), STANDARDS.md (code), DESIGN.md (design system), docs/adr/ (decision records).

Topic guides in docs/:

Guide Covers
karaoke.md Running a karaoke night — granting admin, multi-room, song ingestion, the show, tips & push setup.
embeddings.md Hybrid-search embedding providers (vLLM/Octen, Gemini), dims/normalization, the provider-switch re-embed.
rendering.md The ffmpeg export worker (--profile render).
open-questions.md Recorded product decisions & deviations awaiting human review.
auth.md Anonymous-first auth, the SurrealDB bridge, the Matrix recipe.
database.md SurrealDB schema layout, GraphQL, sync/seed.
serwist.md Service worker, caching policy, manifest/icons/install.
observability.md The reporter, structured logging, OTLP/OpenObserve, RUM.
testing.md The four-layer test rig and the CI job map.
deployment.md Zero-to-HTTPS production on a VPS.
operations.md Backups (the media dir needs its own!), restore drills, upgrades.
maintenance.md Keeping every pin current.
template-updates.md Pulling template improvements into this app; migrations.
releases.md Cutting semver releases; what each bump promises.

Template origin

mune was generated from the Mune base template (create-mune) — a batteries-included, entirely self-hostable PWA monorepo: anonymous-first auth, an offline outbox, live queries, push, an OKLCH design system, and a one-command production stack. The karaoke feature set was built on top of that working baseline; the template's demo pages (notes/board) were replaced by the karaoke surfaces.

This app stays connected to its template: pull later template improvements with pnpm template:update --from <template checkout or URL> --ref latest — three-way merged into your tree, with codemod migrations for breaking changes (docs/template-updates.md). Releases are semver-tagged (docs/releases.md).

The create-mune scaffolder is MIT-licensed. This app is distributed under the GNU AGPL-3.0 (LICENSE at the repo root) — §13 requires offering the running source to network users, which is why the "host your own instance" growth pitch links to NEXT_PUBLIC_SOURCE_URL; point it at your fork.