Compare commits

..

2 Commits

Author SHA1 Message Date
Glenn Thompson e712009d79 Implement DJ Console Phase 1 — dual-deck library mixing
New files:
- dj-session.lisp: DJ session state, deck management, crossfader
  (constant-power curve), auto-playlist pause/resume, ICY metadata
  auto-detect, library search, watchdog timer
- parenscript/dj-console.lisp: UI polling (500ms), deck controls,
  crossfader, library search with load-to-deck, session management
- template/dj-console.ctml: Dark hacker-themed dual-deck interface
  with progress bars, transport controls, crossfader slider, metadata
  override, and library search table

Modified files:
- asteroid.lisp: 14 DJ API endpoints (session, deck, crossfader,
  library search), define-page dj-console, dj-console.js serving
- asteroid.asd: Add dj-session and dj-console components
- cl-streamer/harmony-backend.lisp: Export update-all-mounts-metadata,
  volume-ramp, pipeline-harmony-server for DJ deck control
- navbar-admin.ctml: DJ Console link (role-gated to :dj/:admin)

API endpoints all require :dj role. Session lifecycle:
  GO LIVE -> pause auto-playlist -> mix -> END SESSION -> resume

External audio input stubbed for Phase 2.
2026-03-05 21:22:09 +03:00
Glenn Thompson 3ddd86f8ab 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
2026-03-05 20:40:42 +03:00
8 changed files with 1854 additions and 2 deletions

View File

@ -64,12 +64,14 @@
(:file "player") (:file "player")
(:file "stream-player") (:file "stream-player")
(:file "frameset-utils") (:file "frameset-utils")
(:file "spectrum-analyzer"))) (:file "spectrum-analyzer")
(:file "dj-console")))
(:file "stream-media") (:file "stream-media")
(:file "user-management") (:file "user-management")
(:file "playlist-management") (:file "playlist-management")
(:file "stream-control") (:file "stream-control")
(:file "stream-harmony") (:file "stream-harmony")
(:file "dj-session")
(:file "playlist-scheduler") (:file "playlist-scheduler")
(:file "listener-stats") (:file "listener-stats")
(:file "user-profile") (:file "user-profile")

View File

