Taglib metadata reading, crossfade metadata timing fix

- Add taglib dependency to cl-streamer/harmony system
- Add read-audio-metadata: reads artist/title/album from FLAC/MP3 tags
- Add format-display-title: builds 'Artist - Title' from tags, falls back to filename
- Add update-all-mounts-metadata: updates ICY metadata on all mount points
- Defer metadata update during crossfade until fade completes (listeners hear correct track)
- Fix play-list wait loop: was nested inside crossfade conditional, first track never waited
- Remove filename-derived :title from test playlist (taglib reads real tags now)
This commit is contained in:
Glenn Thompson 2026-03-03 21:10:44 +03:00
parent 2649a8169a
commit edf9326007
3 changed files with 82 additions and 45 deletions

View File

@ -25,7 +25,8 @@
#:harmony #:harmony
#:cl-mixed #:cl-mixed
#:cl-mixed-mpg123 #:cl-mixed-mpg123
#:cl-mixed-flac) #:cl-mixed-flac
#:taglib)
:components ((:file "harmony-backend"))) :components ((:file "harmony-backend")))
(asdf:defsystem #:cl-streamer/encoder (asdf:defsystem #:cl-streamer/encoder

View File

@ -156,21 +156,58 @@
(log:info "Audio pipeline stopped") (log:info "Audio pipeline stopped")
pipeline) pipeline)
(defun play-file (pipeline file-path &key (mixer :music) title (on-end :free)) (defun read-audio-metadata (file-path)
"Read metadata (artist, title, album) from an audio file using taglib.
Returns a plist (:artist ... :title ... :album ...) or NIL on failure."
(handler-case
(let ((audio-file (audio-streams:open-audio-file (namestring file-path))))
(list :artist (or (abstract-tag:artist audio-file) nil)
:title (or (abstract-tag:title audio-file) nil)
:album (or (abstract-tag:album audio-file) nil)))
(error (e)
(log:debug "Could not read tags from ~A: ~A" file-path e)
nil)))
(defun format-display-title (file-path &optional explicit-title)
"Build a display title for ICY metadata.
If EXPLICIT-TITLE is given, use it.
Otherwise read tags from the file: 'Artist - Title' or fall back to filename."
(or explicit-title
(let ((tags (read-audio-metadata file-path)))
(if tags
(let ((artist (getf tags :artist))
(title (getf tags :title)))
(cond ((and artist title (not (string= artist ""))
(not (string= title "")))
(format nil "~A - ~A" artist title))
(title title)
(artist artist)
(t (pathname-name (pathname file-path)))))
(pathname-name (pathname file-path))))))
(defun update-all-mounts-metadata (pipeline display-title)
"Update ICY metadata on all mount points."
(dolist (output (drain-outputs (pipeline-drain pipeline)))
(cl-streamer:set-now-playing (cdr output) display-title)))
(defun play-file (pipeline file-path &key (mixer :music) title (on-end :free)
(update-metadata t))
"Play an audio file through the pipeline. "Play an audio file through the pipeline.
The file will be decoded by Harmony and encoded for streaming. The file will be decoded by Harmony and encoded for streaming.
If TITLE is given, update ICY metadata with it. If TITLE is given, update ICY metadata with it.
Otherwise reads tags from the file via taglib.
FILE-PATH can be a string or pathname. FILE-PATH can be a string or pathname.
ON-END is passed to harmony:play (default :free)." ON-END is passed to harmony:play (default :free).
UPDATE-METADATA controls whether ICY metadata is updated immediately."
(let* ((path (pathname file-path)) (let* ((path (pathname file-path))
(server (pipeline-harmony-server pipeline)) (server (pipeline-harmony-server pipeline))
(harmony:*server* server) (harmony:*server* server)
(display-title (or title (pathname-name path)))) (display-title (format-display-title path title)))
;; Update ICY metadata so listeners see the track name (when update-metadata
(cl-streamer:set-now-playing (pipeline-mount-path pipeline) display-title) (update-all-mounts-metadata pipeline display-title))
(let ((voice (harmony:play path :mixer mixer :on-end on-end))) (let ((voice (harmony:play path :mixer mixer :on-end on-end)))
(log:info "Now playing: ~A" display-title) (log:info "Now playing: ~A" display-title)
voice))) (values voice display-title))))
(defun voice-remaining-seconds (voice) (defun voice-remaining-seconds (voice)
"Return estimated seconds remaining for a voice, or NIL if unknown." "Return estimated seconds remaining for a voice, or NIL if unknown."
@ -214,38 +251,41 @@
(values entry nil)) (values entry nil))
(handler-case (handler-case
(let* ((server (pipeline-harmony-server pipeline)) (let* ((server (pipeline-harmony-server pipeline))
(harmony:*server* server) (harmony:*server* server))
(voice (play-file pipeline path :title title (multiple-value-bind (voice display-title)
:on-end :disconnect))) (play-file pipeline path :title title
(when voice :on-end :disconnect
;; If this isn't the first track, fade in from 0 :update-metadata (null prev-voice))
(when (and prev-voice (> idx 0)) (when voice
(setf (mixed:volume voice) 0.0) ;; If this isn't the first track, crossfade
;; Fade in new voice and fade out old voice in parallel (when (and prev-voice (> idx 0))
(let ((fade-thread (setf (mixed:volume voice) 0.0)
(bt:make-thread ;; Fade in new voice and fade out old voice in parallel
(lambda () (let ((fade-thread
(volume-ramp prev-voice 0.0 fade-out) (bt:make-thread
(harmony:stop prev-voice)) (lambda ()
:name "cl-streamer-fadeout"))) (volume-ramp prev-voice 0.0 fade-out)
(volume-ramp voice 1.0 fade-in) (harmony:stop prev-voice))
(bt:join-thread fade-thread))) :name "cl-streamer-fadeout")))
;; Wait for track to approach its end (volume-ramp voice 1.0 fade-in)
(sleep 0.5) ; let decoder start (bt:join-thread fade-thread))
(loop while (and (pipeline-running-p pipeline) ;; Now the crossfade is done, update metadata
(not (mixed:done-p voice))) (update-all-mounts-metadata pipeline display-title))
for remaining = (voice-remaining-seconds voice) ;; Wait for track to approach its end
;; Start crossfade when we're within crossfade-duration of the end (sleep 0.5)
when (and remaining (loop while (and (pipeline-running-p pipeline)
(<= remaining crossfade-duration) (not (mixed:done-p voice)))
(not (mixed:done-p voice))) for remaining = (voice-remaining-seconds voice)
do (setf prev-voice voice) when (and remaining
(return) ; break out to start next track (<= remaining crossfade-duration)
do (sleep 0.1)) (not (mixed:done-p voice)))
;; If track ended naturally (no crossfade), clean up do (setf prev-voice voice)
(when (mixed:done-p voice) (return)
(harmony:stop voice) do (sleep 0.1))
(setf prev-voice nil)))) ;; If track ended naturally (no crossfade), clean up
(when (mixed:done-p voice)
(harmony:stop voice)
(setf prev-voice nil)))))
(error (e) (error (e)
(log:warn "Error playing ~A: ~A" path e) (log:warn "Error playing ~A: ~A" path e)
(sleep 1))))) (sleep 1)))))

