Compare commits
2 Commits
fcda723577
...
edf9326007
| Author | SHA1 | Date |
|---|---|---|
|
|
edf9326007 | |
|
|
2649a8169a |
|
|
@ -1,69 +1,295 @@
|
|||
#+TITLE: CL-Streamer
|
||||
#+AUTHOR: Glenn Thompson
|
||||
#+DATE: 2026-15-02
|
||||
#+DATE: 2026-02-03
|
||||
|
||||
* Overview
|
||||
|
||||
CL-Streamer is a Common Lisp audio streaming server designed to replace
|
||||
Icecast and Liquidsoap in the Asteroid Radio project.
|
||||
CL-Streamer is a native Common Lisp audio streaming server built to replace
|
||||
the Icecast + Liquidsoap stack in [[https://asteroid.radio][Asteroid Radio]]. It provides HTTP audio
|
||||
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.
|
||||
|
||||
* Status
|
||||
The goal is to eliminate the Docker/C service dependencies (Icecast, Liquidsoap)
|
||||
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.
|
||||
|
||||
*EXPERIMENTAL* - This is an early proof-of-concept.
|
||||
* Why Replace Icecast + Liquidsoap?
|
||||
|
||||
* Features
|
||||
The current Asteroid Radio stack runs three separate services in Docker:
|
||||
|
||||
- HTTP streaming with ICY metadata protocol
|
||||
- Multiple mount points
|
||||
- Thread-safe ring buffers for audio data
|
||||
- Listener statistics
|
||||
- *Liquidsoap* — reads the playlist, decodes audio, applies crossfade,
|
||||
encodes to MP3/AAC, pushes to Icecast
|
||||
- *Icecast* — receives encoded streams, serves them to listeners over HTTP
|
||||
with ICY metadata
|
||||
- *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
|
||||
|
||||
- alexandria
|
||||
- bordeaux-threads
|
||||
- usocket
|
||||
- flexi-streams
|
||||
- chunga
|
||||
- log4cl
|
||||
- split-sequence
|
||||
** Lisp Libraries
|
||||
|
||||
Optional (for audio backend):
|
||||
- harmony
|
||||
- cl-mixed
|
||||
- cl-mixed-mpg123
|
||||
- [[https://github.com/Shinmera/harmony][harmony]] — audio framework (decode, mix, effects)
|
||||
- [[https://github.com/Shinmera/cl-mixed][cl-mixed]] — low-level audio mixing
|
||||
- [[https://github.com/Shinmera/cl-mixed-flac][cl-mixed-flac]] — FLAC decoding
|
||||
- [[https://github.com/Shinmera/cl-mixed-mpg123][cl-mixed-mpg123]] — MP3 decoding
|
||||
- [[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
|
||||
|
||||
#+begin_src common-lisp
|
||||
(ql:quickload :cl-streamer)
|
||||
;; Load the systems
|
||||
(ql:quickload '(:cl-streamer :cl-streamer/encoder
|
||||
:cl-streamer/aac-encoder :cl-streamer/harmony))
|
||||
|
||||
;; Create and start server
|
||||
;; Start the HTTP server
|
||||
(cl-streamer:start :port 8000)
|
||||
|
||||
;; Add a mount point
|
||||
;; Add mount points
|
||||
(cl-streamer:add-mount cl-streamer:*server* "/stream.mp3"
|
||||
:content-type "audio/mpeg"
|
||||
:bitrate 128
|
||||
:name "Asteroid Radio")
|
||||
:name "Asteroid Radio MP3")
|
||||
(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
|
||||
(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
|
||||
(cl-streamer:get-listener-count)
|
||||
|
||||
;; Stop server
|
||||
;; Stop everything
|
||||
(cl-streamer/harmony:stop-pipeline *pipeline*)
|
||||
(cl-streamer:stop)
|
||||
#+end_src
|
||||
|
||||
* Architecture
|
||||
|
||||
See =docs/CL-STREAMING-ARCHITECTURE.org= for the full design document.
|
||||
|
||||
* License
|
||||
|
||||
AGPL-3.0
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@
|
|||
(aot :initarg :aot :accessor aac-encoder-aot :initform :aot-aac-lc)
|
||||
(out-buffer :initform nil :accessor aac-encoder-out-buffer)
|
||||
(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))
|
||||
"Create an AAC encoder with the specified parameters.
|
||||
|
|
@ -21,108 +25,113 @@
|
|||
encoder))
|
||||
|
||||
(defun initialize-aac-encoder (encoder)
|
||||
"Initialize the FDK-AAC encoder with current settings."
|
||||
(cffi:with-foreign-object (handle-ptr :pointer)
|
||||
(let ((result (aac-enc-open handle-ptr 0 (aac-encoder-channels encoder))))
|
||||
"Initialize the FDK-AAC encoder with current settings.
|
||||
Uses C shim to avoid SBCL signal handler conflicts with FDK-AAC."
|
||||
(cffi:with-foreign-objects ((handle-ptr :pointer)
|
||||
(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)
|
||||
(error 'encoding-error :format :aac
|
||||
:message (format nil "aacEncOpen failed: ~A" result)))
|
||||
(setf (encoder-handle encoder) (cffi:mem-ref handle-ptr :pointer))))
|
||||
(let ((handle (encoder-handle encoder)))
|
||||
(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)
|
||||
(cffi:foreign-alloc :unsigned-char :count (aac-encoder-out-buffer-size encoder)))
|
||||
(log:info "AAC encoder initialized: ~Akbps, ~AHz, ~A channels, frame-length=~A"
|
||||
(floor (aac-encoder-bitrate encoder) 1000)
|
||||
(aac-encoder-sample-rate encoder)
|
||||
(aac-encoder-channels encoder)
|
||||
(aac-encoder-frame-length encoder))
|
||||
encoder))
|
||||
:message (format nil "fdkaac_open_and_init failed: ~A" result)))
|
||||
(setf (encoder-handle encoder) (cffi:mem-ref handle-ptr :pointer))
|
||||
(setf (aac-encoder-frame-length encoder) (cffi:mem-ref frame-length-ptr :int))
|
||||
(setf (aac-encoder-out-buffer-size encoder) (cffi:mem-ref max-out-bytes-ptr :int))))
|
||||
(setf (aac-encoder-out-buffer 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"
|
||||
(floor (aac-encoder-bitrate encoder) 1000)
|
||||
(aac-encoder-sample-rate encoder)
|
||||
(aac-encoder-channels encoder)
|
||||
(aac-encoder-frame-length encoder))
|
||||
encoder)
|
||||
|
||||
(defun close-aac-encoder (encoder)
|
||||
"Close the AAC encoder and free resources."
|
||||
(when (encoder-handle encoder)
|
||||
(cffi:with-foreign-object (handle-ptr :pointer)
|
||||
(setf (cffi:mem-ref handle-ptr :pointer) (encoder-handle encoder))
|
||||
(aac-enc-close handle-ptr))
|
||||
(fdkaac-close handle-ptr))
|
||||
(setf (encoder-handle encoder) nil))
|
||||
(when (aac-encoder-out-buffer encoder)
|
||||
(cffi:foreign-free (aac-encoder-out-buffer encoder))
|
||||
(setf (aac-encoder-out-buffer encoder) nil)))
|
||||
|
||||
(defun encode-aac-pcm (encoder pcm-samples num-samples)
|
||||
"Encode PCM samples (16-bit signed interleaved) to AAC.
|
||||
Returns a byte vector of AAC data (ADTS frames)."
|
||||
(defun encode-one-aac-frame (encoder)
|
||||
"Encode a single frame from the accumulation buffer.
|
||||
Returns a byte vector of AAC data, or an empty vector."
|
||||
(let* ((handle (encoder-handle 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-size (aac-encoder-out-buffer-size encoder)))
|
||||
(cffi:with-pointer-to-vector-data (pcm-ptr pcm-samples)
|
||||
(cffi:with-foreign-objects ((in-buf-desc '(:struct aacenc-buf-desc))
|
||||
(out-buf-desc '(:struct aacenc-buf-desc))
|
||||
(in-args '(:struct aacenc-in-args))
|
||||
(out-args '(:struct aacenc-out-args))
|
||||
(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)))
|
||||
(out-buf-size (aac-encoder-out-buffer-size encoder))
|
||||
(total-samples (* frame-length channels))
|
||||
(pcm-bytes (* total-samples 2)))
|
||||
(cffi:with-pointer-to-vector-data (pcm-ptr accum)
|
||||
(cffi:with-foreign-object (bytes-written-ptr :int)
|
||||
(let ((result (fdkaac-encode handle pcm-ptr pcm-bytes total-samples
|
||||
out-buf out-buf-size bytes-written-ptr)))
|
||||
(unless (zerop result)
|
||||
(error 'encoding-error :format :aac
|
||||
:message (format nil "aacEncEncode failed: ~A" result)))
|
||||
(let ((bytes-written (cffi:foreign-slot-value out-args '(:struct aacenc-out-args)
|
||||
'num-out-bytes)))
|
||||
(let ((bytes-written (cffi:mem-ref bytes-written-ptr :int)))
|
||||
(if (> bytes-written 0)
|
||||
(let ((result-vec (make-array bytes-written :element-type '(unsigned-byte 8))))
|
||||
(loop for i below bytes-written
|
||||
do (setf (aref result-vec i) (cffi:mem-aref out-buf :unsigned-char i)))
|
||||
result-vec)
|
||||
(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))))
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
(not-empty :initform (bt:make-condition-variable :name "buffer-not-empty")
|
||||
:reader buffer-not-empty)
|
||||
(burst-size :initarg :burst-size :reader buffer-burst-size
|
||||
:initform (* 32 1024)
|
||||
:initform (* 64 1024)
|
||||
:documentation "Bytes of recent data to send on new client connect")))
|
||||
|
||||
(defun make-ring-buffer (size)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@
|
|||
#:harmony
|
||||
#:cl-mixed
|
||||
#:cl-mixed-mpg123
|
||||
#:cl-mixed-flac)
|
||||
#:cl-mixed-flac
|
||||
#:taglib)
|
||||
:components ((:file "harmony-backend")))
|
||||
|
||||
(asdf:defsystem #:cl-streamer/encoder
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@
|
|||
|
||||
(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:defcenum aac-encoder-param
|
||||
|
|
@ -133,3 +139,27 @@
|
|||
(cffi:defcfun ("aacEncoder_GetParam" aac-encoder-get-param) :uint
|
||||
(h-aac-encoder aac-encoder-handle)
|
||||
(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))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
/* 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);
|
||||
}
|
||||
|
|
@ -4,11 +4,11 @@
|
|||
(#:mixed #:org.shirakumo.fraf.mixed))
|
||||
(:export #:audio-pipeline
|
||||
#:make-audio-pipeline
|
||||
#:add-pipeline-output
|
||||
#:start-pipeline
|
||||
#:stop-pipeline
|
||||
#:play-file
|
||||
#:play-list
|
||||
#:pipeline-encoder
|
||||
#:pipeline-server
|
||||
#:make-streaming-server))
|
||||
|
||||
|
|
@ -20,16 +20,25 @@
|
|||
;;; dummy drain which just discards audio data.
|
||||
|
||||
(defclass streaming-drain (mixed:drain)
|
||||
((encoder :initarg :encoder :accessor drain-encoder)
|
||||
(mount-path :initarg :mount-path :accessor drain-mount-path :initform "/stream.mp3")
|
||||
((outputs :initarg :outputs :accessor drain-outputs :initform nil
|
||||
:documentation "List of (encoder . mount-path) pairs")
|
||||
(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:start ((drain streaming-drain)))
|
||||
|
||||
(defmethod mixed:mix ((drain streaming-drain))
|
||||
"Read interleaved float PCM from the pack buffer, encode to MP3, write to stream.
|
||||
"Read interleaved float PCM from the pack buffer, encode to all outputs.
|
||||
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)"
|
||||
(mixed:with-buffer-tx (data start size (mixed:pack drain))
|
||||
|
|
@ -46,30 +55,41 @@
|
|||
for byte-offset = (+ start (* i bytes-per-sample))
|
||||
for sample = (cffi:mem-ref ptr :float byte-offset)
|
||||
do (setf (aref pcm-buffer i) (float-to-s16 sample))))
|
||||
(handler-case
|
||||
(let ((mp3-data (cl-streamer:encode-pcm-interleaved
|
||||
(drain-encoder drain) pcm-buffer num-samples)))
|
||||
(when (> (length mp3-data) 0)
|
||||
(cl-streamer:write-audio-data (drain-mount-path drain) mp3-data)))
|
||||
(error (e)
|
||||
(log:warn "Encode error in drain: ~A" e)))))
|
||||
;; Sleep for the duration of audio we just processed
|
||||
;; size = bytes, each frame = channels * 4 bytes (single-float)
|
||||
;; Feed PCM to all encoder/mount pairs
|
||||
(dolist (output (drain-outputs drain))
|
||||
(let ((encoder (car output))
|
||||
(mount-path (cdr output)))
|
||||
(handler-case
|
||||
(let ((encoded (encode-for-output encoder pcm-buffer num-samples)))
|
||||
(when (> (length encoded) 0)
|
||||
(cl-streamer:write-audio-data mount-path encoded)))
|
||||
(error (e)
|
||||
(log:warn "Encode error for ~A: ~A" mount-path e)))))))
|
||||
;; Sleep for most of the audio duration (leave headroom for encoding)
|
||||
(let* ((channels (drain-channels drain))
|
||||
(bytes-per-frame (* channels 4))
|
||||
(frames (floor size bytes-per-frame))
|
||||
(samplerate (mixed:samplerate (mixed:pack drain))))
|
||||
(when (> frames 0)
|
||||
(sleep (/ frames samplerate))))
|
||||
(sleep (* 0.9 (/ frames samplerate)))))
|
||||
(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)))
|
||||
|
||||
;;; ---- Audio Pipeline ----
|
||||
|
||||
(defclass audio-pipeline ()
|
||||
((harmony-server :initform nil :accessor pipeline-harmony-server)
|
||||
(encoder :initarg :encoder :accessor pipeline-encoder)
|
||||
(drain :initform nil :accessor pipeline-drain)
|
||||
(stream-server :initarg :stream-server :accessor pipeline-server)
|
||||
(mount-path :initarg :mount-path :accessor pipeline-mount-path :initform "/stream.mp3")
|
||||
(sample-rate :initarg :sample-rate :accessor pipeline-sample-rate :initform 44100)
|
||||
|
|
@ -78,13 +98,27 @@
|
|||
|
||||
(defun make-audio-pipeline (&key encoder stream-server (mount-path "/stream.mp3")
|
||||
(sample-rate 44100) (channels 2))
|
||||
"Create an audio pipeline connecting Harmony to the stream server via an encoder."
|
||||
(make-instance 'audio-pipeline
|
||||
:encoder encoder
|
||||
:stream-server stream-server
|
||||
:mount-path mount-path
|
||||
:sample-rate sample-rate
|
||||
:channels channels))
|
||||
"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.
|
||||
Additional outputs can be added with add-pipeline-output."
|
||||
(let ((pipeline (make-instance 'audio-pipeline
|
||||
:stream-server stream-server
|
||||
:mount-path mount-path
|
||||
:sample-rate sample-rate
|
||||
: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)
|
||||
"Start the audio pipeline - initializes Harmony with our streaming drain."
|
||||
|
|
@ -100,10 +134,7 @@
|
|||
(output (harmony:segment :output server))
|
||||
(old-drain (harmony:segment :drain output))
|
||||
(pack (mixed:pack old-drain))
|
||||
(drain (make-instance 'streaming-drain
|
||||
:encoder (pipeline-encoder pipeline)
|
||||
:mount-path (pipeline-mount-path pipeline)
|
||||
:channels (pipeline-channels pipeline))))
|
||||
(drain (pipeline-drain pipeline)))
|
||||
;; Wire our streaming drain to the same pack buffer
|
||||
(setf (mixed:pack drain) pack)
|
||||
;; Swap: withdraw old dummy drain, add our streaming drain
|
||||
|
|
@ -112,7 +143,8 @@
|
|||
(setf (pipeline-harmony-server pipeline) server)
|
||||
(mixed:start server))
|
||||
(setf (pipeline-running-p pipeline) t)
|
||||
(log:info "Audio pipeline started with streaming drain")
|
||||
(log:info "Audio pipeline started with streaming drain (~A outputs)"
|
||||
(length (drain-outputs (pipeline-drain pipeline))))
|
||||
pipeline)
|
||||
|
||||
(defun stop-pipeline (pipeline)
|
||||
|
|
@ -124,56 +156,144 @@
|
|||
(log:info "Audio pipeline stopped")
|
||||
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.
|
||||
The file will be decoded by Harmony and encoded for streaming.
|
||||
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.
|
||||
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))
|
||||
(server (pipeline-harmony-server pipeline))
|
||||
(harmony:*server* server)
|
||||
(display-title (or title (pathname-name path))))
|
||||
;; Update ICY metadata so listeners see the track name
|
||||
(cl-streamer:set-now-playing (pipeline-mount-path pipeline) display-title)
|
||||
(display-title (format-display-title path title)))
|
||||
(when update-metadata
|
||||
(update-all-mounts-metadata pipeline display-title))
|
||||
(let ((voice (harmony:play path :mixer mixer :on-end on-end)))
|
||||
(log:info "Now playing: ~A" display-title)
|
||||
voice)))
|
||||
(values voice display-title))))
|
||||
|
||||
(defun play-list (pipeline file-list &key (gap 0.5))
|
||||
(defun voice-remaining-seconds (voice)
|
||||
"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.
|
||||
Each entry can be a string (path) or a plist (:file path :title title).
|
||||
GAP is seconds of silence between tracks."
|
||||
CROSSFADE-DURATION is how early to start the next track (seconds).
|
||||
FADE-IN/FADE-OUT control the volume ramp durations.
|
||||
Both voices play simultaneously through the mixer during crossfade."
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(loop for entry in file-list
|
||||
while (pipeline-running-p pipeline)
|
||||
do (multiple-value-bind (path title)
|
||||
(if (listp entry)
|
||||
(values (getf entry :file) (getf entry :title))
|
||||
(values entry nil))
|
||||
(handler-case
|
||||
(let* ((done-lock (bt:make-lock "track-done"))
|
||||
(done-cv (bt:make-condition-variable :name "track-done"))
|
||||
(done-p nil)
|
||||
(server (pipeline-harmony-server pipeline))
|
||||
(harmony:*server* server)
|
||||
(voice (play-file pipeline path
|
||||
:title title
|
||||
:on-end (lambda (voice)
|
||||
(declare (ignore voice))
|
||||
(bt:with-lock-held (done-lock)
|
||||
(setf done-p t)
|
||||
(bt:condition-notify done-cv))))))
|
||||
(declare (ignore voice))
|
||||
;; Wait for the track to finish via callback
|
||||
(bt:with-lock-held (done-lock)
|
||||
(loop until (or done-p (not (pipeline-running-p pipeline)))
|
||||
do (bt:condition-wait done-cv done-lock)))
|
||||
(when (> gap 0) (sleep gap)))
|
||||
(error (e)
|
||||
(log:warn "Error playing ~A: ~A" path e)
|
||||
(sleep 1))))))
|
||||
(let ((prev-voice nil))
|
||||
(loop for entry in file-list
|
||||
for idx from 0
|
||||
while (pipeline-running-p pipeline)
|
||||
do (multiple-value-bind (path title)
|
||||
(if (listp entry)
|
||||
(values (getf entry :file) (getf entry :title))
|
||||
(values entry nil))
|
||||
(handler-case
|
||||
(let* ((server (pipeline-harmony-server pipeline))
|
||||
(harmony:*server* server))
|
||||
(multiple-value-bind (voice display-title)
|
||||
(play-file pipeline path :title title
|
||||
:on-end :disconnect
|
||||
:update-metadata (null prev-voice))
|
||||
(when voice
|
||||
;; If this isn't the first track, crossfade
|
||||
(when (and prev-voice (> idx 0))
|
||||
(setf (mixed:volume voice) 0.0)
|
||||
;; Fade in new voice and fade out old voice in parallel
|
||||
(let ((fade-thread
|
||||
(bt:make-thread
|
||||
(lambda ()
|
||||
(volume-ramp prev-voice 0.0 fade-out)
|
||||
(harmony:stop prev-voice))
|
||||
:name "cl-streamer-fadeout")))
|
||||
(volume-ramp voice 1.0 fade-in)
|
||||
(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)
|
||||
(log:warn "Error playing ~A: ~A" path e)
|
||||
(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"))
|
||||
|
||||
(declaim (inline float-to-s16))
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,43 +1,55 @@
|
|||
;;; End-to-end streaming test with playlist
|
||||
;;; End-to-end streaming test with playlist (MP3 + AAC)
|
||||
;;; Usage: sbcl --load test-stream.lisp
|
||||
;;;
|
||||
;;; Then open http://localhost:8000/stream.mp3 in VLC or browser
|
||||
;;; Then open 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.
|
||||
|
||||
(push #p"/home/glenn/SourceCode/harmony/" asdf:*central-registry*)
|
||||
(push #p"/home/glenn/SourceCode/asteroid/cl-streamer/" asdf:*central-registry*)
|
||||
|
||||
(ql:quickload '(:cl-streamer :cl-streamer/encoder :cl-streamer/harmony))
|
||||
(ql:quickload '(:cl-streamer :cl-streamer/encoder :cl-streamer/aac-encoder :cl-streamer/harmony))
|
||||
|
||||
(format t "~%=== CL-Streamer Playlist Test ===~%")
|
||||
(format t "~%=== CL-Streamer Playlist Test (MP3 + AAC) ===~%")
|
||||
(format t "LAME version: ~A~%" (cl-streamer::lame-version))
|
||||
|
||||
;; 1. Create and start stream server
|
||||
(format t "~%[1] Starting stream server on port 8000...~%")
|
||||
(cl-streamer:start :port 8000)
|
||||
|
||||
;; 2. Add mount point
|
||||
(format t "[2] Adding mount point /stream.mp3...~%")
|
||||
;; 2. Add mount points
|
||||
(format t "[2] Adding mount points...~%")
|
||||
(cl-streamer:add-mount cl-streamer:*server* "/stream.mp3"
|
||||
:content-type "audio/mpeg"
|
||||
:bitrate 128
|
||||
:name "Asteroid Radio (CL-Streamer Test)")
|
||||
:name "Asteroid Radio MP3")
|
||||
(cl-streamer:add-mount cl-streamer:*server* "/stream.aac"
|
||||
:content-type "audio/aac"
|
||||
:bitrate 128
|
||||
:name "Asteroid Radio AAC")
|
||||
|
||||
;; 3. Create MP3 encoder
|
||||
(format t "[3] Creating MP3 encoder (128kbps, 44100Hz, stereo)...~%")
|
||||
(defvar *encoder* (cl-streamer:make-mp3-encoder :sample-rate 44100
|
||||
:channels 2
|
||||
:bitrate 128))
|
||||
;; 3. Create encoders
|
||||
(format t "[3] Creating encoders...~%")
|
||||
(defvar *mp3-encoder* (cl-streamer:make-mp3-encoder :sample-rate 44100
|
||||
:channels 2
|
||||
:bitrate 128))
|
||||
(defvar *aac-encoder* (cl-streamer:make-aac-encoder :sample-rate 44100
|
||||
:channels 2
|
||||
:bitrate 128000))
|
||||
|
||||
;; 4. Create and start audio pipeline
|
||||
(format t "[4] Starting audio pipeline with Harmony...~%")
|
||||
;; 4. Create and start audio pipeline with both outputs
|
||||
(format t "[4] Starting audio pipeline with Harmony (MP3 + AAC)...~%")
|
||||
(defvar *pipeline* (cl-streamer/harmony:make-audio-pipeline
|
||||
:encoder *encoder*
|
||||
:encoder *mp3-encoder*
|
||||
:stream-server cl-streamer:*server*
|
||||
:mount-path "/stream.mp3"
|
||||
:sample-rate 44100
|
||||
:channels 2))
|
||||
|
||||
;; Add AAC as second output
|
||||
(cl-streamer/harmony:add-pipeline-output *pipeline* *aac-encoder* "/stream.aac")
|
||||
|
||||
(cl-streamer/harmony:start-pipeline *pipeline*)
|
||||
|
||||
;; 5. Build a playlist from the music library
|
||||
|
|
@ -48,25 +60,25 @@
|
|||
(let ((files nil))
|
||||
(dolist (dir (directory (merge-pathnames "*/" *music-dir*)))
|
||||
(dolist (flac (directory (merge-pathnames "**/*.flac" dir)))
|
||||
(push (list :file (namestring flac)
|
||||
:title (format nil "~A - ~A"
|
||||
(car (last (pathname-directory flac)))
|
||||
(pathname-name flac)))
|
||||
files)))
|
||||
(push (list :file (namestring flac)) files)))
|
||||
;; Shuffle and take first 10 tracks
|
||||
(subseq (alexandria:shuffle (copy-list files))
|
||||
0 (min 10 (length files)))))
|
||||
|
||||
(format t "Queued ~A tracks:~%" (length *playlist*))
|
||||
(dolist (entry *playlist*)
|
||||
(format t " ~A~%" (getf entry :title)))
|
||||
(format t " ~A~%" (getf entry :file)))
|
||||
|
||||
;; 6. Start playlist playback
|
||||
(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 "Listen at: http://localhost:8000/stream.mp3~%")
|
||||
(format t "MP3: http://localhost:8000/stream.mp3~%")
|
||||
(format t "AAC: http://localhost:8000/stream.aac~%")
|
||||
(format t "~%Press Enter to stop...~%")
|
||||
|
||||
(read-line)
|
||||
|
|
@ -74,6 +86,7 @@
|
|||
;; Cleanup
|
||||
(format t "Stopping...~%")
|
||||
(cl-streamer/harmony:stop-pipeline *pipeline*)
|
||||
(cl-streamer:close-encoder *encoder*)
|
||||
(cl-streamer:close-encoder *mp3-encoder*)
|
||||
(cl-streamer:close-aac-encoder *aac-encoder*)
|
||||
(cl-streamer:stop)
|
||||
(format t "Done.~%")
|
||||
|
|
|
|||
Loading…
Reference in New Issue