From 3ddd86f8ab8dfff432556868c9473158fe8f35d0 Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Thu, 5 Mar 2026 20:40:42 +0300 Subject: [PATCH] Add DJ Console design document Comprehensive design doc for the live DJ mixing feature: - Dual-deck library mixing with crossfader (constant-power curve) - External audio input: local sound card (ALSA/Pulse/JACK) and network audio (Icecast source protocol for remote DJs) - Session lifecycle with auto-playlist pause/resume and watchdog - API endpoints, backend classes, frontend layout - Phased implementation plan - Open questions for team discussion --- docs/DJ-CONSOLE.org | 517 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 docs/DJ-CONSOLE.org diff --git a/docs/DJ-CONSOLE.org b/docs/DJ-CONSOLE.org new file mode 100644 index 0000000..bed66e5 --- /dev/null +++ b/docs/DJ-CONSOLE.org @@ -0,0 +1,517 @@ +#+TITLE: DJ Console — Design Document +#+AUTHOR: Glenn Thompson +#+DATE: 2026-03-05 + +* Overview + +The DJ Console is a new feature for Asteroid Radio that allows DJs to mix +tracks live from the station's music library, or connect external audio +equipment (turntables, CDJs, mixers, etc.) to broadcast live sets. + +The console is a dedicated web page at =/asteroid/dj=, accessible to users +with the =:dj= or =:admin= role. It provides a dual-deck interface with +crossfader, per-deck volume, library search, and external audio input — +all feeding into the existing Harmony mixer and streaming pipeline. + +** Why This Works + +The current CL-Streamer architecture makes this straightforward: + +- *Harmony's mixer already sums all active voices.* The streaming drain, + encoders (LAME, FDK-AAC), and broadcast buffers don't need to change. + A DJ session is just manual control over which voices are playing and + at what volume. + +- *play-file already creates Harmony voices.* Each deck loads a track + the same way the auto-playlist does — the difference is that the DJ + controls when to start, stop, and crossfade rather than the + =play-list= loop. + +- *External audio input* is another voice on the mixer. Whether it + comes from a local sound card (ALSA/PulseAudio/JACK) or a network + audio stream, once it's a Harmony source, it's mixed identically to + library tracks. + +* Architecture + +** System Diagram + +#+BEGIN_EXAMPLE +┌──────────────────────────────────────────────────────────────┐ +│ SBCL Process │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ DJ Session (dj-session.lisp) │ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ DECK A │ │ DECK B │ │ │ +│ │ │ voice-a │ │ voice-b │ │ │ +│ │ │ vol: 0.8 │ │ vol: 0.6 │ │ │ +│ │ │ state: │ │ state: │ │ │ +│ │ │ playing │ │ cued │ │ │ +│ │ └────┬─────┘ └────┬─────┘ │ │ +│ │ │ │ │ │ +│ │ │ ┌──────────────┐ │ │ │ +│ │ │ │ EXTERNAL IN │ │ │ │ +│ │ │ │ (optional) │ │ │ │ +│ │ │ │ voice-ext │ │ │ │ +│ │ │ └──────┬───────┘ │ │ │ +│ │ │ │ │ │ │ +│ │ ┌────▼─────────▼─────────▼────┐ │ │ +│ │ │ CROSSFADER (0.0 — 1.0) │ │ │ +│ │ │ Adjusts A/B balance │ │ │ +│ │ └─────────────┬───────────────┘ │ │ +│ └────────────────│────────────────────────────────────┘ │ +│ │ │ +│ ┌────▼────┐ │ +│ │ Harmony │ │ +│ │ Mixer │ (sums all active voices) │ +│ └────┬────┘ │ +│ │ │ +│ ┌────────────────▼──────────────────────────────────┐ │ +│ │ streaming-drain (unchanged) │ │ +│ │ float→s16 → LAME (MP3) + FDK-AAC (AAC) │ │ +│ │ → broadcast buffers → HTTP clients │ │ +│ └────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────┐ ┌────────────────────────┐ │ +│ │ Auto-Playlist │ │ Radiance Web Server │ │ +│ │ (paused while DJ live) │ │ (port 8080) │ │ +│ └───────────────────────┘ └────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +#+END_EXAMPLE + +** Audio Flow + +When a DJ session is active: + +1. The auto-playlist (=play-list= loop) is paused — its current + position is saved for seamless resume later. +2. The DJ loads tracks onto Deck A and/or Deck B via the library search. +3. Each deck creates a Harmony voice. =play-file= is called with + =:on-end :disconnect= so the voice cleans up when the track ends. +4. The crossfader adjusts the =(mixed:volume)= of each deck's voice: + - Position 0.0: Deck A at full volume, Deck B silent + - Position 0.5: Both at equal volume + - Position 1.0: Deck B at full volume, Deck A silent +5. Per-deck volume knobs provide independent gain before the crossfader. +6. Harmony's mixer sums all active voices (both decks + external input + if connected). +7. The streaming drain captures the mixed output — identical to + auto-playlist mode. Encoders and listeners are unaware of the + source change. + +When the DJ ends the session: + +1. Active decks fade out over 3 seconds (=volume-ramp=). +2. The auto-playlist resumes from the saved position. +3. ICY metadata updates to show the resumed track. + +* External Audio Input + +** Use Cases + +- *Turntable/CDJ setup*: DJ connects external hardware to the server's + sound card (line-in, USB audio interface) +- *Remote DJ*: DJ streams audio over the network from their own + equipment/software (e.g., Traktor, Serato, Ableton, BUTT) +- *Hybrid set*: DJ mixes between library tracks on the decks and + external audio input + +** Option 1: Local Sound Card Input (ALSA/PulseAudio/JACK) + +For DJs with physical access to the server (or a connected audio +interface), cl-mixed can capture from ALSA, PulseAudio, or JACK: + +- =cl-mixed-pulse= — PulseAudio source capture +- =cl-mixed-alsa= — ALSA device capture +- =cl-mixed-jack= — JACK audio connection + +The captured audio becomes a Harmony source that feeds into the mixer +like any other voice. This is the lowest-latency option. + +#+BEGIN_EXAMPLE +┌──────────────┐ ┌───────────┐ ┌─────────────┐ +│ Turntable / │───▶│ USB Audio │───▶│ ALSA/Pulse │ +│ CDJ / Mixer │ │ Interface │ │ Capture │ +└──────────────┘ └───────────┘ └──────┬──────┘ + │ + ┌───────▼───────┐ + │ Harmony Voice │ + │ (ext-input) │ + └───────┬───────┘ + │ + ┌───────▼───────┐ + │ Harmony Mixer │ + │ → encoders → │ + │ listeners │ + └───────────────┘ +#+END_EXAMPLE + +** Option 2: Network Audio Input (Remote DJ) + +For remote DJs, the server accepts an incoming audio stream. The DJ +sends audio from their software (BUTT, Traktor, etc.) over HTTP or +Icecast protocol to a receive endpoint on the CL-Streamer server. + +The incoming compressed audio (MP3 or Ogg) is decoded and fed into +Harmony's mixer as another voice. + +#+BEGIN_EXAMPLE +┌──────────────┐ ┌──────────────────┐ +│ DJ Software │ ──HTTP──▶│ CL-Streamer │ +│ (BUTT, │ or SRT │ /dj-input mount │ +│ Traktor, │ │ │ +│ etc.) │ │ decode → Harmony │ +└──────────────┘ │ voice (ext-input)│ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Harmony Mixer │ + │ → encoders → │ + │ listeners │ + └──────────────────┘ +#+END_EXAMPLE + +Implementation options: +- *Icecast source protocol*: DJ software connects as an Icecast source + client (=SOURCE /dj-input HTTP/1.0=). CL-Streamer accepts the + connection, decodes the incoming MP3/Ogg stream via cl-mixed, and + routes it to the mixer. Most DJ software already supports this. +- *SRT (Secure Reliable Transport)*: Lower latency for remote DJs. + Would require SRT library integration (future). +- *WebRTC*: Browser-based DJ could stream from their mic/mixer via + WebRTC. Complex but enables browser-only DJ workflow (future). + +** Option 3: Hybrid + +Both options can be active simultaneously. The DJ console UI shows an +"External Input" channel alongside Deck A and Deck B, with its own +volume control and mute button. The crossfader operates on Deck A/B; +the external input has independent volume. + +* Access Control + +** Route and Roles + +- *URL*: =/asteroid/dj= +- *Required role*: =:dj= (which also grants access to =:admin= users, + since admin inherits all lower roles) +- *Single session*: Only one DJ session can be active at a time. The + session is owned by the user who started it. +- *Admin override*: Admin users can end any DJ session (e.g., if a DJ + disconnects without ending cleanly) + +** Session Lifecycle + +1. DJ navigates to =/asteroid/dj= and clicks "Go Live" +2. Server pauses the auto-playlist and creates a =dj-session= struct +3. DJ loads tracks, mixes, broadcasts +4. DJ clicks "End Session" — decks fade out, auto-playlist resumes +5. If DJ disconnects without ending: a watchdog timer (configurable, + default 60s of no API polls) auto-ends the session + +* Backend Design + +** New file: =dj-session.lisp= + +*** DJ Session State + +#+BEGIN_SRC lisp +(defclass dj-session () + ((owner :initarg :owner :accessor session-owner + :documentation "Username of the DJ who owns this session") + (started-at :initarg :started-at :accessor session-started-at + :initform (get-universal-time)) + (deck-a :initform (make-instance 'dj-deck :name :a) :accessor session-deck-a) + (deck-b :initform (make-instance 'dj-deck :name :b) :accessor session-deck-b) + (crossfader :initform 0.5 :accessor session-crossfader + :documentation "0.0 = all A, 1.0 = all B") + (external-input :initform nil :accessor session-external-input + :documentation "External audio voice, or NIL if not connected") + (external-volume :initform 1.0 :accessor session-external-volume) + (last-poll :initform (get-universal-time) :accessor session-last-poll + :documentation "Timestamp of last UI poll, for watchdog") + (saved-playlist-state :initform nil :accessor session-saved-playlist-state + :documentation "Auto-playlist state saved on session start"))) + +(defclass dj-deck () + ((name :initarg :name :accessor deck-name :documentation ":a or :b") + (file-path :initform nil :accessor deck-file-path) + (voice :initform nil :accessor deck-voice + :documentation "Harmony voice, or NIL if not loaded/playing") + (volume :initform 1.0 :accessor deck-volume + :documentation "Per-deck volume before crossfader") + (state :initform :empty :accessor deck-state + :documentation ":empty, :loaded, :playing, :paused") + (track-info :initform nil :accessor deck-track-info + :documentation "Plist: (:artist :title :album :file :display-title)"))) + +(defvar *dj-session* nil + "The currently active DJ session, or NIL if no DJ is live.") +#+END_SRC + +*** Key Functions + +| Function | Description | +|---------------------------------+-----------------------------------------------------| +| =start-dj-session (user)= | Pause auto-playlist, create session | +| =end-dj-session ()= | Fade out decks, resume auto-playlist | +| =load-deck (deck file-path)= | Load a track onto deck (reads metadata, creates voice) | +| =play-deck (deck)= | Start playback on a loaded/paused deck | +| =pause-deck (deck)= | Pause a playing deck | +| =stop-deck (deck)= | Stop and unload a deck | +| =seek-deck (deck position)= | Seek to position (seconds) | +| =set-crossfader (position)= | Set crossfader 0.0–1.0, updates deck volumes | +| =set-deck-volume (deck vol)= | Set per-deck volume 0.0–1.0 | +| =connect-external-input (src)= | Connect external audio source to mixer | +| =disconnect-external-input ()= | Disconnect external audio | +| =set-external-volume (vol)= | Set external input volume | +| =dj-session-status ()= | Returns full state for UI polling | +| =dj-watchdog-check ()= | Called by cl-cron; ends stale sessions | + +*** Crossfader Volume Calculation + +The crossfader applies a constant-power curve so the perceived volume +stays consistent across the sweep: + +#+BEGIN_SRC lisp +(defun crossfader-volumes (position) + "Return (values vol-a vol-b) for crossfader position 0.0–1.0. + Uses constant-power (equal-power) curve." + (let ((angle (* position (/ pi 2.0)))) + (values (cos angle) ;; Deck A: 1.0 at 0.0, 0.0 at 1.0 + (sin angle)))) ;; Deck B: 0.0 at 0.0, 1.0 at 1.0 +#+END_SRC + +The effective volume for each deck is: +=effective-volume = deck-volume * crossfader-component= + +This is applied by setting =(mixed:volume voice)= on each deck's +Harmony voice. + +*** ICY Metadata During DJ Session + +When the DJ is live, ICY metadata updates to reflect the currently +audible track. Options: + +- *Auto-detect*: Whichever deck is louder (based on crossfader + position) determines the displayed track +- *Manual override*: DJ can set custom metadata text (e.g., "DJ Fade + — Live Set") +- *Session default*: Show "DJ — Live" when the session starts + +* API Endpoints + +All endpoints require =:dj= role. + +** Session Management + +| Endpoint | Method | Description | +|-----------------------------------+--------+--------------------------------------| +| =asteroid/dj/session/start= | POST | Start DJ session | +| =asteroid/dj/session/end= | POST | End session, resume auto-playlist | +| =asteroid/dj/session/status= | GET | Full state (polled by UI every 500ms) | +| =asteroid/dj/session/metadata= | POST | Set custom ICY metadata text | + +** Deck Control + +| Endpoint | Method | Params | Description | +|-----------------------------------+--------+--------------------------+----------------------| +| =asteroid/dj/deck/load= | POST | deck (a/b), track-id | Load track onto deck | +| =asteroid/dj/deck/play= | POST | deck | Play | +| =asteroid/dj/deck/pause= | POST | deck | Pause | +| =asteroid/dj/deck/stop= | POST | deck | Stop and unload | +| =asteroid/dj/deck/seek= | POST | deck, position (seconds) | Seek | +| =asteroid/dj/deck/volume= | POST | deck, volume (0.0–1.0) | Set deck volume | + +** Crossfader + +| Endpoint | Method | Params | Description | +|-----------------------------------+--------+---------------------+----------------------| +| =asteroid/dj/crossfader= | POST | position (0.0–1.0) | Set crossfader | + +** External Input + +| Endpoint | Method | Params | Description | +|-----------------------------------+--------+---------------------+----------------------------| +| =asteroid/dj/external/connect= | POST | source (alsa/pulse) | Connect local audio input | +| =asteroid/dj/external/disconnect= | POST | | Disconnect external input | +| =asteroid/dj/external/volume= | POST | volume (0.0–1.0) | Set external input volume | + +** Library Search + +| Endpoint | Method | Params | Description | +|-----------------------------------+--------+---------------------+----------------------------| +| =asteroid/dj/library/search= | GET | q (search query) | Search library for tracks | + +* Frontend Design + +** Template: =template/dj-console.ctml= + +*** Layout + +#+BEGIN_EXAMPLE +┌─────────────────────────────────────────────────────────────┐ +│ 🎛️ DJ CONSOLE DJ: fade [End Session]│ +├────────────────────────┬────────────────────────────────────┤ +│ │ │ +│ ◉ DECK A │ ◎ DECK B │ +│ ───────────────── │ ───────────────── │ +│ Orbital │ (empty) │ +│ Halcyon + On + On │ │ +│ Brown Album │ │ +│ │ │ +│ ▶ ⏸ ■ │ ▶ ⏸ ■ │ +│ [========|------] │ [-------------] │ +│ 2:14 / 9:27 │ 0:00 / 0:00 │ +│ │ │ +│ VOL [=========-] │ VOL [=========-] │ +│ │ │ +├────────────────────────┴────────────────────────────────────┤ +│ │ +│ A [===================|===================] B │ +│ CROSSFADER │ +│ │ +├──────────────────────────────────────────────────────────────┤ +│ 🎤 EXTERNAL INPUT [Connect] Vol [=========-] │ +│ Status: Not connected │ +├──────────────────────────────────────────────────────────────┤ +│ 📚 LIBRARY │ +│ [____________________________] [Search] │ +│ │ +│ Artist Title Album Action │ +│ ─────────────────── ───────────────────── ───────── ─────── │ +│ Orbital Halcyon + On + On Brown.. [A] [B] │ +│ Underworld Born Slippy (Nuxx) STITI [A] [B] │ +│ Boards of Canada Kid for Today In a.. [A] [B] │ +│ Drexciya Bubble Chamber Journey [A] [B] │ +│ Model 500 Digital Solutions Digital [A] [B] │ +│ ... │ +│ │ +│ ◀ Page 1 of 12 ▶ │ +└──────────────────────────────────────────────────────────────┘ +#+END_EXAMPLE + +*** UI Behaviour + +- *Status polling*: ParenScript polls =asteroid/dj/session/status= + every 500ms for deck positions, crossfader state, and track info. + This keeps the progress bars and time displays in sync. +- *Crossfader*: An HTML range input (slider). On change, sends the + new position to =asteroid/dj/crossfader=. +- *Deck volume*: Vertical or horizontal sliders per deck. +- *Library search*: Text input with debounced search (300ms delay). + Results show artist, title, album, and [Load A] / [Load B] buttons. +- *Deck controls*: Play/Pause/Stop buttons. Seek by clicking on the + progress bar. +- *External input*: Connect button opens a dropdown to select audio + source (ALSA device, PulseAudio). Volume slider. +- *Session indicator*: Shows who is currently DJing and session + duration. Visible to admin users from the admin dashboard as well. + +** ParenScript: =parenscript/dj-console.lisp= + +Key functions: + +| Function | Description | +|-----------------------------+-------------------------------------------| +| =poll-session-status= | GET status, update all UI elements | +| =load-track-to-deck= | POST load, refresh deck display | +| =toggle-deck-playback= | Play/pause deck | +| =stop-deck= | Stop and unload | +| =update-crossfader= | POST new crossfader position | +| =update-deck-volume= | POST new volume | +| =search-library= | GET search results, render table | +| =start-session= | POST session start | +| =end-session= | POST session end, redirect to front page | +| =connect-external= | POST external input connection | + +* Navigation + +Add "DJ Console" link to =template/partial/navbar-admin.ctml=, visible +only to users with =:dj= or =:admin= role: + +#+BEGIN_SRC html + + + 🎛️ DJ + + +#+END_SRC + +The DJ link appears between "Profile" and "Admin" in the navbar. For +=:dj= users who don't have =:admin= access, they won't see the Admin +or Users links but will see the DJ Console link. + +* Files to Create / Modify + +** New Files + +| File | Description | +|-----------------------------------+--------------------------------------------------| +| =dj-session.lisp= | DJ session state, deck management, auto-fallback | +| =parenscript/dj-console.lisp= | DJ console UI logic (ParenScript) | +| =template/dj-console.ctml= | DJ console HTML template | + +** Modified Files + +| File | Change | +|--------------------------------------------+-----------------------------------------------| +| =asteroid.lisp= | Add =define-page dj-console=, DJ API endpoints, serve =dj-console.js= | +| =asteroid.asd= | Add =dj-session= to =:components= | +| =template/partial/navbar-admin.ctml= | Add DJ Console nav link | +| =stream-harmony.lisp= | Add pause/resume hooks for auto-playlist | +| =cl-streamer/harmony-backend.lisp= | Export deck-level play/pause/seek if needed | +| =cl-streamer/package.lisp= | Export new symbols | + +* Implementation Plan + +** Phase 1: Library Mixing (Core) + +1. =dj-session.lisp= — session lifecycle, deck state, crossfader math +2. API endpoints in =asteroid.lisp= — session/deck/crossfader control +3. =stream-harmony.lisp= — pause/resume auto-playlist on session start/end +4. =template/dj-console.ctml= — dual-deck UI with library browser +5. =parenscript/dj-console.lisp= — polling, controls, search +6. Navigation and ASDF updates + +** Phase 2: External Audio Input + +7. Local audio capture via cl-mixed-pulse or cl-mixed-alsa +8. Network audio input — accept Icecast source protocol connections +9. External input UI in the DJ console +10. Hybrid mixing (decks + external simultaneously) + +** Phase 3: Polish + +11. Waveform display (decode audio ahead of time, render waveform in canvas) +12. Cue points / hot cues +13. BPM detection and sync +14. Effects (EQ, filter, reverb via cl-mixed effects chain) +15. Session recording (save the mixed output to a file) +16. Chat / talkback between DJ and listeners + +* Open Questions + +1. *Monitoring / cue preview*: Should the DJ be able to preview a + track in headphones before pushing it live? This would require a + second audio output (local sound card) separate from the streaming + drain. Possible with Harmony but adds complexity. + +2. *Concurrent sessions*: Do we ever want multiple DJs? (e.g., back- + to-back sets with handover). For now, single session is simpler. + +3. *Network input protocol*: Icecast source protocol is the most + compatible with existing DJ software. Should we also support SRT + for lower latency? + +4. *Latency*: The current pipeline has ~50ms latency (Harmony's + buffer) plus encoding time. For remote DJ monitoring this is fine. + For local DJ monitoring with headphones, we may want a direct + monitor path that bypasses encoding. + +5. *Recording*: Should DJ sets be recorded to disk automatically? + Could write the raw PCM or a high-quality FLAC alongside the + encoded streams.