492 lines
21 KiB
Common 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)))))
|