int16 pack encoding + fix playlist resume across schedule changes

- Replace default float packer with int16 packer (mixed:make-packer :encoding :int16)
  cl-mixed now handles float→s16 conversion in optimized C code instead of
  per-sample Lisp loop. Halves pack buffer memory (2 vs 4 bytes/sample).
- Remove float-to-s16 helper (no longer needed)
- Fix resume-from-saved-state: when saved playlist differs from currently
  scheduled playlist, use the scheduled one from the beginning instead of
  continuing the old playlist. Prevents stale playlist playing after restart.
This commit is contained in:
Glenn Thompson 2026-03-06 18:56:10 +03:00
parent df9d939a2f
commit bcfda2ebb6
2 changed files with 67 additions and 50 deletions

View File

@ -55,30 +55,23 @@
(defmethod mixed:start ((drain streaming-drain))) (defmethod mixed:start ((drain streaming-drain)))
(declaim (inline float-to-s16))
(defun float-to-s16 (sample)
"Convert a float sample (-1.0 to 1.0) to signed 16-bit integer."
(let ((clamped (max -1.0 (min 1.0 sample))))
(the (signed-byte 16) (round (* clamped 32767.0)))))
(defmethod mixed:mix ((drain streaming-drain)) (defmethod mixed:mix ((drain streaming-drain))
"Read interleaved float PCM from the pack buffer, encode to all outputs. "Read interleaved s16 PCM from the pack buffer, encode to all outputs.
The pack buffer is (unsigned-byte 8) with IEEE 754 single-floats (4 bytes each). The pack is created with :encoding :int16, so cl-mixed converts floats16 in C.
Layout: L0b0 L0b1 L0b2 L0b3 R0b0 R0b1 R0b2 R0b3 L1b0 ... (interleaved stereo)" Layout: L0lo L0hi R0lo R0hi L1lo L1hi R1lo R1hi ... (interleaved stereo, 2 bytes/sample)"
(mixed:with-buffer-tx (data start size (mixed:pack drain)) (mixed:with-buffer-tx (data start size (mixed:pack drain))
(when (> size 0) (when (> size 0)
(let* ((channels (drain-channels drain)) (let* ((channels (drain-channels drain))
(bytes-per-sample 4) ; single-float = 4 bytes (bytes-per-sample 2) ; int16 = 2 bytes
(total-floats (floor size bytes-per-sample)) (total-samples (floor size bytes-per-sample))
(num-samples (floor total-floats channels)) (num-samples (floor total-samples channels))
(pcm-buffer (make-array (* num-samples channels) (pcm-buffer (make-array (* num-samples channels)
:element-type '(signed-byte 16)))) :element-type '(signed-byte 16))))
;; Convert raw bytes -> single-float -> signed-16 ;; Read s16 PCM directly — no conversion needed, cl-mixed did it
(cffi:with-pointer-to-vector-data (ptr data) (cffi:with-pointer-to-vector-data (ptr data)
(loop for i below (* num-samples channels) (loop for i below (* num-samples channels)
for byte-offset = (+ start (* i bytes-per-sample)) for byte-offset = (+ start (* i bytes-per-sample))
for sample = (cffi:mem-ref ptr :float byte-offset) do (setf (aref pcm-buffer i) (cffi:mem-ref ptr :int16 byte-offset))))
do (setf (aref pcm-buffer i) (float-to-s16 sample))))
;; Feed PCM to all encoder/mount pairs ;; Feed PCM to all encoder/mount pairs
(dolist (output (drain-outputs drain)) (dolist (output (drain-outputs drain))
(let ((encoder (car output)) (let ((encoder (car output))
@ -91,7 +84,7 @@
(log:warn "Encode error for ~A: ~A" mount-path e))))))) (log:warn "Encode error for ~A: ~A" mount-path e)))))))
;; Sleep for most of the audio duration (leave headroom for encoding) ;; Sleep for most of the audio duration (leave headroom for encoding)
(let* ((channels (drain-channels drain)) (let* ((channels (drain-channels drain))
(bytes-per-frame (* channels 4)) (bytes-per-frame (* channels 2)) ; 2 bytes per sample (int16)
(frames (floor size bytes-per-frame)) (frames (floor size bytes-per-frame))
(samplerate (mixed:samplerate (mixed:pack drain)))) (samplerate (mixed:samplerate (mixed:pack drain))))
(when (> frames 0) (when (> frames 0)
@ -175,16 +168,23 @@
:output-channels (pipeline-channels pipeline))) :output-channels (pipeline-channels pipeline)))
(output (harmony:segment :output server)) (output (harmony:segment :output server))
(old-drain (harmony:segment :drain output)) (old-drain (harmony:segment :drain output))
(pack (mixed:pack old-drain))
(drain (pipeline-drain pipeline))) (drain (pipeline-drain pipeline)))
;; TODO: Investigate setting (mixed:encoding pack) :int16 to let cl-mixed ;; Replace the default float packer with an int16 packer.
;; handle float→s16 in C. Currently causes static — may need to be set ;; cl-mixed handles float→s16 conversion in C (faster than our Lisp loop).
;; before server start, or pack may need recreation with correct encoding. (let* ((old-packer (harmony:segment :packer output))
;; Wire our streaming drain to the same pack buffer (new-packer (mixed:make-packer
(setf (mixed:pack drain) pack) :encoding :int16
;; Swap: withdraw old dummy drain, add our streaming drain :channels (pipeline-channels pipeline)
(mixed:withdraw old-drain output) :samplerate (pipeline-sample-rate pipeline)
(mixed:add drain output) :frames (* 2 (harmony::buffersize server)))))
;; Connect upmix → new packer (same wiring as old)
(harmony:connect (harmony:segment :upmix output) T new-packer T)
;; Withdraw old packer and float drain, add new int16 packer and our drain
(mixed:withdraw old-drain output)
(mixed:withdraw old-packer output)
(mixed:add new-packer output)
(setf (mixed:pack drain) (mixed:pack new-packer))
(mixed:add drain output))
(setf (pipeline-harmony-server pipeline) server) (setf (pipeline-harmony-server pipeline) server)
(mixed:start server)) (mixed:start server))
(setf (pipeline-running-p pipeline) t) (setf (pipeline-running-p pipeline) t)

View File

@ -60,41 +60,58 @@
(defun resume-from-saved-state () (defun resume-from-saved-state ()
"Load saved playback state, resolve the correct playlist, and return "Load saved playback state, resolve the correct playlist, and return
(values file-list playlist-path) starting after the saved track. (values file-list playlist-path) starting after the saved track.
If the currently scheduled playlist differs from the saved one,
uses the scheduled playlist from the beginning instead.
Returns NIL if no state or playlist found." Returns NIL if no state or playlist found."
(let ((state (load-playback-state))) (let ((state (load-playback-state)))
(when state (when state
(let* ((saved-file (getf state :track-file)) (let* ((saved-file (getf state :track-file))
(saved-playlist (getf state :playlist)) (saved-playlist (getf state :playlist))
;; Use saved playlist if it exists, otherwise fall back to current scheduled (saved-playlist-name (when saved-playlist
(playlist-path (or (and saved-playlist (file-namestring (pathname saved-playlist))))
(probe-file (pathname saved-playlist))) ;; Check what should be playing right now
(let ((scheduled (get-current-scheduled-playlist))) (scheduled-name (get-current-scheduled-playlist))
(when scheduled (scheduled-path (when scheduled-name
(let ((p (merge-pathnames scheduled (get-playlists-directory)))) (let ((p (merge-pathnames scheduled-name (get-playlists-directory))))
(probe-file p)))))) (probe-file p))))
;; If scheduled playlist differs from saved, use scheduled (start fresh)
(playlist-changed-p (and scheduled-name saved-playlist-name
(not (string= scheduled-name saved-playlist-name))))
(playlist-path (if playlist-changed-p
scheduled-path
(or (and saved-playlist
(probe-file (pathname saved-playlist)))
scheduled-path)))
(file-list (when playlist-path (file-list (when playlist-path
(m3u-to-file-list playlist-path)))) (m3u-to-file-list playlist-path))))
(when file-list (when file-list
(setf *current-playlist-path* playlist-path) (setf *current-playlist-path* playlist-path)
(setf *resumed-from-saved-state* t) (setf *resumed-from-saved-state* t)
(let ((pos (when saved-file (if playlist-changed-p
(position saved-file file-list :test #'string=)))) ;; Different playlist should be active — start from beginning
(if pos (progn
(let ((remaining (nthcdr (1+ pos) file-list))) (log:info "Scheduled playlist changed: ~A -> ~A, starting from beginning"
(if remaining saved-playlist-name scheduled-name)
(progn (values file-list playlist-path))
(log:info "Resuming after track ~A (~A of ~A) from ~A" ;; Same playlist — resume from saved position
(file-namestring saved-file) (1+ pos) (length file-list) (let ((pos (when saved-file
(file-namestring playlist-path)) (position saved-file file-list :test #'string=))))
(values remaining playlist-path)) (if pos
(progn (let ((remaining (nthcdr (1+ pos) file-list)))
(log:info "Last saved track was final, restarting ~A" (if remaining
(file-namestring playlist-path)) (progn
(values file-list playlist-path)))) (log:info "Resuming after track ~A (~A of ~A) from ~A"
(progn (file-namestring saved-file) (1+ pos) (length file-list)
(log:info "Saved track not found, starting ~A from beginning" (file-namestring playlist-path))
(file-namestring playlist-path)) (values remaining playlist-path))
(values file-list playlist-path))))))))) (progn
(log:info "Last saved track was final, restarting ~A"
(file-namestring playlist-path))
(values file-list playlist-path))))
(progn
(log:info "Saved track not found, starting ~A from beginning"
(file-namestring playlist-path))
(values file-list playlist-path))))))))))
;;; ---- M3U Playlist Loading ---- ;;; ---- M3U Playlist Loading ----