Compare commits
No commits in common. "e712009d794dcb13c839f47f4894d52b40a781a0" and "ef3e1eab47a8ff73dbc329a5ff63af881778d495" have entirely different histories.
e712009d79
...
ef3e1eab47
|
|
@ -64,14 +64,12 @@
|
|||
(:file "player")
|
||||
(:file "stream-player")
|
||||
(:file "frameset-utils")
|
||||
(:file "spectrum-analyzer")
|
||||
(:file "dj-console")))
|
||||
(:file "spectrum-analyzer")))
|
||||
(:file "stream-media")
|
||||
(:file "user-management")
|
||||
(:file "playlist-management")
|
||||
(:file "stream-control")
|
||||
(:file "stream-harmony")
|
||||
(:file "dj-session")
|
||||
(:file "playlist-scheduler")
|
||||
(:file "listener-stats")
|
||||
(:file "user-profile")
|
||||
|
|
|
|||
157
asteroid.lisp
157
asteroid.lisp
|
|
@ -558,138 +558,6 @@
|
|||
(api-output `(("status" . "success")
|
||||
("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)
|
||||
"Get a track by its ID - handles type mismatches"
|
||||
(dm:get-one "tracks" (db:query (:= '_id track-id))))
|
||||
|
|
@ -1041,16 +909,6 @@
|
|||
(format t "ERROR generating frameset-utils.js: ~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
|
||||
(t
|
||||
(serve-file (merge-pathnames (format nil "static/~a" path)
|
||||
|
|
@ -1085,21 +943,6 @@
|
|||
:stream-base-url *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)
|
||||
(define-page users-management #@"/admin/user" ()
|
||||
"User Management dashboard"
|
||||
|
|
|
|||
|
|
@ -23,11 +23,7 @@
|
|||
#:pipeline-on-playlist-change
|
||||
;; Metadata helpers
|
||||
#:read-audio-metadata
|
||||
#:format-display-title
|
||||
#:update-all-mounts-metadata
|
||||
;; DJ support
|
||||
#:pipeline-harmony-server
|
||||
#:volume-ramp))
|
||||
#:format-display-title))
|
||||
|
||||
(in-package #:cl-streamer/harmony)
|
||||
|
||||
|
|
|
|||
501
dj-session.lisp
501
dj-session.lisp
|
|
@ -1,501 +0,0 @@
|
|||
;;;; 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)))))
|
||||
|
|
@ -1,517 +0,0 @@
|
|||
#+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 <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.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
|
||||
<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.
|
||||
|
|
@ -1,348 +0,0 @@
|
|||
;;;; 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*)
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -20,13 +20,6 @@
|
|||
Profile
|
||||
</a>
|
||||
</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))'>
|
||||
<a href="/asteroid/admin"
|
||||
lquery='(attr :target (when framesetp "_self"))'>
|
||||
|
|
|
|||
Loading…
Reference in New Issue