@ -558,6 +558,138 @@
(api-output `(("status" . "success") (api-output `(("status" . "success")
("message" . "Streaming pipeline restarted"))))) ("message" . "Streaming pipeline restarted")))))
;;; ---- DJ Console API Endpoints ----
(define-api asteroid/dj/session/start () ()
"Start a new DJ session. Pauses the auto-playlist."
(require-role :dj)
(with-error-handling
(let* ((user (get-current-user))
(username (if user (dm:field user "username") "unknown")))
(start-dj-session username)
(api-output `(("status" . "success")
("message" . ,(format nil "DJ session started by ~A" username)))))))
(define-api asteroid/dj/session/end () ()
"End the current DJ session. Resumes auto-playlist."
(require-role :dj)
(with-error-handling
(end-dj-session)
(api-output `(("status" . "success")
("message" . "DJ session ended")))))
(define-api asteroid/dj/session/status () ()
"Get full DJ session status (polled by UI)."
(require-role :dj)
(with-error-handling
(let ((status (dj-session-status)))
(api-output (or status `(("active" . nil)))))))
(define-api asteroid/dj/session/metadata (text) ()
"Set custom ICY metadata text for the DJ session. Empty string clears override."
(require-role :dj)
(with-error-handling
(set-dj-metadata (if (or (null text) (string= text "")) nil text))
(api-output `(("status" . "success")
("message" . "Metadata updated")))))
(define-api asteroid/dj/deck/load (deck track-id) ()
"Load a track onto a deck by track ID."
(require-role :dj)
(with-error-handling
(let* ((deck-id (parse-deck-id deck))
(id (parse-integer track-id :junk-allowed t))
(track (when id (dm:get-one "tracks" (db:query (:= '_id id)))))
(file-path (when track (dm:field track "file-path"))))
(unless file-path
(error "Track not found: ~A" track-id))
(unless (probe-file file-path)
(error "Audio file not found on disk: ~A" file-path))
(let ((info (load-deck deck-id file-path)))
(api-output `(("status" . "success")
("deck" . ,deck)
("trackInfo" . (("artist" . ,(or (getf info :artist) ""))
("title" . ,(or (getf info :title) ""))
("album" . ,(or (getf info :album) ""))
("displayTitle" . ,(or (getf info :display-title) ""))))))))))
(define-api asteroid/dj/deck/load-path (deck path) ()
"Load a track onto a deck by file path (for loading from playlists)."
(require-role :dj)
(with-error-handling
(let ((deck-id (parse-deck-id deck)))
(unless (probe-file path)
(error "Audio file not found: ~A" path))
(let ((info (load-deck deck-id path)))
(api-output `(("status" . "success")
("deck" . ,deck)
("trackInfo" . (("artist" . ,(or (getf info :artist) ""))
("title" . ,(or (getf info :title) ""))
("album" . ,(or (getf info :album) ""))
("displayTitle" . ,(or (getf info :display-title) ""))))))))))
(define-api asteroid/dj/deck/play (deck) ()
"Start or resume playback on a deck."
(require-role :dj)
(with-error-handling
(play-deck (parse-deck-id deck))
(api-output `(("status" . "success")
("message" . ,(format nil "Deck ~A playing" deck))))))
(define-api asteroid/dj/deck/pause (deck) ()
"Pause a playing deck."
(require-role :dj)
(with-error-handling
(pause-deck (parse-deck-id deck))
(api-output `(("status" . "success")
("message" . ,(format nil "Deck ~A paused" deck))))))
(define-api asteroid/dj/deck/stop (deck) ()
"Stop and unload a deck."
(require-role :dj)
(with-error-handling
(stop-deck (parse-deck-id deck))
(api-output `(("status" . "success")
("message" . ,(format nil "Deck ~A stopped" deck))))))
(define-api asteroid/dj/deck/seek (deck position) ()
"Seek to a position (seconds) on a deck."
(require-role :dj)
(with-error-handling
(let ((pos (float (read-from-string position))))
(seek-deck (parse-deck-id deck) pos)
(api-output `(("status" . "success")
("message" . ,(format nil "Deck ~A seeked to ~,1Fs" deck pos)))))))
(define-api asteroid/dj/deck/volume (deck volume) ()
"Set per-deck volume (0.0-1.0)."
(require-role :dj)
(with-error-handling
(let ((vol (float (read-from-string volume))))
(set-deck-volume (parse-deck-id deck) vol)
(api-output `(("status" . "success")
("volume" . ,vol))))))
(define-api asteroid/dj/crossfader (position) ()
"Set crossfader position (0.0-1.0)."
(require-role :dj)
(with-error-handling
(let ((pos (float (read-from-string position))))
(set-crossfader pos)
(api-output `(("status" . "success")
("position" . ,pos))))))
(define-api asteroid/dj/library/search (q &optional (limit "50") (offset "0")) ()
"Search the music library for tracks to load onto a deck."
(require-role :dj)
(with-error-handling
(let ((results (search-library-tracks q
:limit (parse-integer limit :junk-allowed t)
:offset (parse-integer offset :junk-allowed t))))
(api-output `(("status" . "success")
("results" . ,results)
("count" . ,(length results)))))))
(defun get-track-by-id (track-id) (defun get-track-by-id (track-id)
"Get a track by its ID - handles type mismatches" "Get a track by its ID - handles type mismatches"
(dm:get-one "tracks" (db:query (:= '_id track-id)))) (dm:get-one "tracks" (db:query (:= '_id track-id))))
@ -909,6 +1041,16 @@
(format t "ERROR generating frameset-utils.js: ~a~%" e) (format t "ERROR generating frameset-utils.js: ~a~%" e)
(format nil "// Error generating JavaScript: ~a~%" e)))) (format nil "// Error generating JavaScript: ~a~%" e))))
;; Serve ParenScript-compiled dj-console.js
((string= path "js/dj-console.js")
(setf (content-type *response*) "application/javascript")
(handler-case
(let ((js (generate-dj-console-js)))
(if js js "// Error: No JavaScript generated"))
(error (e)
(format t "ERROR generating dj-console.js: ~a~%" e)
(format nil "// Error generating JavaScript: ~a~%" e))))
;; Serve regular static file ;; Serve regular static file
(t (t
(serve-file (merge-pathnames (format nil "static/~a" path) (serve-file (merge-pathnames (format nil "static/~a" path)
@ -943,6 +1085,21 @@
:stream-base-url *stream-base-url* :stream-base-url *stream-base-url*
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)))) :default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*))))
;; DJ Console page (requires DJ or admin role)
(define-page dj-console #@"/dj" ()
"DJ Console - Live mixing interface"
(require-role :dj)
(let* ((user (get-current-user))
(username (if user (dm:field user "username") "unknown")))
(clip:process-to-string
(load-template "dj-console")
:navbar-exclude '("dj")
:title "🎛️ ASTEROID RADIO - DJ Console"
:username username
:stream-base-url *stream-base-url*
:dj-active (if (dj-session-active-p) "true" "false")
:dj-owner (when *dj-session* (session-owner *dj-session*)))))
;; User Management page (requires authentication) ;; User Management page (requires authentication)
(define-page users-management #@"/admin/user" () (define-page users-management #@"/admin/user" ()
"User Management dashboard" "User Management dashboard"

View File

@ -23,7 +23,11 @@
#:pipeline-on-playlist-change #:pipeline-on-playlist-change
;; Metadata helpers ;; Metadata helpers
#:read-audio-metadata #:read-audio-metadata
#:format-display-title)) #:format-display-title
#:update-all-mounts-metadata
;; DJ support
#:pipeline-harmony-server
#:volume-ramp))
(in-package #:cl-streamer/harmony) (in-package #:cl-streamer/harmony)

501
dj-session.lisp Normal file
View File

@ -0,0 +1,501 @@
;;;; dj-session.lisp - Live DJ mixing console for Asteroid Radio
;;;; Provides dual-deck library mixing with crossfader, per-deck volume,
;;;; and external audio input, all feeding into the existing Harmony mixer.
;;;; See docs/DJ-CONSOLE.org for the full design document.
(in-package :asteroid)
;;; ---- DJ Deck ----
(defclass dj-deck ()
((name :initarg :name :accessor deck-name
:documentation "Deck identifier: :a or :b")
(file-path :initform nil :accessor deck-file-path
:documentation "Path of loaded track, or NIL if empty")
(voice :initform nil :accessor deck-voice
:documentation "Harmony voice object, or NIL")
(volume :initform 1.0 :accessor deck-volume
:documentation "Per-deck volume 0.0-1.0 (before crossfader)")
(state :initform :empty :accessor deck-state
:documentation "One of :empty :loaded :playing :paused")
(track-info :initform nil :accessor deck-track-info
:documentation "Plist: (:artist :title :album :file :display-title)")))
(defmethod print-object ((deck dj-deck) stream)
(print-unreadable-object (deck stream :type t)
(format stream "~A ~A" (deck-name deck) (deck-state deck))))
;;; ---- DJ Session ----
(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")
(external-volume :initform 1.0 :accessor session-external-volume)
(metadata-override :initform nil :accessor session-metadata-override
:documentation "Custom ICY metadata text, or NIL for auto-detect")
(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 "Saved auto-playlist state for resume on session end")))
(defvar *dj-session* nil
"The currently active DJ session, or NIL if no DJ is live.")
(defvar *dj-session-lock* (bt:make-lock "dj-session-lock")
"Lock for DJ session state changes.")
(defvar *dj-watchdog-interval* 60
"Seconds of no UI polling before a DJ session is auto-ended.")
;;; ---- Crossfader Math ----
(defun crossfader-volumes (position)
"Return (values vol-a vol-b) for crossfader position 0.0-1.0.
Uses constant-power (equal-power) curve so perceived volume
stays consistent across the sweep."
(let* ((pos (max 0.0 (min 1.0 (float position))))
(angle (* pos (/ pi 2.0))))
(values (cos angle) ;; Deck A: 1.0 at pos=0.0, 0.0 at pos=1.0
(sin angle)))) ;; Deck B: 0.0 at pos=0.0, 1.0 at pos=1.0
(defun effective-deck-volume (deck crossfader-component)
"Calculate the effective volume for a deck: deck-volume * crossfader-component."
(* (deck-volume deck) crossfader-component))
(defun apply-crossfader (session)
"Apply the current crossfader position to both deck voices."
(multiple-value-bind (vol-a vol-b)
(crossfader-volumes (session-crossfader session))
(let ((deck-a (session-deck-a session))
(deck-b (session-deck-b session)))
(when (and (deck-voice deck-a) (eq (deck-state deck-a) :playing))
(setf (org.shirakumo.fraf.mixed:volume (deck-voice deck-a))
(float (effective-deck-volume deck-a vol-a))))
(when (and (deck-voice deck-b) (eq (deck-state deck-b) :playing))
(setf (org.shirakumo.fraf.mixed:volume (deck-voice deck-b))
(float (effective-deck-volume deck-b vol-b)))))))
;;; ---- Auto-Playlist Pause / Resume ----
(defun pause-auto-playlist ()
"Pause the auto-playlist by skipping and clearing the queue.
The play-list thread will exit when it has no more tracks.
Returns saved state for restoration."
(when *harmony-pipeline*
(let ((state (list :playlist-path (when *current-playlist-path*
(namestring *current-playlist-path*))
:current-track (cl-streamer/harmony:pipeline-current-track
*harmony-pipeline*))))
;; Skip current track to stop playback, then clear queue
(cl-streamer/harmony:pipeline-skip *harmony-pipeline*)
(cl-streamer/harmony:pipeline-clear-queue *harmony-pipeline*)
(log:info "Auto-playlist paused for DJ session")
state)))
(defun resume-auto-playlist (saved-state)
"Resume the auto-playlist from saved state after a DJ session ends."
(when saved-state
(let ((playlist-path (getf saved-state :playlist-path)))
(if playlist-path
(progn
(log:info "Resuming auto-playlist: ~A" (file-namestring playlist-path))
(harmony-load-playlist playlist-path))
;; No saved playlist - try loading the current scheduled one
(let ((scheduled (get-current-scheduled-playlist)))
(when scheduled
(let ((path (merge-pathnames scheduled (get-playlists-directory))))
(when (probe-file path)
(log:info "Resuming with scheduled playlist: ~A" scheduled)
(harmony-load-playlist path)))))))))
;;; ---- Session Lifecycle ----
(defun start-dj-session (username)
"Start a new DJ session. Pauses the auto-playlist and creates the session.
Returns the session, or signals an error if one is already active."
(bt:with-lock-held (*dj-session-lock*)
(when *dj-session*
(error "DJ session already active (owned by ~A)" (session-owner *dj-session*)))
(let ((saved-state (pause-auto-playlist)))
(setf *dj-session*
(make-instance 'dj-session
:owner username
:started-at (get-universal-time)))
(setf (session-saved-playlist-state *dj-session*) saved-state)
;; Update ICY metadata to show DJ is live
(update-dj-metadata)
(log:info "DJ session started by ~A" username)
*dj-session*)))
(defun end-dj-session (&key (fade-duration 3.0))
"End the current DJ session. Fades out active decks and resumes auto-playlist."
(bt:with-lock-held (*dj-session-lock*)
(unless *dj-session*
(return-from end-dj-session nil))
(let ((session *dj-session*)
(owner (session-owner *dj-session*)))
;; Fade out and stop both decks
(fade-and-stop-deck (session-deck-a session) fade-duration)
(fade-and-stop-deck (session-deck-b session) fade-duration)
;; Disconnect external input if connected
(when (session-external-input session)
(disconnect-external-input-internal session))
;; Resume auto-playlist
(resume-auto-playlist (session-saved-playlist-state session))
;; Clear session
(setf *dj-session* nil)
(log:info "DJ session ended (was owned by ~A)" owner)
t)))
(defun dj-session-active-p ()
"Return T if a DJ session is currently active."
(not (null *dj-session*)))
;;; ---- Deck Control ----
(defun get-deck (session deck-id)
"Return the deck object for DECK-ID (:a or :b)."
(ecase deck-id
(:a (session-deck-a session))
(:b (session-deck-b session))))
(defun parse-deck-id (deck-string)
"Parse a deck identifier string (\"a\" or \"b\") to a keyword."
(cond
((string-equal deck-string "a") :a)
((string-equal deck-string "b") :b)
(t (error "Invalid deck identifier: ~A (expected 'a' or 'b')" deck-string))))
(defun load-deck (deck-id file-path)
"Load a track onto a deck. Stops any currently playing track on that deck.
Reads metadata from the file and prepares the deck for playback."
(unless *dj-session*
(error "No active DJ session"))
(let* ((deck (get-deck *dj-session* deck-id))
(pipeline *harmony-pipeline*)
(server (cl-streamer/harmony:pipeline-harmony-server pipeline))
(org.shirakumo.fraf.harmony:*server* server))
;; Stop current track if playing
(when (member (deck-state deck) '(:playing :paused))
(stop-deck-internal deck))
;; Read metadata
(let* ((tags (cl-streamer/harmony:read-audio-metadata file-path))
(display-title (cl-streamer/harmony:format-display-title file-path))
(track-info (list :file file-path
:display-title display-title
:artist (getf tags :artist)
:title (getf tags :title)
:album (getf tags :album))))
(setf (deck-file-path deck) file-path
(deck-track-info deck) track-info
(deck-state deck) :loaded)
(log:info "Deck ~A loaded: ~A" deck-id display-title)
track-info)))
(defun play-deck (deck-id)
"Start or resume playback on a deck."
(unless *dj-session*
(error "No active DJ session"))
(let* ((session *dj-session*)
(deck (get-deck session deck-id))
(pipeline *harmony-pipeline*)
(server (cl-streamer/harmony:pipeline-harmony-server pipeline))
(org.shirakumo.fraf.harmony:*server* server))
(ecase (deck-state deck)
(:empty
(error "Deck ~A has no track loaded" deck-id))
(:loaded
;; Create a new voice and start playing
(let ((voice (org.shirakumo.fraf.harmony:play
(sb-ext:parse-native-namestring (deck-file-path deck))
:mixer :music
:on-end :disconnect)))
(setf (deck-voice deck) voice
(deck-state deck) :playing)
;; Apply current crossfader volumes
(apply-crossfader session)
;; Update ICY metadata
(update-dj-metadata)
(log:info "Deck ~A playing: ~A" deck-id
(getf (deck-track-info deck) :display-title))))
(:paused
;; Resume - re-apply crossfader to restore volume
(when (deck-voice deck)
(setf (deck-state deck) :playing)
(apply-crossfader session)
(update-dj-metadata)
(log:info "Deck ~A resumed" deck-id)))
(:playing
;; Already playing, no-op
nil))))
(defun pause-deck (deck-id)
"Pause playback on a deck. Mutes the voice but keeps the position."
(unless *dj-session*
(error "No active DJ session"))
(let ((deck (get-deck *dj-session* deck-id)))
(when (and (eq (deck-state deck) :playing)
(deck-voice deck))
(setf (org.shirakumo.fraf.mixed:volume (deck-voice deck)) 0.0)
(setf (deck-state deck) :paused)
(update-dj-metadata)
(log:info "Deck ~A paused" deck-id))))
(defun stop-deck (deck-id)
"Stop playback and unload the track from a deck."
(unless *dj-session*
(error "No active DJ session"))
(let ((deck (get-deck *dj-session* deck-id)))
(stop-deck-internal deck)
(update-dj-metadata)
(log:info "Deck ~A stopped" deck-id)))
(defun stop-deck-internal (deck)
"Internal: stop a deck's voice and reset state."
(when (deck-voice deck)
(let* ((pipeline *harmony-pipeline*)
(server (cl-streamer/harmony:pipeline-harmony-server pipeline))
(org.shirakumo.fraf.harmony:*server* server))
(handler-case
(org.shirakumo.fraf.harmony:stop (deck-voice deck))
(error (e)
(log:debug "Error stopping deck voice: ~A" e)))))
(setf (deck-voice deck) nil
(deck-file-path deck) nil
(deck-track-info deck) nil
(deck-state deck) :empty))
(defun fade-and-stop-deck (deck duration)
"Fade a deck to silence and stop it. Runs in current thread."
(when (and (deck-voice deck)
(member (deck-state deck) '(:playing :paused)))
(handler-case
(progn
(cl-streamer/harmony:volume-ramp (deck-voice deck) 0.0 duration)
(stop-deck-internal deck))
(error (e)
(log:debug "Error fading deck: ~A" e)
(stop-deck-internal deck)))))
(defun seek-deck (deck-id position-seconds)
"Seek to a position (in seconds) on a deck."
(unless *dj-session*
(error "No active DJ session"))
(let ((deck (get-deck *dj-session* deck-id)))
(when (and (deck-voice deck)
(member (deck-state deck) '(:playing :paused)))
(let ((frame (round (* position-seconds
(org.shirakumo.fraf.mixed:samplerate (deck-voice deck))))))
(org.shirakumo.fraf.mixed:seek (deck-voice deck) frame)
(log:info "Deck ~A seeked to ~,1Fs" deck-id position-seconds)))))
;;; ---- Crossfader Control ----
(defun set-crossfader (position)
"Set the crossfader position (0.0-1.0) and update deck volumes."
(unless *dj-session*
(error "No active DJ session"))
(setf (session-crossfader *dj-session*)
(max 0.0 (min 1.0 (float position))))
(apply-crossfader *dj-session*)
(update-dj-metadata))
(defun set-deck-volume (deck-id volume)
"Set the per-deck volume (0.0-1.0) and reapply crossfader."
(unless *dj-session*
(error "No active DJ session"))
(let ((deck (get-deck *dj-session* deck-id)))
(setf (deck-volume deck) (max 0.0 (min 1.0 (float volume))))
(apply-crossfader *dj-session*)))
;;; ---- External Audio Input ----
(defun connect-external-input (source-type &key device)
"Connect an external audio source to the mixer.
SOURCE-TYPE is :pulse, :alsa, or :jack.
DEVICE is the device name/path (source-specific).
Note: This is a Phase 2 feature."
(declare (ignore source-type device))
(unless *dj-session*
(error "No active DJ session"))
;; TODO: Phase 2 - implement local audio capture via cl-mixed
(log:warn "External audio input not yet implemented")
(error "External audio input is not yet available (Phase 2)"))
(defun disconnect-external-input ()
"Disconnect the external audio input."
(unless *dj-session*
(error "No active DJ session"))
(disconnect-external-input-internal *dj-session*))
(defun disconnect-external-input-internal (session)
"Internal: disconnect external input voice."
(when (session-external-input session)
(handler-case
(let* ((pipeline *harmony-pipeline*)
(server (cl-streamer/harmony:pipeline-harmony-server pipeline))
(org.shirakumo.fraf.harmony:*server* server))
(org.shirakumo.fraf.harmony:stop (session-external-input session)))
(error (e)
(log:debug "Error stopping external input: ~A" e)))
(setf (session-external-input session) nil)
(log:info "External audio input disconnected")))
(defun set-external-volume (volume)
"Set the external input volume (0.0-1.0)."
(unless *dj-session*
(error "No active DJ session"))
(setf (session-external-volume *dj-session*)
(max 0.0 (min 1.0 (float volume))))
(when (session-external-input *dj-session*)
(setf (org.shirakumo.fraf.mixed:volume (session-external-input *dj-session*))
(float (session-external-volume *dj-session*)))))
;;; ---- ICY Metadata ----
(defun set-dj-metadata (text)
"Set custom ICY metadata for the DJ session.
Pass NIL to return to auto-detect mode."
(unless *dj-session*
(error "No active DJ session"))
(setf (session-metadata-override *dj-session*) text)
(update-dj-metadata))
(defun update-dj-metadata ()
"Update ICY metadata based on DJ session state.
Uses custom override if set, otherwise auto-detects from louder deck."
(when (and *dj-session* *harmony-pipeline*)
(let ((title (or (session-metadata-override *dj-session*)
(auto-detect-dj-metadata *dj-session*))))
(when title
(cl-streamer/harmony:update-all-mounts-metadata *harmony-pipeline* title)))))
(defun auto-detect-dj-metadata (session)
"Determine ICY metadata from the louder deck.
Returns a display string like 'DJ fade - Artist - Title'."
(let* ((crossfader (session-crossfader session))
(deck-a (session-deck-a session))
(deck-b (session-deck-b session))
(owner (session-owner session))
;; Pick the deck that's louder based on crossfader position
(active-deck (cond
((and (eq (deck-state deck-a) :playing)
(eq (deck-state deck-b) :playing))
(if (<= crossfader 0.5) deck-a deck-b))
((eq (deck-state deck-a) :playing) deck-a)
((eq (deck-state deck-b) :playing) deck-b)
(t nil)))
(track-title (when (and active-deck (deck-track-info active-deck))
(getf (deck-track-info active-deck) :display-title))))
(if track-title
(format nil "DJ ~A - ~A" owner track-title)
(format nil "DJ ~A - Live" owner))))
;;; ---- Deck Position / Status ----
(defun deck-position-info (deck)
"Return position info for a deck as a plist.
Includes :position (seconds), :duration (seconds), :remaining (seconds)."
(if (and (deck-voice deck)
(member (deck-state deck) '(:playing :paused)))
(handler-case
(let* ((pos (org.shirakumo.fraf.mixed:frame-position (deck-voice deck)))
(total (org.shirakumo.fraf.mixed:frame-count (deck-voice deck)))
(sr (org.shirakumo.fraf.mixed:samplerate (deck-voice deck)))
(position-secs (when (and pos sr (> sr 0))
(float (/ pos sr))))
(duration-secs (when (and total sr (> sr 0))
(float (/ total sr))))
(remaining (when (and position-secs duration-secs)
(- duration-secs position-secs))))
(list :position (or position-secs 0.0)
:duration (or duration-secs 0.0)
:remaining (or remaining 0.0)))
(error ()
(list :position 0.0 :duration 0.0 :remaining 0.0)))
(list :position 0.0 :duration 0.0 :remaining 0.0)))
(defun deck-status-alist (deck)
"Return an alist representing a deck's full state for JSON serialization."
(let ((pos-info (deck-position-info deck)))
`(("name" . ,(string-downcase (symbol-name (deck-name deck))))
("state" . ,(string-downcase (symbol-name (deck-state deck))))
("volume" . ,(deck-volume deck))
("position" . ,(getf pos-info :position))
("duration" . ,(getf pos-info :duration))
("remaining" . ,(getf pos-info :remaining))
("trackInfo" . ,(when (deck-track-info deck)
`(("artist" . ,(or (getf (deck-track-info deck) :artist) ""))
("title" . ,(or (getf (deck-track-info deck) :title) ""))
("album" . ,(or (getf (deck-track-info deck) :album) ""))
("displayTitle" . ,(or (getf (deck-track-info deck) :display-title) ""))))))))
;;; ---- Session Status (for UI polling) ----
(defun dj-session-status ()
"Return the full DJ session status as an alist for JSON serialization.
Returns NIL if no session is active."
(when *dj-session*
;; Update last-poll timestamp for watchdog
(setf (session-last-poll *dj-session*) (get-universal-time))
`(("active" . t)
("owner" . ,(session-owner *dj-session*))
("startedAt" . ,(session-started-at *dj-session*))
("duration" . ,(- (get-universal-time) (session-started-at *dj-session*)))
("deckA" . ,(deck-status-alist (session-deck-a *dj-session*)))
("deckB" . ,(deck-status-alist (session-deck-b *dj-session*)))
("crossfader" . ,(session-crossfader *dj-session*))
("metadataOverride" . ,(session-metadata-override *dj-session*))
("externalInput" . ,(not (null (session-external-input *dj-session*))))
("externalVolume" . ,(session-external-volume *dj-session*)))))
;;; ---- Library Search ----
(defun search-library-tracks (query &key (limit 50) (offset 0))
"Search the track database for tracks matching QUERY.
Returns a list of alists with track info."
(handler-case
(with-db
(let ((results (postmodern:query
(:limit
(:offset
(:order-by
(:select '_id 'title 'artist 'album 'file-path
:from 'tracks
:where (:or (:ilike 'title (format nil "%~A%" query))
(:ilike 'artist (format nil "%~A%" query))
(:ilike 'album (format nil "%~A%" query))))
'artist 'title)
offset)
limit)
:rows)))
(mapcar (lambda (row)
`(("id" . ,(first row))
("title" . ,(or (second row) ""))
("artist" . ,(or (third row) ""))
("album" . ,(or (fourth row) ""))
("filePath" . ,(or (fifth row) ""))))
results)))
(error (e)
(log:warn "Library search error: ~A" e)
nil)))
;;; ---- Watchdog ----
(defun dj-watchdog-check ()
"Check if the DJ session has gone stale (no UI polling).
Called periodically by cl-cron. Auto-ends stale sessions."
(when *dj-session*
(let ((elapsed (- (get-universal-time) (session-last-poll *dj-session*))))
(when (> elapsed *dj-watchdog-interval*)
(log:warn "DJ session stale (~As since last poll), auto-ending"
elapsed)
(end-dj-session :fade-duration 5.0)))))

517
docs/DJ-CONSOLE.org Normal file
View File

@ -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.01.0, updates deck volumes |
| =set-deck-volume (deck vol)= | Set per-deck volume 0.01.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.01.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 <username> — 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.01.0) | Set deck volume |
** Crossfader
| Endpoint | Method | Params | Description |
|-----------------------------------+--------+---------------------+----------------------|
| =asteroid/dj/crossfader= | POST | position (0.01.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.01.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
<c:unless test='(asteroid::member-string "dj" (** :navbar-exclude))'>
<a href="/asteroid/dj"
lquery='(attr :target (when framesetp "_self"))'>
🎛️ DJ
</a>
</c:unless>
#+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.

348
parenscript/dj-console.lisp Normal file
View File

@ -0,0 +1,348 @@
;;;; dj-console.lisp - ParenScript for DJ Console interface
;;;; Handles session management, deck control, crossfader, library search,
;;;; and status polling.
(in-package #:asteroid)
(defparameter *dj-console-js*
(ps:ps*
'(progn
;; ---- State ----
(defvar *poll-timer* nil)
(defvar *session-active* false)
(defvar *search-debounce* nil)
;; ---- Utility ----
(defun format-time (seconds)
"Format seconds as M:SS"
(let* ((secs (ps:chain -math (floor seconds)))
(m (ps:chain -math (floor (/ secs 60))))
(s (mod secs 60)))
(+ m ":" (if (< s 10) (+ "0" s) s))))
(defun api-post (url params callback)
"POST to an API endpoint with form params"
(let ((xhr (ps:new (-x-m-l-http-request))))
(ps:chain xhr (open "POST" url true))
(ps:chain xhr (set-request-header "Content-Type" "application/x-www-form-urlencoded"))
(setf (ps:@ xhr onload)
(lambda ()
(let ((data (ps:chain -j-s-o-n (parse (ps:@ xhr response-text)))))
(when callback (funcall callback data)))))
(setf (ps:@ xhr onerror)
(lambda () (show-message "Network error" "error")))
(ps:chain xhr (send params))))
(defun api-get (url callback)
"GET from an API endpoint"
(let ((xhr (ps:new (-x-m-l-http-request))))
(ps:chain xhr (open "GET" url true))
(setf (ps:@ xhr onload)
(lambda ()
(let ((data (ps:chain -j-s-o-n (parse (ps:@ xhr response-text)))))
(when callback (funcall callback data)))))
(setf (ps:@ xhr onerror)
(lambda () (show-message "Network error" "error")))
(ps:chain xhr (send))))
(defun show-message (text msg-type)
"Show a status message"
(let ((el (ps:chain document (get-element-by-id "dj-message"))))
(setf (ps:@ el inner-text) text)
(setf (ps:@ el class-name) (+ "dj-message " msg-type))
(setf (ps:@ el style display) "block")
(set-timeout (lambda ()
(setf (ps:@ el style display) "none"))
4000)))
(defun encode-params (obj)
"Encode an object as URL form params"
(let ((parts (array)))
(ps:for-in (key obj)
(ps:chain parts (push (+ (encode-u-r-i-component key)
"="
(encode-u-r-i-component (ps:getprop obj key))))))
(ps:chain parts (join "&"))))
;; ---- Session Control ----
(defun start-session ()
(api-post "/api/asteroid/dj/session/start" ""
(lambda (data)
(if (= (ps:@ data status) "success")
(progn
(show-message "Session started - you are LIVE!" "success")
(set-session-active true))
(show-message (or (ps:@ data message) "Failed to start session") "error")))))
(defun end-session ()
(when (ps:chain window (confirm "End your DJ session? Auto-playlist will resume."))
(api-post "/api/asteroid/dj/session/end" ""
(lambda (data)
(if (= (ps:@ data status) "success")
(progn
(show-message "Session ended - auto-playlist resuming" "success")
(set-session-active false))
(show-message (or (ps:@ data message) "Failed to end session") "error"))))))
(defun set-session-active (active)
(setf *session-active* active)
(let ((controls (ps:chain document (get-element-by-id "dj-controls")))
(btn-live (ps:chain document (get-element-by-id "btn-go-live")))
(btn-end (ps:chain document (get-element-by-id "btn-end-session")))
(info (ps:chain document (get-element-by-id "session-info"))))
(if active
(progn
(setf (ps:@ controls class-name) "")
(setf (ps:@ btn-live style display) "none")
(setf (ps:@ btn-end style display) "inline-block")
(setf (ps:@ info inner-h-t-m-l)
(+ "<span class='live-indicator'></span>LIVE"))
;; Start polling
(when *poll-timer* (clear-interval *poll-timer*))
(setf *poll-timer* (set-interval poll-status 500)))
(progn
(setf (ps:@ controls class-name) "no-session-overlay")
(setf (ps:@ btn-live style display) "inline-block")
(setf (ps:@ btn-end style display) "none")
(setf (ps:@ info inner-text) "")
;; Stop polling
(when *poll-timer*
(clear-interval *poll-timer*)
(setf *poll-timer* nil))
;; Reset UI
(reset-deck-ui "a")
(reset-deck-ui "b")))))
;; ---- Status Polling ----
(defun poll-status ()
(api-get "/api/asteroid/dj/session/status"
(lambda (data)
(when (ps:@ data active)
(update-deck-ui "a" (ps:@ data deck-a))
(update-deck-ui "b" (ps:@ data deck-b))
;; Update crossfader if not being dragged
(let ((slider (ps:chain document (get-element-by-id "crossfader"))))
(unless (= (ps:@ document active-element) slider)
(setf (ps:@ slider value)
(ps:chain -math (round (* (ps:@ data crossfader) 100))))))))))
(defun update-deck-ui (deck-name deck-data)
"Update a deck's UI from status data"
(let ((state (ps:@ deck-data state))
(track (ps:@ deck-data track-info))
(position (ps:@ deck-data position))
(duration (ps:@ deck-data duration)))
;; State label
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-state")))
inner-text)
(ps:chain state (to-upper-case)))
;; Track info
(let ((info-el (ps:chain document (get-element-by-id (+ "deck-" deck-name "-info")))))
(if track
(setf (ps:@ info-el inner-h-t-m-l)
(+ "<div class='deck-artist'>"
(or (ps:@ track artist) "") "</div>"
"<div class='deck-title'>"
(or (ps:@ track title) "") "</div>"
"<div class='deck-album'>"
(or (ps:@ track album) "") "</div>"))
(setf (ps:@ info-el inner-h-t-m-l)
"<div class='deck-empty'>No track loaded</div>")))
;; Progress bar
(let ((pct (if (and duration (> duration 0))
(* (/ position duration) 100)
0)))
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-progress")))
style width)
(+ pct "%")))
;; Time display
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-position")))
inner-text)
(format-time (or position 0)))
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-duration")))
inner-text)
(format-time (or duration 0)))
;; Button states
(let ((can-play (or (= state "loaded") (= state "paused")))
(can-pause (= state "playing"))
(can-stop (or (= state "playing") (= state "paused") (= state "loaded"))))
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-play")))
disabled)
(not can-play))
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-pause")))
disabled)
(not can-pause))
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-stop")))
disabled)
(not can-stop)))
;; Active indicator
(let ((container (ps:chain document (get-element-by-id (+ "deck-" deck-name "-container")))))
(if (= state "playing")
(ps:chain (ps:@ container class-list) (add "active"))
(ps:chain (ps:@ container class-list) (remove "active"))))))
(defun reset-deck-ui (deck-name)
"Reset a deck's UI to empty state"
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-state")))
inner-text) "EMPTY")
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-info")))
inner-h-t-m-l) "<div class='deck-empty'>No track loaded</div>")
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-progress")))
style width) "0%")
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-position")))
inner-text) "0:00")
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-duration")))
inner-text) "0:00")
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-play")))
disabled) true)
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-pause")))
disabled) true)
(setf (ps:@ (ps:chain document (get-element-by-id (+ "deck-" deck-name "-stop")))
disabled) true))
;; ---- Deck Control ----
(defun play-deck (deck)
(api-post "/api/asteroid/dj/deck/play"
(encode-params (ps:create :deck deck))
(lambda (data)
(unless (= (ps:@ data status) "success")
(show-message (or (ps:@ data message) "Play failed") "error")))))
(defun pause-deck (deck)
(api-post "/api/asteroid/dj/deck/pause"
(encode-params (ps:create :deck deck))
(lambda (data)
(unless (= (ps:@ data status) "success")
(show-message (or (ps:@ data message) "Pause failed") "error")))))
(defun stop-deck (deck)
(api-post "/api/asteroid/dj/deck/stop"
(encode-params (ps:create :deck deck))
(lambda (data)
(unless (= (ps:@ data status) "success")
(show-message (or (ps:@ data message) "Stop failed") "error")))))
(defun seek-deck (deck event)
"Seek on a deck by clicking the progress bar"
(let* ((bar (ps:@ event current-target))
(rect (ps:chain bar (get-bounding-client-rect)))
(x (- (ps:@ event client-x) (ps:@ rect left)))
(pct (/ x (ps:@ rect width)))
;; Get duration from the UI display (rough but works)
(duration-el (ps:chain document (get-element-by-id (+ "deck-" deck "-duration"))))
(parts (ps:chain (ps:@ duration-el inner-text) (split ":")))
(dur (+ (* (parse-int (aref parts 0) 10) 60) (parse-int (aref parts 1) 10)))
(seek-pos (* pct dur)))
(api-post "/api/asteroid/dj/deck/seek"
(encode-params (ps:create :deck deck :position seek-pos))
nil)))
;; ---- Crossfader ----
(defun set-crossfader (position)
(api-post "/api/asteroid/dj/crossfader"
(encode-params (ps:create :position position))
nil))
;; ---- Volume ----
(defun set-deck-volume (deck volume)
(api-post "/api/asteroid/dj/deck/volume"
(encode-params (ps:create :deck deck :volume volume))
nil))
;; ---- Metadata ----
(defun set-metadata ()
(let ((text (ps:@ (ps:chain document (get-element-by-id "metadata-input")) value)))
(api-post "/api/asteroid/dj/session/metadata"
(encode-params (ps:create :text text))
(lambda (data)
(when (= (ps:@ data status) "success")
(show-message "Metadata updated" "success"))))))
(defun clear-metadata ()
(setf (ps:@ (ps:chain document (get-element-by-id "metadata-input")) value) "")
(api-post "/api/asteroid/dj/session/metadata"
(encode-params (ps:create :text ""))
(lambda (data)
(when (= (ps:@ data status) "success")
(show-message "Metadata set to auto-detect" "success")))))
;; ---- Library Search ----
(defun search-library ()
(let ((query (ps:@ (ps:chain document (get-element-by-id "library-query")) value)))
(when (> (ps:@ query length) 0)
(api-get (+ "/api/asteroid/dj/library/search?q=" (encode-u-r-i-component query))
render-library-results))))
(defun render-library-results (data)
(let ((container (ps:chain document (get-element-by-id "library-results")))
(results (ps:@ data results)))
(if (and results (> (ps:@ results length) 0))
(let ((html "<table><tr><th>Artist</th><th>Title</th><th>Album</th><th>Load</th></tr>"))
(ps:chain results
(for-each
(lambda (track)
(setf html (+ html
"<tr>"
"<td>" (or (ps:@ track artist) "") "</td>"
"<td>" (or (ps:@ track title) "") "</td>"
"<td>" (or (ps:@ track album) "") "</td>"
"<td>"
"<button class='load-btn' onclick='loadTrack(\"a\", "
(ps:@ track id) ")'>A</button>"
"<button class='load-btn' onclick='loadTrack(\"b\", "
(ps:@ track id) ")'>B</button>"
"</td>"
"</tr>")))))
(setf html (+ html "</table>"))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l)
"<p style='color: #666; text-align: center;'>No results found</p>"))))
(defun load-track (deck track-id)
(api-post "/api/asteroid/dj/deck/load"
(encode-params (ps:create :deck deck :track-id track-id))
(lambda (data)
(if (= (ps:@ data status) "success")
(show-message (+ "Loaded onto Deck "
(ps:chain deck (to-upper-case)) ": "
(ps:@ data track-info display-title))
"success")
(show-message (or (ps:@ data message) "Load failed") "error")))))
;; ---- Expose functions to window ----
(setf (ps:@ window start-session) start-session)
(setf (ps:@ window end-session) end-session)
(setf (ps:@ window play-deck) play-deck)
(setf (ps:@ window pause-deck) pause-deck)
(setf (ps:@ window stop-deck) stop-deck)
(setf (ps:@ window seek-deck) seek-deck)
(setf (ps:@ window set-crossfader) set-crossfader)
(setf (ps:@ window set-deck-volume) set-deck-volume)
(setf (ps:@ window set-metadata) set-metadata)
(setf (ps:@ window clear-metadata) clear-metadata)
(setf (ps:@ window search-library) search-library)
(setf (ps:@ window load-track) load-track)
;; ---- Init ----
(defun init-dj-console ()
"Initialize the DJ console - check if a session is already active"
;; Check server-rendered state first, then poll for live status
(let ((active-val (ps:@ (ps:chain document (get-element-by-id "dj-active")) value)))
(if (= active-val "true")
(set-session-active true)
;; Also poll in case state changed between page render and load
(api-get "/api/asteroid/dj/session/status"
(lambda (data)
(when (ps:@ data active)
(set-session-active true)))))))
;; Run on page load
(ps:chain window (add-event-listener "load" init-dj-console))
))
"Compiled JavaScript for DJ console - generated at load time")
(defun generate-dj-console-js ()
"Return the pre-compiled JavaScript for DJ console"
*dj-console-js*)

316
template/dj-console.ctml Normal file
View File

@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title data-text="title">Asteroid Radio - DJ Console</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script lquery='(text (asteroid::get-auth-state-js-var))'></script>
<style>
/* DJ Console Styles — layered on top of asteroid.css */
.dj-console { max-width: 1200px; margin: 0 auto; }
.dj-header {
display: flex; justify-content: space-between; align-items: center;
border-bottom: 1px solid #00ffff33; padding-bottom: 10px; margin-bottom: 20px;
}
.dj-header h1 { margin: 0; font-size: 1.8rem; }
.dj-session-info { color: #4488FF; font-size: 1.1rem; }
.session-controls { display: flex; gap: 10px; align-items: center; }
.btn-go-live {
background: #00aa44; color: #000; border: none; padding: 8px 20px;
font-family: VT323, monospace; font-size: 1.2rem; cursor: pointer;
text-transform: uppercase; letter-spacing: 2px;
}
.btn-go-live:hover { background: #00ff66; }
.btn-go-live:disabled { background: #333; color: #666; cursor: not-allowed; }
.btn-end-session {
background: #cc2200; color: #fff; border: none; padding: 8px 20px;
font-family: VT323, monospace; font-size: 1.2rem; cursor: pointer;
text-transform: uppercase; letter-spacing: 2px;
}
.btn-end-session:hover { background: #ff3300; }
.live-indicator {
display: inline-block; width: 12px; height: 12px; border-radius: 50%;
background: #ff0000; margin-right: 8px;
animation: pulse-live 1s ease-in-out infinite;
}
@keyframes pulse-live {
0%, 100% { opacity: 1; box-shadow: 0 0 4px #ff0000; }
50% { opacity: 0.4; box-shadow: 0 0 12px #ff0000; }
}
/* Decks */
.decks-container {
display: grid; grid-template-columns: 1fr 1fr; gap: 20px;
margin-bottom: 20px;
}
.deck {
background: #0d1117; border: 1px solid #00ffff22;
padding: 15px; position: relative;
}
.deck.active { border-color: #00ffff66; }
.deck-label {
font-size: 1.4rem; color: #00ffff; margin-bottom: 10px;
display: flex; justify-content: space-between; align-items: center;
}
.deck-state { font-size: 0.9rem; color: #4488FF; text-transform: uppercase; }
.deck-track-info {
min-height: 60px; margin-bottom: 10px;
border-left: 3px solid #00ffff33; padding-left: 10px;
}
.deck-artist { color: #00ffff; font-size: 1.2rem; }
.deck-title { color: #aaa; font-size: 1.1rem; }
.deck-album { color: #666; font-size: 0.9rem; }
.deck-empty { color: #444; font-style: italic; }
.deck-transport {
display: flex; gap: 8px; margin-bottom: 10px;
}
.deck-transport button {
background: #1a2332; color: #00ffff; border: 1px solid #00ffff44;
padding: 6px 14px; font-family: VT323, monospace; font-size: 1.1rem;
cursor: pointer;
}
.deck-transport button:hover { background: #00ffff22; }
.deck-transport button:disabled { color: #444; border-color: #333; cursor: not-allowed; }
.deck-progress {
width: 100%; height: 8px; background: #1a2332; margin-bottom: 5px;
cursor: pointer; position: relative;
}
.deck-progress-fill {
height: 100%; background: #00ffff; width: 0%; transition: width 0.2s linear;
}
.deck-time {
display: flex; justify-content: space-between; color: #666;
font-size: 0.9rem; margin-bottom: 10px;
}
.deck-volume {
display: flex; align-items: center; gap: 10px;
}
.deck-volume label { color: #666; font-size: 0.9rem; min-width: 30px; }
.deck-volume input[type="range"] {
-webkit-appearance: none; appearance: none; flex: 1;
height: 4px; background: #1a2332; outline: none;
}
.deck-volume input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; background: #00ffff; cursor: pointer;
}
/* Crossfader */
.crossfader-section {
background: #0d1117; border: 1px solid #00ffff22;
padding: 15px; margin-bottom: 20px; text-align: center;
}
.crossfader-label {
display: flex; justify-content: space-between; color: #666;
margin-bottom: 5px; font-size: 0.9rem;
}
.crossfader-input {
-webkit-appearance: none; appearance: none; width: 100%;
height: 6px; background: #1a2332; outline: none;
}
.crossfader-input::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 20px; height: 20px; background: #4488FF; cursor: pointer;
}
/* Metadata Override */
.metadata-section {
background: #0d1117; border: 1px solid #00ffff22;
padding: 15px; margin-bottom: 20px;
display: flex; gap: 10px; align-items: center;
}
.metadata-section label { color: #666; white-space: nowrap; }
.metadata-section input[type="text"] {
flex: 1; background: #1a2332; border: 1px solid #00ffff33;
color: #00ffff; padding: 6px 10px; font-family: VT323, monospace;
font-size: 1rem;
}
.metadata-section button {
background: #1a2332; color: #4488FF; border: 1px solid #4488FF44;
padding: 6px 14px; font-family: VT323, monospace; font-size: 1rem;
cursor: pointer;
}
.metadata-section button:hover { background: #4488FF22; }
/* Library Search */
.library-section {
background: #0d1117; border: 1px solid #00ffff22; padding: 15px;
}
.library-section h2 { margin-top: 0; font-size: 1.3rem; color: #00ffff; }
.library-search {
display: flex; gap: 10px; margin-bottom: 15px;
}
.library-search input[type="text"] {
flex: 1; background: #1a2332; border: 1px solid #00ffff33;
color: #00ffff; padding: 8px 12px; font-family: VT323, monospace;
font-size: 1.1rem;
}
.library-search button {
background: #1a2332; color: #00ffff; border: 1px solid #00ffff44;
padding: 8px 16px; font-family: VT323, monospace; font-size: 1.1rem;
cursor: pointer;
}
.library-search button:hover { background: #00ffff22; }
.library-results { max-height: 400px; overflow-y: auto; }
.library-results table { width: 100%; border-collapse: collapse; }
.library-results th {
text-align: left; color: #666; border-bottom: 1px solid #00ffff22;
padding: 5px 8px; font-size: 0.9rem;
}
.library-results td {
padding: 5px 8px; border-bottom: 1px solid #0d1117;
font-size: 1rem; color: #aaa;
}
.library-results tr:hover td { background: #1a233244; }
.library-results .load-btn {
background: none; border: 1px solid #00ffff44; color: #00ffff;
padding: 2px 8px; font-family: VT323, monospace; font-size: 0.9rem;
cursor: pointer; margin: 0 2px;
}
.library-results .load-btn:hover { background: #00ffff22; }
.no-session-overlay {
position: relative; pointer-events: auto;
}
.no-session-overlay .decks-container,
.no-session-overlay .crossfader-section,
.no-session-overlay .metadata-section,
.no-session-overlay .library-section {
opacity: 0.3; pointer-events: none;
}
.dj-message {
padding: 10px; margin-bottom: 15px; border: 1px solid;
display: none;
}
.dj-message.error { border-color: #cc2200; color: #ff4444; }
.dj-message.success { border-color: #00aa44; color: #00ff66; }
</style>
</head>
<body>
<div class="container dj-console">
<div class="dj-header">
<h1>🎛️ DJ CONSOLE</h1>
<div class="session-controls">
<span id="session-info" class="dj-session-info"></span>
<button id="btn-go-live" class="btn-go-live" onclick="startSession()">GO LIVE</button>
<button id="btn-end-session" class="btn-end-session" onclick="endSession()" style="display:none">END SESSION</button>
</div>
</div>
<c:h>(asteroid::load-template "partial/navbar-admin")</c:h>
<div id="dj-message" class="dj-message"></div>
<div id="dj-controls" class="no-session-overlay">
<!-- Decks -->
<div class="decks-container">
<!-- Deck A -->
<div class="deck" id="deck-a-container">
<div class="deck-label">
<span>◉ DECK A</span>
<span class="deck-state" id="deck-a-state">EMPTY</span>
</div>
<div class="deck-track-info" id="deck-a-info">
<div class="deck-empty">No track loaded</div>
</div>
<div class="deck-transport">
<button onclick="playDeck('a')" id="deck-a-play" disabled>▶ PLAY</button>
<button onclick="pauseDeck('a')" id="deck-a-pause" disabled>⏸ PAUSE</button>
<button onclick="stopDeck('a')" id="deck-a-stop" disabled>■ STOP</button>
</div>
<div class="deck-progress" id="deck-a-progress-bar" onclick="seekDeck('a', event)">
<div class="deck-progress-fill" id="deck-a-progress"></div>
</div>
<div class="deck-time">
<span id="deck-a-position">0:00</span>
<span id="deck-a-duration">0:00</span>
</div>
<div class="deck-volume">
<label>VOL</label>
<input type="range" min="0" max="100" value="100"
oninput="setDeckVolume('a', this.value / 100)"
id="deck-a-volume">
</div>
</div>
<!-- Deck B -->
<div class="deck" id="deck-b-container">
<div class="deck-label">
<span>◎ DECK B</span>
<span class="deck-state" id="deck-b-state">EMPTY</span>
</div>
<div class="deck-track-info" id="deck-b-info">
<div class="deck-empty">No track loaded</div>
</div>
<div class="deck-transport">
<button onclick="playDeck('b')" id="deck-b-play" disabled>▶ PLAY</button>
<button onclick="pauseDeck('b')" id="deck-b-pause" disabled>⏸ PAUSE</button>
<button onclick="stopDeck('b')" id="deck-b-stop" disabled>■ STOP</button>
</div>
<div class="deck-progress" id="deck-b-progress-bar" onclick="seekDeck('b', event)">
<div class="deck-progress-fill" id="deck-b-progress"></div>
</div>
<div class="deck-time">
<span id="deck-b-position">0:00</span>
<span id="deck-b-duration">0:00</span>
</div>
<div class="deck-volume">
<label>VOL</label>
<input type="range" min="0" max="100" value="100"
oninput="setDeckVolume('b', this.value / 100)"
id="deck-b-volume">
</div>
</div>
</div>
<!-- Crossfader -->
<div class="crossfader-section">
<div class="crossfader-label">
<span>◉ DECK A</span>
<span>CROSSFADER</span>
<span>DECK B ◎</span>
</div>
<input type="range" class="crossfader-input" id="crossfader"
min="0" max="100" value="50"
oninput="setCrossfader(this.value / 100)">
</div>
<!-- Metadata Override -->
<div class="metadata-section">
<label>ICY METADATA:</label>
<input type="text" id="metadata-input" placeholder="Auto-detect from active deck">
<button onclick="setMetadata()">SET</button>
<button onclick="clearMetadata()">AUTO</button>
</div>
<!-- Library Search -->
<div class="library-section">
<h2>📚 LIBRARY</h2>
<div class="library-search">
<input type="text" id="library-query" placeholder="Search artist, title, album..."
onkeyup="if(event.key==='Enter')searchLibrary()">
<button onclick="searchLibrary()">SEARCH</button>
</div>
<div class="library-results" id="library-results">
<p style="color: #444; text-align: center;">Search the library to load tracks onto a deck</p>
</div>
</div>
</div>
</div>
<input type="hidden" id="dj-active" lquery='(val (** :dj-active))'>
<input type="hidden" id="dj-owner" lquery='(val (** :dj-owner))'>
<input type="hidden" id="dj-username" lquery='(val (** :username))'>
<script src="/asteroid/static/js/dj-console.js"></script>
</body>
</html>

View File

@ -20,6 +20,13 @@
Profile Profile
</a> </a>
</c:unless> </c:unless>
<c:when test='(and (not (asteroid::member-string "dj" (** :navbar-exclude)))
(asteroid::user-has-role-p current-user :dj))'>
<a href="/asteroid/dj"
lquery='(attr :target (when framesetp "_self"))'>
🎛️ DJ
</a>
</c:when>
<c:unless test='(asteroid::member-string "admin" (** :navbar-exclude))'> <c:unless test='(asteroid::member-string "admin" (** :navbar-exclude))'>
<a href="/asteroid/admin" <a href="/asteroid/admin"
lquery='(attr :target (when framesetp "_self"))'> lquery='(attr :target (when framesetp "_self"))'>