diff --git a/cl-streamer/README.org b/cl-streamer/README.org index 0114ca4..3219377 100644 --- a/cl-streamer/README.org +++ b/cl-streamer/README.org @@ -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 diff --git a/cl-streamer/aac-encoder.lisp b/cl-streamer/aac-encoder.lisp index 94d1e15..9b93f5f 100644 --- a/cl-streamer/aac-encoder.lisp +++ b/cl-streamer/aac-encoder.lisp @@ -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)))) diff --git a/cl-streamer/buffer.lisp b/cl-streamer/buffer.lisp index f5688ef..b146b85 100644 --- a/cl-streamer/buffer.lisp +++ b/cl-streamer/buffer.lisp @@ -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) diff --git a/cl-streamer/fdkaac-ffi.lisp b/cl-streamer/fdkaac-ffi.lisp index e522f28..fde989d 100644 --- a/cl-streamer/fdkaac-ffi.lisp +++ b/cl-streamer/fdkaac-ffi.lisp @@ -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)) diff --git a/cl-streamer/fdkaac-shim.c b/cl-streamer/fdkaac-shim.c new file mode 100644 index 0000000..d8dc5c5 --- /dev/null +++ b/cl-streamer/fdkaac-shim.c @@ -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 +#include + +/* 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); +} diff --git a/cl-streamer/harmony-backend.lisp b/cl-streamer/harmony-backend.lisp index 18e7f66..a3fe94f 100644 --- a/cl-streamer/harmony-backend.lisp +++ b/cl-streamer/harmony-backend.lisp @@ -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) @@ -140,40 +172,88 @@ (log:info "Now playing: ~A" display-title) voice))) -(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) + (voice (play-file pipeline path :title title + :on-end :disconnect))) + (when voice + ;; If this isn't the first track, fade in from 0 + (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))) + ;; Wait for track to approach its end + (sleep 0.5) ; let decoder start + (loop while (and (pipeline-running-p pipeline) + (not (mixed:done-p voice))) + for remaining = (voice-remaining-seconds voice) + ;; Start crossfade when we're within crossfade-duration of the end + when (and remaining + (<= remaining crossfade-duration) + (not (mixed:done-p voice))) + do (setf prev-voice voice) + (return) ; break out to start next track + 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)) diff --git a/cl-streamer/libfdkaac-shim.so b/cl-streamer/libfdkaac-shim.so new file mode 100755 index 0000000..54ff8d2 Binary files /dev/null and b/cl-streamer/libfdkaac-shim.so differ diff --git a/cl-streamer/test-stream.lisp b/cl-streamer/test-stream.lisp index d11ec58..266c833 100644 --- a/cl-streamer/test-stream.lisp +++ b/cl-streamer/test-stream.lisp @@ -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 @@ -63,10 +75,14 @@ ;; 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 +90,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.~%")