View File

@ -60,18 +60,14 @@
(let ((files nil)) (let ((files nil))
(dolist (dir (directory (merge-pathnames "*/" *music-dir*))) (dolist (dir (directory (merge-pathnames "*/" *music-dir*)))
(dolist (flac (directory (merge-pathnames "**/*.flac" dir))) (dolist (flac (directory (merge-pathnames "**/*.flac" dir)))
(push (list :file (namestring flac) (push (list :file (namestring flac)) files)))
:title (format nil "~A - ~A"
(car (last (pathname-directory flac)))
(pathname-name flac)))
files)))
;; Shuffle and take first 10 tracks ;; Shuffle and take first 10 tracks
(subseq (alexandria:shuffle (copy-list files)) (subseq (alexandria:shuffle (copy-list files))
0 (min 10 (length files))))) 0 (min 10 (length files)))))
(format t "Queued ~A tracks:~%" (length *playlist*)) (format t "Queued ~A tracks:~%" (length *playlist*))
(dolist (entry *playlist*) (dolist (entry *playlist*)
(format t " ~A~%" (getf entry :title))) (format t " ~A~%" (getf entry :file)))
;; 6. Start playlist playback ;; 6. Start playlist playback
(format t "~%[6] Starting playlist...~%") (format t "~%[6] Starting playlist...~%")