asteroid/dj-session.lisp

492 lines
21 KiB
Common Lisp

;;;; 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 immediately stopping all voices and clearing the queue.
The play-list thread will exit when it sees the empty queue and skip flag.
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:pipeline-current-track
*harmony-pipeline*))))
;; 1. Clear the queue so play-list has nothing to advance to
(cl-streamer:pipeline-clear-queue *harmony-pipeline*)
;; 2. Set skip flag so the play-list loop exits its wait
(cl-streamer:pipeline-skip *harmony-pipeline*)
;; 3. Immediately silence and stop all voices on the mixer
(cl-streamer:pipeline-stop-all-voices *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*))
;; Stop current track if playing
(when (member (deck-state deck) '(:playing :paused))
(stop-deck-internal deck))
;; Read metadata
(let* ((tags (cl-streamer:pipeline-read-metadata pipeline file-path))
(display-title (cl-streamer:pipeline-format-title pipeline 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*))
(ecase (deck-state deck)
(:empty
(error "Deck ~A has no track loaded" deck-id))
(:loaded
;; Create a new voice and start playing
(let ((voice (cl-streamer:pipeline-play-voice pipeline
(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*))
(handler-case
(cl-streamer:pipeline-stop-voice pipeline (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:pipeline-volume-ramp *harmony-pipeline* (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*))
(cl-streamer:pipeline-stop-voice pipeline (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:pipeline-update-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* ((pattern (format nil "%~A%" query))
(results
(postmodern:query
(:raw (format nil
"SELECT _id, title, artist, album, \"file-path\" FROM tracks WHERE (title ILIKE $1 OR artist ILIKE $1 OR album ILIKE $1) ORDER BY artist, title LIMIT ~A OFFSET ~A"
limit offset))
pattern
: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)))))