Compare commits

..

No commits in common. "edf9326007a8668ddd45f245ecc47c3a5a190e28" and "fcda723577a8b794ab1d98ad2de999099f050050" have entirely different histories.

9 changed files with 203 additions and 703 deletions

View File

@ -1,295 +1,69 @@
#+TITLE: CL-Streamer #+TITLE: CL-Streamer
#+AUTHOR: Glenn Thompson #+AUTHOR: Glenn Thompson
#+DATE: 2026-02-03 #+DATE: 2026-15-02
* Overview * Overview
CL-Streamer is a native Common Lisp audio streaming server built to replace CL-Streamer is a Common Lisp audio streaming server designed to replace
the Icecast + Liquidsoap stack in [[https://asteroid.radio][Asteroid Radio]]. It provides HTTP audio Icecast and Liquidsoap in the Asteroid Radio project.
streaming with ICY metadata, multi-format encoding (MP3 and AAC), and
real-time audio processing via [[https://shirakumo.github.io/harmony/][Harmony]] and [[https://shirakumo.github.io/cl-mixed/][cl-mixed]] — all in a single Lisp
process.
The goal is to eliminate the Docker/C service dependencies (Icecast, Liquidsoap) * Status
and bring the entire audio pipeline under the control of the Lisp application.
This means playlist management, stream encoding, metadata, listener stats, and
the web frontend all live in one process and can interact directly.
* Why Replace Icecast + Liquidsoap? *EXPERIMENTAL* - This is an early proof-of-concept.
The current Asteroid Radio stack runs three separate services in Docker: * Features
- *Liquidsoap* — reads the playlist, decodes audio, applies crossfade, - HTTP streaming with ICY metadata protocol
encodes to MP3/AAC, pushes to Icecast - Multiple mount points
- *Icecast* — receives encoded streams, serves them to listeners over HTTP - Thread-safe ring buffers for audio data
with ICY metadata - Listener statistics
- *PostgreSQL* — stores playlist state, listener stats, etc.
This works, but has significant friction:
- *Operational complexity* — three Docker containers to manage, with
inter-service communication over HTTP and Telnet
- *Playlist control* — Liquidsoap reads an M3U file; the Lisp app writes it
and pokes Liquidsoap via Telnet to reload. Clunky round-trip for
something that should be a function call
- *Metadata* — requires polling Icecast's admin XML endpoint to get
listener stats, then correlating with our own data
- *Crossfade/transitions* — configured in Liquidsoap's DSL, not
accessible to the application logic
- *No live mixing* — adding DJ input or live mixing requires more
Liquidsoap configuration and another audio path
With CL-Streamer, the Lisp process *is* the streaming server:
- =play-list= and =play-file= are function calls, not Telnet commands
- ICY metadata updates are immediate — =(set-now-playing mount title)=
- Listener connections are tracked in-process, no XML polling needed
- Crossfade parameters can be changed at runtime
- Future: live DJ input via Harmony's mixer, with the same encoder pipeline
* Architecture
#+begin_example
┌──────────────────────────────────────────────────────────┐
│ Lisp Process │
│ │
│ ┌─────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Harmony │───▶│ streaming- │───▶│ MP3 Encoder │ │
│ │ (decode, │ │ drain │ │ (LAME FFI) │─┼──▶ /stream.mp3
│ │ mix, │ │ (float→s16 │ └─────────────────┘ │
│ │ effects)│ │ conversion) │ ┌─────────────────┐ │
│ └─────────┘ └──────────────┘───▶│ AAC Encoder │ │
│ ▲ │ (FDK-AAC shim) │─┼──▶ /stream.aac
│ │ └─────────────────┘ │
│ ┌─────────┐ ┌─────────────────┐ │
│ │ Playlist │ ICY metadata ──────▶│ HTTP Server │ │
│ │ Manager │ Listener stats ◀────│ (usocket) │ │
│ └─────────┘ └─────────────────┘ │
└──────────────────────────────────────────────────────────┘
#+end_example
** Harmony Integration
[[https://shirakumo.github.io/harmony/][Harmony]] is Shinmera's Common Lisp audio framework built on top of cl-mixed.
It handles audio decoding (FLAC, MP3, OGG, etc.), sample rate conversion,
mixing, and effects processing.
CL-Streamer connects to Harmony by replacing the default audio output drain
with a custom =streaming-drain= that intercepts the mixed audio data.
Instead of sending PCM to a sound card, we:
1. Read interleaved IEEE 754 single-float samples from Harmony's pack buffer
2. Convert to signed 16-bit PCM
3. Feed to all registered encoders (MP3 via LAME, AAC via FDK-AAC)
4. Write encoded bytes to per-mount broadcast ring buffers
5. HTTP clients read from these buffers with burst-on-connect
Both voices (e.g., during crossfade) play through the same Harmony mixer
and are automatically summed before reaching the drain — the encoders see
a single mixed signal.
** The FDK-AAC C Shim
SBCL's signal handlers conflict with FDK-AAC's internal memory access patterns.
When FDK-AAC touches certain memory during =aacEncOpen= or =aacEncEncode=,
SBCL's SIGSEGV handler intercepts it before FDK-AAC's own handler can run,
causing a recursive signal fault.
The solution is a thin C shim (=fdkaac-shim.c=, compiled to =libfdkaac-shim.so=)
that wraps all FDK-AAC calls:
- =fdkaac_open_and_init= — opens the encoder, sets all parameters (AOT,
sample rate, channels, bitrate, transport format, afterburner), and
runs the initialisation call, all from C
- =fdkaac_encode= — sets up the buffer descriptors (=AACENC_BufDesc=) and
calls =aacEncEncode=, returning encoded ADTS frames
- =fdkaac_close= — closes the encoder handle
The Lisp side calls these via CFFI and never touches FDK-AAC directly.
This avoids the signal handler conflict entirely.
Note: one subtle bug was that FDK-AAC's =OUT_BITSTREAM_DATA= constant is
=3=, not =1=. Getting this wrong causes =aacEncEncode= to return error 96
with no useful error message. The fix was to use the proper enum constants
from =aacenc_lib.h= rather than hardcoded integers.
Additionally, the AAC encoder uses a PCM accumulation buffer to feed
FDK-AAC exact =frameLength=-sized chunks (1024 frames for AAC-LC). Feeding
arbitrary chunk sizes from Harmony's real-time callback produces audio
artefacts.
To rebuild the shim:
#+begin_src sh
gcc -shared -fPIC -o libfdkaac-shim.so fdkaac-shim.c -lfdk-aac
#+end_src
** Broadcast Buffer
Each mount point has a ring buffer (=broadcast-buffer=) that acts as a
single-producer, multi-consumer queue. The encoder writes encoded audio
in, and each connected client reads from its own position.
- Never blocks the producer — slow clients lose data rather than stalling
the encoder
- Burst-on-connect — new clients receive ~4 seconds of recent audio
immediately for fast playback start
- Condition variable signalling for efficient client wakeup
** ICY Metadata Protocol
The server implements the SHOUTcast/Icecast ICY metadata protocol:
- Responds to =Icy-MetaData: 1= requests with metadata-interleaved streams
- Injects metadata blocks at the configured =metaint= byte interval
- =set-now-playing= updates the metadata for a mount, picked up by all
connected clients on their next metadata interval
* Current Status
*Working and tested:*
- [X] HTTP streaming server with multiple mount points
- [X] MP3 encoding via LAME (128kbps, configurable)
- [X] AAC encoding via FDK-AAC with C shim (128kbps ADTS, configurable)
- [X] Harmony audio backend with custom streaming drain
- [X] Real-time float→s16 PCM conversion and dual-encoder output
- [X] ICY metadata protocol (set-now-playing on track change)
- [X] Broadcast ring buffer with burst-on-connect
- [X] Sequential playlist playback with reliable track-end detection
- [X] Crossfade between tracks (3s overlap, 2s fade-in/out)
- [X] Multi-format simultaneous output (MP3 + AAC from same source)
*Remaining work:*
- [ ] Read file metadata (artist, title, album) from FLAC/MP3 tags via
cl-taglib and feed to ICY metadata (currently uses filename)
- [ ] Flush AAC accumulation buffer at track boundaries for clean transitions
- [ ] Integration with Asteroid Radio's playlist/queue system (replace
Liquidsoap Telnet control with direct function calls)
- [ ] Integration with Asteroid Radio's listener statistics (replace
Icecast admin XML polling with in-process tracking)
- [ ] Live DJ input via Harmony mixer (replace Liquidsoap's input sources)
- [ ] Stream quality variants (low bitrate MP3, shuffle stream, etc.)
- [ ] Robustness: auto-restart on encoder errors, watchdog, graceful
degradation
* Integration with Asteroid Radio
The plan is to load CL-Streamer as an ASDF system within the main Asteroid
Radio Lisp image. The web application (Radiance) and the streaming server
share the same process:
- *Playlist control* — the web app's queue management calls =play-list=
and =play-file= directly, no IPC needed
- *Now playing* — track changes call =set-now-playing=, which is
immediately visible to all connected ICY-metadata clients
- *Listener stats* — the stream server tracks connections in-process;
the web app reads them directly for the admin dashboard
- *Tag metadata* — cl-taglib (already a dependency of Asteroid) reads
FLAC/MP3 tags and passes artist/title/album to ICY metadata
This eliminates the Docker containers for Icecast and Liquidsoap entirely.
The only external service is PostgreSQL for persistent data.
* File Structure
| File | Purpose |
|----------------------+--------------------------------------------------|
| =cl-streamer.asd= | ASDF system definition |
| =package.lisp= | Package and exports |
| =cl-streamer.lisp= | Top-level API (start, stop, add-mount, etc.) |
| =stream-server.lisp=| HTTP server, client connections, ICY responses |
| =buffer.lisp= | Broadcast ring buffer (single-producer, multi-consumer) |
| =icy-protocol.lisp= | ICY metadata encoding and injection |
| =conditions.lisp= | Error condition types |
| =encoder.lisp= | MP3 encoder (LAME wrapper) |
| =lame-ffi.lisp= | CFFI bindings for libmp3lame |
| =aac-encoder.lisp= | AAC encoder with frame accumulation buffer |
| =fdkaac-ffi.lisp= | CFFI bindings for FDK-AAC (via shim) |
| =fdkaac-shim.c= | C shim for FDK-AAC (avoids SBCL signal conflicts)|
| =libfdkaac-shim.so= | Compiled shim shared library |
| =harmony-backend.lisp= | Harmony integration: streaming-drain, crossfade, playlist |
| =test-stream.lisp= | End-to-end test: playlist with MP3 + AAC output |
* Dependencies * Dependencies
** Lisp Libraries - alexandria
- bordeaux-threads
- usocket
- flexi-streams
- chunga
- log4cl
- split-sequence
- [[https://github.com/Shinmera/harmony][harmony]] — audio framework (decode, mix, effects) Optional (for audio backend):
- [[https://github.com/Shinmera/cl-mixed][cl-mixed]] — low-level audio mixing - harmony
- [[https://github.com/Shinmera/cl-mixed-flac][cl-mixed-flac]] — FLAC decoding - cl-mixed
- [[https://github.com/Shinmera/cl-mixed-mpg123][cl-mixed-mpg123]] — MP3 decoding - cl-mixed-mpg123
- [[https://common-lisp.net/project/cffi/][cffi]] — C foreign function interface
- [[https://github.com/usocket/usocket][usocket]] — socket networking
- [[https://edicl.github.io/flexi-streams/][flexi-streams]] — flexible stream types
- [[https://github.com/sharplispers/log4cl][log4cl]] — logging
- [[https://gitlab.common-lisp.net/alexandria/alexandria][alexandria]] — utility library
- [[https://sionescu.github.io/bordeaux-threads/][bordeaux-threads]] — portable threading
** C Libraries
- [[https://lame.sourceforge.io/][libmp3lame]] — MP3 encoding
- [[https://github.com/mstorsjo/fdk-aac][libfdk-aac]] — AAC encoding (via C shim)
* Quick Start * Quick Start
#+begin_src common-lisp #+begin_src common-lisp
;; Load the systems (ql:quickload :cl-streamer)
(ql:quickload '(:cl-streamer :cl-streamer/encoder
:cl-streamer/aac-encoder :cl-streamer/harmony))
;; Start the HTTP server ;; Create and start server
(cl-streamer:start :port 8000) (cl-streamer:start :port 8000)
;; Add mount points ;; Add a mount point
(cl-streamer:add-mount cl-streamer:*server* "/stream.mp3" (cl-streamer:add-mount cl-streamer:*server* "/stream.mp3"
:content-type "audio/mpeg" :content-type "audio/mpeg"
:bitrate 128 :bitrate 128
:name "Asteroid Radio MP3") :name "Asteroid Radio")
(cl-streamer:add-mount cl-streamer:*server* "/stream.aac"
:content-type "audio/aac"
:bitrate 128
:name "Asteroid Radio AAC")
;; Create encoders
(defvar *mp3* (cl-streamer:make-mp3-encoder :sample-rate 44100
:channels 2
:bitrate 128))
(defvar *aac* (cl-streamer:make-aac-encoder :sample-rate 44100
:channels 2
:bitrate 128000))
;; Create pipeline with both outputs
(defvar *pipeline* (cl-streamer/harmony:make-audio-pipeline
:encoder *mp3*
:stream-server cl-streamer:*server*
:mount-path "/stream.mp3"))
(cl-streamer/harmony:add-pipeline-output *pipeline* *aac* "/stream.aac")
(cl-streamer/harmony:start-pipeline *pipeline*)
;; Play a playlist with crossfade
(cl-streamer/harmony:play-list *pipeline* '("/path/to/track1.flac"
"/path/to/track2.flac")
:crossfade-duration 3.0
:fade-in 2.0
:fade-out 2.0)
;; Or play individual files
(cl-streamer/harmony:play-file *pipeline* "/path/to/track.flac"
:title "Artist - Track Name")
;; Update now-playing metadata ;; Update now-playing metadata
(cl-streamer:set-now-playing "/stream.mp3" "Artist - Track Title") (cl-streamer:set-now-playing "/stream.mp3" "Artist - Track Title")
;; Write audio data (from encoder)
(cl-streamer:write-audio-data "/stream.mp3" encoded-mp3-bytes)
;; Check listeners ;; Check listeners
(cl-streamer:get-listener-count) (cl-streamer:get-listener-count)
;; Stop everything ;; Stop server
(cl-streamer/harmony:stop-pipeline *pipeline*)
(cl-streamer:stop) (cl-streamer:stop)
#+end_src #+end_src
* Architecture
See =docs/CL-STREAMING-ARCHITECTURE.org= for the full design document.
* License * License
AGPL-3.0 AGPL-3.0

View File

@ -8,11 +8,7 @@
(aot :initarg :aot :accessor aac-encoder-aot :initform :aot-aac-lc) (aot :initarg :aot :accessor aac-encoder-aot :initform :aot-aac-lc)
(out-buffer :initform nil :accessor aac-encoder-out-buffer) (out-buffer :initform nil :accessor aac-encoder-out-buffer)
(out-buffer-size :initform (* 1024 8) :accessor aac-encoder-out-buffer-size) (out-buffer-size :initform (* 1024 8) :accessor aac-encoder-out-buffer-size)
(frame-length :initform 1024 :accessor aac-encoder-frame-length) (frame-length :initform 1024 :accessor aac-encoder-frame-length)))
(pcm-accum :initform nil :accessor aac-encoder-pcm-accum
:documentation "Accumulation buffer for PCM samples (signed-byte 16), frame-length * channels elements.")
(pcm-accum-pos :initform 0 :accessor aac-encoder-pcm-accum-pos
:documentation "Number of samples currently accumulated.")))
(defun make-aac-encoder (&key (sample-rate 44100) (channels 2) (bitrate 128000)) (defun make-aac-encoder (&key (sample-rate 44100) (channels 2) (bitrate 128000))
"Create an AAC encoder with the specified parameters. "Create an AAC encoder with the specified parameters.
@ -25,113 +21,108 @@
encoder)) encoder))
(defun initialize-aac-encoder (encoder) (defun initialize-aac-encoder (encoder)
"Initialize the FDK-AAC encoder with current settings. "Initialize the FDK-AAC encoder with current settings."
Uses C shim to avoid SBCL signal handler conflicts with FDK-AAC." (cffi:with-foreign-object (handle-ptr :pointer)
(cffi:with-foreign-objects ((handle-ptr :pointer) (let ((result (aac-enc-open handle-ptr 0 (aac-encoder-channels encoder))))
(frame-length-ptr :int)
(max-out-bytes-ptr :int))
(let ((result (fdkaac-open-and-init handle-ptr
(aac-encoder-sample-rate encoder)
(aac-encoder-channels encoder)
(aac-encoder-bitrate encoder)
2 ; AOT: AAC-LC
2 ; TRANSMUX: ADTS
1 ; AFTERBURNER: on
frame-length-ptr
max-out-bytes-ptr)))
(unless (zerop result) (unless (zerop result)
(error 'encoding-error :format :aac (error 'encoding-error :format :aac
:message (format nil "fdkaac_open_and_init failed: ~A" result))) :message (format nil "aacEncOpen failed: ~A" result)))
(setf (encoder-handle encoder) (cffi:mem-ref handle-ptr :pointer)) (setf (encoder-handle encoder) (cffi:mem-ref handle-ptr :pointer))))
(setf (aac-encoder-frame-length encoder) (cffi:mem-ref frame-length-ptr :int)) (let ((handle (encoder-handle encoder)))
(setf (aac-encoder-out-buffer-size encoder) (cffi:mem-ref max-out-bytes-ptr :int)))) (aac-encoder-set-param handle :aacenc-aot 2)
(aac-encoder-set-param handle :aacenc-samplerate (aac-encoder-sample-rate encoder))
(aac-encoder-set-param handle :aacenc-channelmode
(if (= (aac-encoder-channels encoder) 1) 1 2))
(aac-encoder-set-param handle :aacenc-channelorder 1)
(aac-encoder-set-param handle :aacenc-bitrate (aac-encoder-bitrate encoder))
(aac-encoder-set-param handle :aacenc-transmux 2)
(aac-encoder-set-param handle :aacenc-afterburner 1)
(cffi:with-foreign-object (in-args '(:struct aacenc-in-args))
(cffi:with-foreign-object (out-args '(:struct aacenc-out-args))
(setf (cffi:foreign-slot-value in-args '(:struct aacenc-in-args) 'num-in-samples) -1)
(setf (cffi:foreign-slot-value in-args '(:struct aacenc-in-args) 'num-ancillary-bytes) 0)
(let ((result (aac-enc-encode handle (cffi:null-pointer) (cffi:null-pointer)
in-args out-args)))
(unless (zerop result)
(aac-enc-close (cffi:foreign-alloc :pointer :initial-element handle))
(error 'encoding-error :format :aac
:message (format nil "aacEncEncode init failed: ~A" result))))))
(cffi:with-foreign-object (info '(:struct aacenc-info-struct))
(aac-enc-info handle info)
(setf (aac-encoder-frame-length encoder)
(cffi:foreign-slot-value info '(:struct aacenc-info-struct) 'frame-length))
(setf (aac-encoder-out-buffer-size encoder)
(cffi:foreign-slot-value info '(:struct aacenc-info-struct) 'max-out-buf-bytes)))
(setf (aac-encoder-out-buffer encoder) (setf (aac-encoder-out-buffer encoder)
(cffi:foreign-alloc :unsigned-char :count (aac-encoder-out-buffer-size encoder))) (cffi:foreign-alloc :unsigned-char :count (aac-encoder-out-buffer-size encoder)))
;; Initialize PCM accumulation buffer (frame-length * channels samples)
(let ((accum-size (* (aac-encoder-frame-length encoder)
(aac-encoder-channels encoder))))
(setf (aac-encoder-pcm-accum encoder)
(make-array accum-size :element-type '(signed-byte 16) :initial-element 0))
(setf (aac-encoder-pcm-accum-pos encoder) 0))
(log:info "AAC encoder initialized: ~Akbps, ~AHz, ~A channels, frame-length=~A" (log:info "AAC encoder initialized: ~Akbps, ~AHz, ~A channels, frame-length=~A"
(floor (aac-encoder-bitrate encoder) 1000) (floor (aac-encoder-bitrate encoder) 1000)
(aac-encoder-sample-rate encoder) (aac-encoder-sample-rate encoder)
(aac-encoder-channels encoder) (aac-encoder-channels encoder)
(aac-encoder-frame-length encoder)) (aac-encoder-frame-length encoder))
encoder) encoder))
(defun close-aac-encoder (encoder) (defun close-aac-encoder (encoder)
"Close the AAC encoder and free resources." "Close the AAC encoder and free resources."
(when (encoder-handle encoder) (when (encoder-handle encoder)
(cffi:with-foreign-object (handle-ptr :pointer) (cffi:with-foreign-object (handle-ptr :pointer)
(setf (cffi:mem-ref handle-ptr :pointer) (encoder-handle encoder)) (setf (cffi:mem-ref handle-ptr :pointer) (encoder-handle encoder))
(fdkaac-close handle-ptr)) (aac-enc-close handle-ptr))
(setf (encoder-handle encoder) nil)) (setf (encoder-handle encoder) nil))
(when (aac-encoder-out-buffer encoder) (when (aac-encoder-out-buffer encoder)
(cffi:foreign-free (aac-encoder-out-buffer encoder)) (cffi:foreign-free (aac-encoder-out-buffer encoder))
(setf (aac-encoder-out-buffer encoder) nil))) (setf (aac-encoder-out-buffer encoder) nil)))
(defun encode-one-aac-frame (encoder) (defun encode-aac-pcm (encoder pcm-samples num-samples)
"Encode a single frame from the accumulation buffer. "Encode PCM samples (16-bit signed interleaved) to AAC.
Returns a byte vector of AAC data, or an empty vector." Returns a byte vector of AAC data (ADTS frames)."
(let* ((handle (encoder-handle encoder)) (let* ((handle (encoder-handle encoder))
(channels (aac-encoder-channels encoder)) (channels (aac-encoder-channels encoder))
(frame-length (aac-encoder-frame-length encoder))
(accum (aac-encoder-pcm-accum encoder))
(out-buf (aac-encoder-out-buffer encoder)) (out-buf (aac-encoder-out-buffer encoder))
(out-buf-size (aac-encoder-out-buffer-size encoder)) (out-buf-size (aac-encoder-out-buffer-size encoder)))
(total-samples (* frame-length channels)) (cffi:with-pointer-to-vector-data (pcm-ptr pcm-samples)
(pcm-bytes (* total-samples 2))) (cffi:with-foreign-objects ((in-buf-desc '(:struct aacenc-buf-desc))
(cffi:with-pointer-to-vector-data (pcm-ptr accum) (out-buf-desc '(:struct aacenc-buf-desc))
(cffi:with-foreign-object (bytes-written-ptr :int) (in-args '(:struct aacenc-in-args))
(let ((result (fdkaac-encode handle pcm-ptr pcm-bytes total-samples (out-args '(:struct aacenc-out-args))
out-buf out-buf-size bytes-written-ptr))) (in-buf-ptr :pointer)
(in-buf-id :int)
(in-buf-size :int)
(in-buf-el-size :int)
(out-buf-ptr :pointer)
(out-buf-id :int)
(out-buf-size-ptr :int)
(out-buf-el-size :int))
(setf (cffi:mem-ref in-buf-ptr :pointer) pcm-ptr)
(setf (cffi:mem-ref in-buf-id :int) 0)
(setf (cffi:mem-ref in-buf-size :int) (* num-samples channels 2))
(setf (cffi:mem-ref in-buf-el-size :int) 2)
(setf (cffi:foreign-slot-value in-buf-desc '(:struct aacenc-buf-desc) 'num-bufs) 1)
(setf (cffi:foreign-slot-value in-buf-desc '(:struct aacenc-buf-desc) 'bufs) in-buf-ptr)
(setf (cffi:foreign-slot-value in-buf-desc '(:struct aacenc-buf-desc) 'buf-ids) in-buf-id)
(setf (cffi:foreign-slot-value in-buf-desc '(:struct aacenc-buf-desc) 'buf-sizes) in-buf-size)
(setf (cffi:foreign-slot-value in-buf-desc '(:struct aacenc-buf-desc) 'buf-el-sizes) in-buf-el-size)
(setf (cffi:mem-ref out-buf-ptr :pointer) out-buf)
(setf (cffi:mem-ref out-buf-id :int) 1)
(setf (cffi:mem-ref out-buf-size-ptr :int) out-buf-size)
(setf (cffi:mem-ref out-buf-el-size :int) 1)
(setf (cffi:foreign-slot-value out-buf-desc '(:struct aacenc-buf-desc) 'num-bufs) 1)
(setf (cffi:foreign-slot-value out-buf-desc '(:struct aacenc-buf-desc) 'bufs) out-buf-ptr)
(setf (cffi:foreign-slot-value out-buf-desc '(:struct aacenc-buf-desc) 'buf-ids) out-buf-id)
(setf (cffi:foreign-slot-value out-buf-desc '(:struct aacenc-buf-desc) 'buf-sizes) out-buf-size-ptr)
(setf (cffi:foreign-slot-value out-buf-desc '(:struct aacenc-buf-desc) 'buf-el-sizes) out-buf-el-size)
(setf (cffi:foreign-slot-value in-args '(:struct aacenc-in-args) 'num-in-samples)
(* num-samples channels))
(setf (cffi:foreign-slot-value in-args '(:struct aacenc-in-args) 'num-ancillary-bytes) 0)
(let ((result (aac-enc-encode handle in-buf-desc out-buf-desc in-args out-args)))
(unless (zerop result) (unless (zerop result)
(error 'encoding-error :format :aac (error 'encoding-error :format :aac
:message (format nil "aacEncEncode failed: ~A" result))) :message (format nil "aacEncEncode failed: ~A" result)))
(let ((bytes-written (cffi:mem-ref bytes-written-ptr :int))) (let ((bytes-written (cffi:foreign-slot-value out-args '(:struct aacenc-out-args)
'num-out-bytes)))
(if (> bytes-written 0) (if (> bytes-written 0)
(let ((result-vec (make-array bytes-written :element-type '(unsigned-byte 8)))) (let ((result-vec (make-array bytes-written :element-type '(unsigned-byte 8))))
(loop for i below bytes-written (loop for i below bytes-written
do (setf (aref result-vec i) (cffi:mem-aref out-buf :unsigned-char i))) do (setf (aref result-vec i) (cffi:mem-aref out-buf :unsigned-char i)))
result-vec) result-vec)
(make-array 0 :element-type '(unsigned-byte 8))))))))) (make-array 0 :element-type '(unsigned-byte 8)))))))))
(defun encode-aac-pcm (encoder pcm-samples num-samples)
"Encode PCM samples (16-bit signed interleaved) to AAC.
Accumulates samples and encodes in exact frame-length chunks.
Returns a byte vector of AAC data (ADTS frames).
Uses C shim to avoid SBCL signal handler conflicts."
(let* ((channels (aac-encoder-channels encoder))
(frame-samples (* (aac-encoder-frame-length encoder) channels))
(accum (aac-encoder-pcm-accum encoder))
(input-total (* num-samples channels))
(input-pos 0)
(output-chunks nil))
;; Copy input samples into accumulation buffer, encoding whenever full
(loop while (< input-pos input-total)
for space-left = (- frame-samples (aac-encoder-pcm-accum-pos encoder))
for copy-count = (min space-left (- input-total input-pos))
do (replace accum pcm-samples
:start1 (aac-encoder-pcm-accum-pos encoder)
:end1 (+ (aac-encoder-pcm-accum-pos encoder) copy-count)
:start2 input-pos
:end2 (+ input-pos copy-count))
(incf (aac-encoder-pcm-accum-pos encoder) copy-count)
(incf input-pos copy-count)
;; When accumulation buffer is full, encode one frame
(when (= (aac-encoder-pcm-accum-pos encoder) frame-samples)
(let ((encoded (encode-one-aac-frame encoder)))
(when (> (length encoded) 0)
(push encoded output-chunks)))
(setf (aac-encoder-pcm-accum-pos encoder) 0)))
;; Concatenate all encoded chunks into one result vector
(if (null output-chunks)
(make-array 0 :element-type '(unsigned-byte 8))
(let* ((total-bytes (reduce #'+ output-chunks :key #'length))
(result (make-array total-bytes :element-type '(unsigned-byte 8)))
(pos 0))
(dolist (chunk (nreverse output-chunks))
(replace result chunk :start1 pos)
(incf pos (length chunk)))
result))))

View File

@ -14,7 +14,7 @@
(not-empty :initform (bt:make-condition-variable :name "buffer-not-empty") (not-empty :initform (bt:make-condition-variable :name "buffer-not-empty")
:reader buffer-not-empty) :reader buffer-not-empty)
(burst-size :initarg :burst-size :reader buffer-burst-size (burst-size :initarg :burst-size :reader buffer-burst-size
:initform (* 64 1024) :initform (* 32 1024)
:documentation "Bytes of recent data to send on new client connect"))) :documentation "Bytes of recent data to send on new client connect")))
(defun make-ring-buffer (size) (defun make-ring-buffer (size)

View File

@ -25,8 +25,7 @@
#: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

@ -8,12 +8,6 @@
(cffi:use-foreign-library libfdkaac) (cffi:use-foreign-library libfdkaac)
;; Shim library for safe NULL-pointer init call (SBCL/CFFI crashes on NULL args to aacEncEncode)
(eval-when (:compile-toplevel :load-toplevel :execute)
(let ((shim-path (merge-pathnames "libfdkaac-shim.so"
(asdf:system-source-directory :cl-streamer/aac-encoder))))
(cffi:load-foreign-library shim-path)))
(cffi:defctype aac-encoder-handle :pointer) (cffi:defctype aac-encoder-handle :pointer)
(cffi:defcenum aac-encoder-param (cffi:defcenum aac-encoder-param
@ -139,27 +133,3 @@
(cffi:defcfun ("aacEncoder_GetParam" aac-encoder-get-param) :uint (cffi:defcfun ("aacEncoder_GetParam" aac-encoder-get-param) :uint
(h-aac-encoder aac-encoder-handle) (h-aac-encoder aac-encoder-handle)
(param aac-encoder-param)) (param aac-encoder-param))
;; Shim: all FDK-AAC calls go through C to avoid SBCL signal handler conflicts
(cffi:defcfun ("fdkaac_open_and_init" fdkaac-open-and-init) :int
(out-handle :pointer)
(sample-rate :int)
(channels :int)
(bitrate :int)
(aot :int)
(transmux :int)
(afterburner :int)
(out-frame-length :pointer)
(out-max-out-bytes :pointer))
(cffi:defcfun ("fdkaac_encode" fdkaac-encode) :int
(handle :pointer)
(pcm-buf :pointer)
(pcm-bytes :int)
(num-samples :int)
(out-buf :pointer)
(out-buf-size :int)
(out-bytes-written :pointer))
(cffi:defcfun ("fdkaac_close" fdkaac-close) :void
(ph :pointer))

View File

@ -1,101 +0,0 @@
/* Shim for FDK-AAC encoder initialization.
SBCL's signal handlers conflict with FDK-AAC's internal memory access,
causing recursive SIGSEGV when calling aacEncEncode or aacEncOpen from
CFFI. This shim does the entire open+configure+init from C. */
#include <fdk-aac/aacenc_lib.h>
#include <string.h>
/* Open, configure, and initialize an AAC encoder entirely from C.
Returns 0 on success, or the FDK-AAC error code on failure.
On success, *out_handle is set, and *out_frame_length / *out_max_out_bytes
are filled from aacEncInfo. */
int fdkaac_open_and_init(HANDLE_AACENCODER *out_handle,
int sample_rate, int channels, int bitrate,
int aot, int transmux, int afterburner,
int *out_frame_length, int *out_max_out_bytes) {
HANDLE_AACENCODER handle = NULL;
AACENC_ERROR err;
AACENC_InfoStruct info;
err = aacEncOpen(&handle, 0, channels);
if (err != AACENC_OK) return (int)err;
if ((err = aacEncoder_SetParam(handle, AACENC_AOT, aot)) != AACENC_OK) goto fail;
if ((err = aacEncoder_SetParam(handle, AACENC_SAMPLERATE, sample_rate)) != AACENC_OK) goto fail;
if ((err = aacEncoder_SetParam(handle, AACENC_CHANNELMODE, channels == 1 ? MODE_1 : MODE_2)) != AACENC_OK) goto fail;
if ((err = aacEncoder_SetParam(handle, AACENC_CHANNELORDER, 1)) != AACENC_OK) goto fail;
if ((err = aacEncoder_SetParam(handle, AACENC_BITRATE, bitrate)) != AACENC_OK) goto fail;
if ((err = aacEncoder_SetParam(handle, AACENC_TRANSMUX, transmux)) != AACENC_OK) goto fail;
if ((err = aacEncoder_SetParam(handle, AACENC_AFTERBURNER, afterburner)) != AACENC_OK) goto fail;
err = aacEncEncode(handle, NULL, NULL, NULL, NULL);
if (err != AACENC_OK) goto fail;
memset(&info, 0, sizeof(info));
err = aacEncInfo(handle, &info);
if (err != AACENC_OK) goto fail;
*out_handle = handle;
*out_frame_length = info.frameLength;
*out_max_out_bytes = info.maxOutBufBytes;
return 0;
fail:
aacEncClose(&handle);
return (int)err;
}
/* Encode PCM samples to AAC.
pcm_buf: interleaved signed 16-bit PCM
pcm_bytes: size of pcm_buf in bytes
out_buf: output buffer for AAC data
out_buf_size: size of out_buf in bytes
out_bytes_written: set to actual bytes written on success
Returns 0 on success, FDK-AAC error code on failure. */
int fdkaac_encode(HANDLE_AACENCODER handle,
void *pcm_buf, int pcm_bytes,
int num_samples,
void *out_buf, int out_buf_size,
int *out_bytes_written) {
AACENC_BufDesc in_desc = {0}, out_desc = {0};
AACENC_InArgs in_args = {0};
AACENC_OutArgs out_args = {0};
AACENC_ERROR err;
void *in_ptr = pcm_buf;
INT in_id = IN_AUDIO_DATA;
INT in_size = pcm_bytes;
INT in_el_size = sizeof(INT_PCM);
in_desc.numBufs = 1;
in_desc.bufs = &in_ptr;
in_desc.bufferIdentifiers = &in_id;
in_desc.bufSizes = &in_size;
in_desc.bufElSizes = &in_el_size;
void *out_ptr = out_buf;
INT out_id = OUT_BITSTREAM_DATA;
INT out_size = out_buf_size;
INT out_el_size = 1;
out_desc.numBufs = 1;
out_desc.bufs = &out_ptr;
out_desc.bufferIdentifiers = &out_id;
out_desc.bufSizes = &out_size;
out_desc.bufElSizes = &out_el_size;
in_args.numInSamples = num_samples;
in_args.numAncBytes = 0;
err = aacEncEncode(handle, &in_desc, &out_desc, &in_args, &out_args);
if (err != AACENC_OK) return (int)err;
*out_bytes_written = out_args.numOutBytes;
return 0;
}
/* Close an encoder handle. */
void fdkaac_close(HANDLE_AACENCODER *ph) {
aacEncClose(ph);
}

View File

@ -4,11 +4,11 @@
(#:mixed #:org.shirakumo.fraf.mixed)) (#:mixed #:org.shirakumo.fraf.mixed))
(:export #:audio-pipeline (:export #:audio-pipeline
#:make-audio-pipeline #:make-audio-pipeline
#:add-pipeline-output
#:start-pipeline #:start-pipeline
#:stop-pipeline #:stop-pipeline
#:play-file #:play-file
#:play-list #:play-list
#:pipeline-encoder
#:pipeline-server #:pipeline-server
#:make-streaming-server)) #:make-streaming-server))
@ -20,25 +20,16 @@
;;; dummy drain which just discards audio data. ;;; dummy drain which just discards audio data.
(defclass streaming-drain (mixed:drain) (defclass streaming-drain (mixed:drain)
((outputs :initarg :outputs :accessor drain-outputs :initform nil ((encoder :initarg :encoder :accessor drain-encoder)
:documentation "List of (encoder . mount-path) pairs") (mount-path :initarg :mount-path :accessor drain-mount-path :initform "/stream.mp3")
(channels :initarg :channels :accessor drain-channels :initform 2))) (channels :initarg :channels :accessor drain-channels :initform 2)))
(defun drain-add-output (drain encoder mount-path)
"Add an encoder/mount pair to the drain."
(push (cons encoder mount-path) (drain-outputs drain)))
(defun drain-remove-output (drain mount-path)
"Remove an encoder/mount pair by mount path."
(setf (drain-outputs drain)
(remove mount-path (drain-outputs drain) :key #'cdr :test #'string=)))
(defmethod mixed:free ((drain streaming-drain))) (defmethod mixed:free ((drain streaming-drain)))
(defmethod mixed:start ((drain streaming-drain))) (defmethod mixed:start ((drain streaming-drain)))
(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 float PCM from the pack buffer, encode to MP3, write to stream.
The pack buffer is (unsigned-byte 8) with IEEE 754 single-floats (4 bytes each). The pack buffer is (unsigned-byte 8) with IEEE 754 single-floats (4 bytes each).
Layout: L0b0 L0b1 L0b2 L0b3 R0b0 R0b1 R0b2 R0b3 L1b0 ... (interleaved stereo)" Layout: L0b0 L0b1 L0b2 L0b3 R0b0 R0b1 R0b2 R0b3 L1b0 ... (interleaved stereo)"
(mixed:with-buffer-tx (data start size (mixed:pack drain)) (mixed:with-buffer-tx (data start size (mixed:pack drain))
@ -55,41 +46,30 @@
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) for sample = (cffi:mem-ref ptr :float byte-offset)
do (setf (aref pcm-buffer i) (float-to-s16 sample)))) do (setf (aref pcm-buffer i) (float-to-s16 sample))))
;; Feed PCM to all encoder/mount pairs
(dolist (output (drain-outputs drain))
(let ((encoder (car output))
(mount-path (cdr output)))
(handler-case (handler-case
(let ((encoded (encode-for-output encoder pcm-buffer num-samples))) (let ((mp3-data (cl-streamer:encode-pcm-interleaved
(when (> (length encoded) 0) (drain-encoder drain) pcm-buffer num-samples)))
(cl-streamer:write-audio-data mount-path encoded))) (when (> (length mp3-data) 0)
(cl-streamer:write-audio-data (drain-mount-path drain) mp3-data)))
(error (e) (error (e)
(log:warn "Encode error for ~A: ~A" mount-path e))))))) (log:warn "Encode error in drain: ~A" e)))))
;; Sleep for most of the audio duration (leave headroom for encoding) ;; Sleep for the duration of audio we just processed
;; size = bytes, each frame = channels * 4 bytes (single-float)
(let* ((channels (drain-channels drain)) (let* ((channels (drain-channels drain))
(bytes-per-frame (* channels 4)) (bytes-per-frame (* channels 4))
(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)
(sleep (* 0.9 (/ frames samplerate))))) (sleep (/ frames samplerate))))
(mixed:finish size))) (mixed:finish size)))
(defgeneric encode-for-output (encoder pcm-buffer num-samples)
(:documentation "Encode PCM samples using the given encoder. Returns byte vector."))
(defmethod encode-for-output ((encoder cl-streamer::mp3-encoder) pcm-buffer num-samples)
(cl-streamer:encode-pcm-interleaved encoder pcm-buffer num-samples))
(defmethod encode-for-output ((encoder cl-streamer::aac-encoder) pcm-buffer num-samples)
(cl-streamer:encode-aac-pcm encoder pcm-buffer num-samples))
(defmethod mixed:end ((drain streaming-drain))) (defmethod mixed:end ((drain streaming-drain)))
;;; ---- Audio Pipeline ---- ;;; ---- Audio Pipeline ----
(defclass audio-pipeline () (defclass audio-pipeline ()
((harmony-server :initform nil :accessor pipeline-harmony-server) ((harmony-server :initform nil :accessor pipeline-harmony-server)
(drain :initform nil :accessor pipeline-drain) (encoder :initarg :encoder :accessor pipeline-encoder)
(stream-server :initarg :stream-server :accessor pipeline-server) (stream-server :initarg :stream-server :accessor pipeline-server)
(mount-path :initarg :mount-path :accessor pipeline-mount-path :initform "/stream.mp3") (mount-path :initarg :mount-path :accessor pipeline-mount-path :initform "/stream.mp3")
(sample-rate :initarg :sample-rate :accessor pipeline-sample-rate :initform 44100) (sample-rate :initarg :sample-rate :accessor pipeline-sample-rate :initform 44100)
@ -98,27 +78,13 @@
(defun make-audio-pipeline (&key encoder stream-server (mount-path "/stream.mp3") (defun make-audio-pipeline (&key encoder stream-server (mount-path "/stream.mp3")
(sample-rate 44100) (channels 2)) (sample-rate 44100) (channels 2))
"Create an audio pipeline connecting Harmony to the stream server via an encoder. "Create an audio pipeline connecting Harmony to the stream server via an encoder."
The initial encoder/mount-path pair is added as the first output. (make-instance 'audio-pipeline
Additional outputs can be added with add-pipeline-output." :encoder encoder
(let ((pipeline (make-instance 'audio-pipeline
:stream-server stream-server :stream-server stream-server
:mount-path mount-path :mount-path mount-path
:sample-rate sample-rate :sample-rate sample-rate
:channels channels))) :channels channels))
(when encoder
(setf (slot-value pipeline 'drain)
(make-instance 'streaming-drain :channels channels))
(drain-add-output (pipeline-drain pipeline) encoder mount-path))
pipeline))
(defun add-pipeline-output (pipeline encoder mount-path)
"Add an additional encoder/mount output to the pipeline.
Can be called before or after start-pipeline."
(unless (pipeline-drain pipeline)
(setf (pipeline-drain pipeline)
(make-instance 'streaming-drain :channels (pipeline-channels pipeline))))
(drain-add-output (pipeline-drain pipeline) encoder mount-path))
(defun start-pipeline (pipeline) (defun start-pipeline (pipeline)
"Start the audio pipeline - initializes Harmony with our streaming drain." "Start the audio pipeline - initializes Harmony with our streaming drain."
@ -134,7 +100,10 @@
(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)) (pack (mixed:pack old-drain))
(drain (pipeline-drain pipeline))) (drain (make-instance 'streaming-drain
:encoder (pipeline-encoder pipeline)
:mount-path (pipeline-mount-path pipeline)
:channels (pipeline-channels pipeline))))
;; Wire our streaming drain to the same pack buffer ;; Wire our streaming drain to the same pack buffer
(setf (mixed:pack drain) pack) (setf (mixed:pack drain) pack)
;; Swap: withdraw old dummy drain, add our streaming drain ;; Swap: withdraw old dummy drain, add our streaming drain
@ -143,8 +112,7 @@
(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)
(log:info "Audio pipeline started with streaming drain (~A outputs)" (log:info "Audio pipeline started with streaming drain")
(length (drain-outputs (pipeline-drain pipeline))))
pipeline) pipeline)
(defun stop-pipeline (pipeline) (defun stop-pipeline (pipeline)
@ -156,144 +124,56 @@
(log:info "Audio pipeline stopped") (log:info "Audio pipeline stopped")
pipeline) pipeline)
(defun read-audio-metadata (file-path) (defun play-file (pipeline file-path &key (mixer :music) title (on-end :free))
"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 (format-display-title path title))) (display-title (or title (pathname-name path))))
(when update-metadata ;; Update ICY metadata so listeners see the track name
(update-all-mounts-metadata pipeline display-title)) (cl-streamer:set-now-playing (pipeline-mount-path 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)
(values voice display-title)))) voice)))
(defun voice-remaining-seconds (voice) (defun play-list (pipeline file-list &key (gap 0.5))
"Return estimated seconds remaining for a voice, or NIL if unknown."
(handler-case
(let ((pos (mixed:frame-position voice))
(total (mixed:frame-count voice))
(sr (mixed:samplerate voice)))
(when (and pos total sr (> total 0) (> sr 0))
(/ (- total pos) sr)))
(error () nil)))
(defun volume-ramp (voice target-volume duration &key (steps 20))
"Smoothly ramp a voice's volume to TARGET-VOLUME over DURATION seconds.
Runs in the calling thread (blocks for DURATION seconds)."
(let* ((start-volume (mixed:volume voice))
(delta (- target-volume start-volume))
(step-time (/ duration steps)))
(loop for i from 1 to steps
for fraction = (/ i steps)
for vol = (+ start-volume (* delta fraction))
do (setf (mixed:volume voice) (max 0.0 (min 1.0 (float vol))))
(sleep step-time))))
(defun play-list (pipeline file-list &key (crossfade-duration 3.0)
(fade-in 2.0)
(fade-out 2.0))
"Play a list of file paths sequentially through the pipeline. "Play a list of file paths sequentially through the pipeline.
Each entry can be a string (path) or a plist (:file path :title title). Each entry can be a string (path) or a plist (:file path :title title).
CROSSFADE-DURATION is how early to start the next track (seconds). GAP is seconds of silence between tracks."
FADE-IN/FADE-OUT control the volume ramp durations.
Both voices play simultaneously through the mixer during crossfade."
(bt:make-thread (bt:make-thread
(lambda () (lambda ()
(let ((prev-voice nil))
(loop for entry in file-list (loop for entry in file-list
for idx from 0
while (pipeline-running-p pipeline) while (pipeline-running-p pipeline)
do (multiple-value-bind (path title) do (multiple-value-bind (path title)
(if (listp entry) (if (listp entry)
(values (getf entry :file) (getf entry :title)) (values (getf entry :file) (getf entry :title))
(values entry nil)) (values entry nil))
(handler-case (handler-case
(let* ((server (pipeline-harmony-server pipeline)) (let* ((done-lock (bt:make-lock "track-done"))
(harmony:*server* server)) (done-cv (bt:make-condition-variable :name "track-done"))
(multiple-value-bind (voice display-title) (done-p nil)
(play-file pipeline path :title title (server (pipeline-harmony-server pipeline))
:on-end :disconnect (harmony:*server* server)
:update-metadata (null prev-voice)) (voice (play-file pipeline path
(when voice :title title
;; If this isn't the first track, crossfade :on-end (lambda (voice)
(when (and prev-voice (> idx 0)) (declare (ignore voice))
(setf (mixed:volume voice) 0.0) (bt:with-lock-held (done-lock)
;; Fade in new voice and fade out old voice in parallel (setf done-p t)
(let ((fade-thread (bt:condition-notify done-cv))))))
(bt:make-thread (declare (ignore voice))
(lambda () ;; Wait for the track to finish via callback
(volume-ramp prev-voice 0.0 fade-out) (bt:with-lock-held (done-lock)
(harmony:stop prev-voice)) (loop until (or done-p (not (pipeline-running-p pipeline)))
:name "cl-streamer-fadeout"))) do (bt:condition-wait done-cv done-lock)))
(volume-ramp voice 1.0 fade-in) (when (> gap 0) (sleep gap)))
(bt:join-thread fade-thread))
;; Now the crossfade is done, update metadata
(update-all-mounts-metadata pipeline display-title))
;; Wait for track to approach its end
(sleep 0.5)
(loop while (and (pipeline-running-p pipeline)
(not (mixed:done-p voice)))
for remaining = (voice-remaining-seconds voice)
when (and remaining
(<= remaining crossfade-duration)
(not (mixed:done-p voice)))
do (setf prev-voice voice)
(return)
do (sleep 0.1))
;; 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))))))
;; Clean up last voice
(when prev-voice
(let ((harmony:*server* (pipeline-harmony-server pipeline)))
(volume-ramp prev-voice 0.0 fade-out)
(harmony:stop prev-voice)))))
:name "cl-streamer-playlist")) :name "cl-streamer-playlist"))
(declaim (inline float-to-s16)) (declaim (inline float-to-s16))

Binary file not shown.

View File

@ -1,55 +1,43 @@
;;; End-to-end streaming test with playlist (MP3 + AAC) ;;; End-to-end streaming test with playlist
;;; Usage: sbcl --load test-stream.lisp ;;; Usage: sbcl --load test-stream.lisp
;;; ;;;
;;; Then open in VLC or browser: ;;; Then open http://localhost:8000/stream.mp3 in VLC or browser
;;; http://localhost:8000/stream.mp3 (MP3 128kbps)
;;; http://localhost:8000/stream.aac (AAC 128kbps)
;;; ICY metadata will show track names as they change. ;;; ICY metadata will show track names as they change.
(push #p"/home/glenn/SourceCode/harmony/" asdf:*central-registry*) (push #p"/home/glenn/SourceCode/harmony/" asdf:*central-registry*)
(push #p"/home/glenn/SourceCode/asteroid/cl-streamer/" asdf:*central-registry*) (push #p"/home/glenn/SourceCode/asteroid/cl-streamer/" asdf:*central-registry*)
(ql:quickload '(:cl-streamer :cl-streamer/encoder :cl-streamer/aac-encoder :cl-streamer/harmony)) (ql:quickload '(:cl-streamer :cl-streamer/encoder :cl-streamer/harmony))
(format t "~%=== CL-Streamer Playlist Test (MP3 + AAC) ===~%") (format t "~%=== CL-Streamer Playlist Test ===~%")
(format t "LAME version: ~A~%" (cl-streamer::lame-version)) (format t "LAME version: ~A~%" (cl-streamer::lame-version))
;; 1. Create and start stream server ;; 1. Create and start stream server
(format t "~%[1] Starting stream server on port 8000...~%") (format t "~%[1] Starting stream server on port 8000...~%")
(cl-streamer:start :port 8000) (cl-streamer:start :port 8000)
;; 2. Add mount points ;; 2. Add mount point
(format t "[2] Adding mount points...~%") (format t "[2] Adding mount point /stream.mp3...~%")
(cl-streamer:add-mount cl-streamer:*server* "/stream.mp3" (cl-streamer:add-mount cl-streamer:*server* "/stream.mp3"
:content-type "audio/mpeg" :content-type "audio/mpeg"
:bitrate 128 :bitrate 128
:name "Asteroid Radio MP3") :name "Asteroid Radio (CL-Streamer Test)")
(cl-streamer:add-mount cl-streamer:*server* "/stream.aac"
:content-type "audio/aac"
:bitrate 128
:name "Asteroid Radio AAC")
;; 3. Create encoders ;; 3. Create MP3 encoder
(format t "[3] Creating encoders...~%") (format t "[3] Creating MP3 encoder (128kbps, 44100Hz, stereo)...~%")
(defvar *mp3-encoder* (cl-streamer:make-mp3-encoder :sample-rate 44100 (defvar *encoder* (cl-streamer:make-mp3-encoder :sample-rate 44100
:channels 2 :channels 2
:bitrate 128)) :bitrate 128))
(defvar *aac-encoder* (cl-streamer:make-aac-encoder :sample-rate 44100
:channels 2
:bitrate 128000))
;; 4. Create and start audio pipeline with both outputs ;; 4. Create and start audio pipeline
(format t "[4] Starting audio pipeline with Harmony (MP3 + AAC)...~%") (format t "[4] Starting audio pipeline with Harmony...~%")
(defvar *pipeline* (cl-streamer/harmony:make-audio-pipeline (defvar *pipeline* (cl-streamer/harmony:make-audio-pipeline
:encoder *mp3-encoder* :encoder *encoder*
:stream-server cl-streamer:*server* :stream-server cl-streamer:*server*
:mount-path "/stream.mp3" :mount-path "/stream.mp3"
:sample-rate 44100 :sample-rate 44100
:channels 2)) :channels 2))
;; Add AAC as second output
(cl-streamer/harmony:add-pipeline-output *pipeline* *aac-encoder* "/stream.aac")
(cl-streamer/harmony:start-pipeline *pipeline*) (cl-streamer/harmony:start-pipeline *pipeline*)
;; 5. Build a playlist from the music library ;; 5. Build a playlist from the music library
@ -60,25 +48,25 @@
(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)) files))) (push (list :file (namestring flac)
: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 :file))) (format t " ~A~%" (getf entry :title)))
;; 6. Start playlist playback ;; 6. Start playlist playback
(format t "~%[6] Starting playlist...~%") (format t "~%[6] Starting playlist...~%")
(cl-streamer/harmony:play-list *pipeline* *playlist* (cl-streamer/harmony:play-list *pipeline* *playlist*)
:crossfade-duration 3.0
:fade-in 2.0
:fade-out 2.0)
(format t "~%=== Stream is live! ===~%") (format t "~%=== Stream is live! ===~%")
(format t "MP3: http://localhost:8000/stream.mp3~%") (format t "Listen at: http://localhost:8000/stream.mp3~%")
(format t "AAC: http://localhost:8000/stream.aac~%")
(format t "~%Press Enter to stop...~%") (format t "~%Press Enter to stop...~%")
(read-line) (read-line)
@ -86,7 +74,6 @@
;; Cleanup ;; Cleanup
(format t "Stopping...~%") (format t "Stopping...~%")
(cl-streamer/harmony:stop-pipeline *pipeline*) (cl-streamer/harmony:stop-pipeline *pipeline*)
(cl-streamer:close-encoder *mp3-encoder*) (cl-streamer:close-encoder *encoder*)
(cl-streamer:close-aac-encoder *aac-encoder*)
(cl-streamer:stop) (cl-streamer:stop)
(format t "Done.~%") (format t "Done.~%")