diff --git a/asteroid.asd b/asteroid.asd index a276aa8..07ab9f5 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -64,12 +64,14 @@ (:file "player") (:file "stream-player") (:file "frameset-utils") - (:file "spectrum-analyzer"))) + (:file "spectrum-analyzer") + (:file "dj-console"))) (: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") diff --git a/asteroid.lisp b/asteroid.lisp index 1304505..07b0bfb 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -558,6 +558,138 @@ (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)))) @@ -909,6 +1041,16 @@ (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) @@ -943,6 +1085,21 @@ :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" diff --git a/cl-streamer/harmony-backend.lisp b/cl-streamer/harmony-backend.lisp index b6746d4..b907f6e 100644 --- a/cl-streamer/harmony-backend.lisp +++ b/cl-streamer/harmony-backend.lisp @@ -23,7 +23,11 @@ #:pipeline-on-playlist-change ;; Metadata helpers #: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) diff --git a/dj-session.lisp b/dj-session.lisp new file mode 100644 index 0000000..4d30b30 --- /dev/null +++ b/dj-session.lisp @@ -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))))) diff --git a/parenscript/dj-console.lisp b/parenscript/dj-console.lisp new file mode 100644 index 0000000..4aa73f0 --- /dev/null +++ b/parenscript/dj-console.lisp @@ -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) + (+ "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) + (+ "
" + (or (ps:@ track artist) "") "
" + "
" + (or (ps:@ track title) "") "
" + "
" + (or (ps:@ track album) "") "
")) + (setf (ps:@ info-el inner-h-t-m-l) + "
No track loaded
"))) + ;; 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) "
No track loaded
") + (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 "")) + (ps:chain results + (for-each + (lambda (track) + (setf html (+ html + "" + "" + "" + "" + "" + ""))))) + (setf html (+ html "
ArtistTitleAlbumLoad
" (or (ps:@ track artist) "") "" (or (ps:@ track title) "") "" (or (ps:@ track album) "") "" + "" + "" + "
")) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) + "

No results found

")))) + + (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*) diff --git a/template/dj-console.ctml b/template/dj-console.ctml new file mode 100644 index 0000000..cc6b424 --- /dev/null +++ b/template/dj-console.ctml @@ -0,0 +1,316 @@ + + + + Asteroid Radio - DJ Console + + + + + + + +
+
+

🎛️ DJ CONSOLE

+
+ + + +
+
+ + (asteroid::load-template "partial/navbar-admin") + +
+ +
+ +
+ +
+
+ ◉ DECK A + EMPTY +
+
+
No track loaded
+
+
+ + + +
+
+
+
+
+ 0:00 + 0:00 +
+
+ + +
+
+ + +
+
+ ◎ DECK B + EMPTY +
+
+
No track loaded
+
+
+ + + +
+
+
+
+
+ 0:00 + 0:00 +
+
+ + +
+
+
+ + +
+
+ ◉ DECK A + CROSSFADER + DECK B ◎ +
+ +
+ + + + + +
+

📚 LIBRARY

+ +
+

Search the library to load tracks onto a deck

+
+
+
+
+ + + + + + + diff --git a/template/partial/navbar-admin.ctml b/template/partial/navbar-admin.ctml index 6ea9856..a191d3b 100644 --- a/template/partial/navbar-admin.ctml +++ b/template/partial/navbar-admin.ctml @@ -20,6 +20,13 @@ Profile + + + 🎛️ DJ + +