17 KiB
CL-Streamer Standalone Library Refactor
- Overview
- Phase 1: Define the Protocol (CLOS Generic Functions) — DONE ✓
- Phase 2: Clean Package Boundaries — DONE ✓
- Phase 3: Refactor Stream Server to iolib — DONE ✓
- Phase 4: Separate Repository — DONE ✓
- Step 1: Eliminate server Global — DONE ✓
- Step 2: Shuffle Stream as Second Pipeline — DONE ✓
- Task Checklist
- Notes
Overview
Extract cl-streamer from its current role as an embedded subsystem of Asteroid Radio
into a standalone, reusable Common Lisp audio streaming library. This aligns with Fade's
architecture vision: logically separated concerns with a stream daemon, a queue manager,
and the application layer communicating through well-defined protocols.
The goal is a library that any CL application could use to stream audio over HTTP, without any Asteroid-specific knowledge baked in.
Current State (Updated 2026-03-08)
cl-streamer is now a standalone git repository (https://github.com/glenneth1/cl-streamer)
loaded as a git submodule. It has a clean CLOS protocol layer, a declarative pipeline DSL,
a hook system for callbacks, and iolib-based networking. All Asteroid-specific coupling
has been moved to the glue layer (stream-harmony.lisp).
Completed milestones:
- Phase 1 (
c79cebc): CLOS protocol layer — generic functions inprotocol.lisp - Phase 2 (
b7266e3): Clean boundaries —make-pipelineDSL,pipeline-add-hook, encoder lifecycle owned by pipeline - Phase 3 (
2e86dc4): iolib refactor — SO_KEEPALIVE, TCP_NODELAY, SO_SNDTIMEO - Phase 4 (
8d9d2b3): Extracted to standalone repo, loaded as git submodule - Step 1 (cl-streamer c0f9c0e, asteroid
3e6b496): Eliminated*server*global, DJ protocol generics - Step 2 (asteroid
5efa493): Shuffle stream as second pipeline — shared server, separate mounts, full library random
Architecture Vision (per Fade)
"I want the basic architecture of the station to stay roughly the same. The concerns are logically separated, with a daemon holding up the streams, and a queue manager standing between the asteroid application and the stream daemon. This allows scaling horizontally in an unintrusive way."
Current architecture (implemented):
┌─────────────────────────┐ ┌──────────────────────────────┐
│ Asteroid Application │ │ cl-streamer (standalone) │
│ │ │ │
│ Playlist Scheduler ────┼──>──┤ Pipeline Protocol │
│ DJ Session ────────────┼──>──┤ (generic functions / CLOS) │
│ Admin APIs ────────────┼──>──┤ │
│ Track Metadata ────────┼──>──┤ Stream Server (iolib) │
│ Shuffle Stream ────────┼──>──┤ Encoders (MP3, AAC) │
│ Frontend/ParenScript │ │ Broadcast Buffer │
│ │ │ Harmony Backend │
│ Rate Limiting │ │ ICY Protocol │
│ User Management │ │ Hook System │
└─────────────────────────┘ └──────────────────────────────┘
Application Library (git submodule)
Phase 1: Define the Protocol (CLOS Generic Functions) — DONE ✓
The protocol layer decouples Asteroid from cl-streamer internals. Implemented in
cl-streamer/protocol.lisp (c79cebc).
1.1 Streaming Protocol Classes
Protocol classes that cl-streamer exports and Asteroid programs against:
;; Protocol classes — no implementation, just interface
(defclass stream-pipeline () ()
(:documentation "Protocol class for an audio streaming pipeline."))
(defclass stream-encoder () ()
(:documentation "Protocol class for an audio encoder (MP3, AAC, etc)."))
(defclass stream-server () ()
(:documentation "Protocol class for the HTTP stream server."))
1.2 Generic Functions (the Protocol)
Implemented in cl-streamer/protocol.lisp, exported from cl-streamer package:
;;; Pipeline lifecycle
(defgeneric pipeline-start (pipeline))
(defgeneric pipeline-stop (pipeline))
(defgeneric pipeline-running-p (pipeline))
;;; Playback control
(defgeneric pipeline-play-file (pipeline file-path))
(defgeneric pipeline-play-list (pipeline file-list &key on-track-change on-playlist-change))
(defgeneric pipeline-skip (pipeline))
(defgeneric pipeline-queue-files (pipeline files))
(defgeneric pipeline-clear-queue (pipeline))
(defgeneric pipeline-get-queue (pipeline))
;;; State queries
(defgeneric pipeline-current-track (pipeline))
(defgeneric pipeline-listener-count (pipeline &optional mount))
;;; Metadata
(defgeneric pipeline-update-metadata (pipeline mount title &optional url))
;;; DJ voice control (added in Step 1, c0f9c0e)
(defgeneric pipeline-play-voice (pipeline file-path &key))
(defgeneric pipeline-stop-voice (pipeline voice))
(defgeneric pipeline-stop-all-voices (pipeline))
(defgeneric pipeline-volume-ramp (pipeline voice target-volume duration))
(defgeneric pipeline-read-metadata (pipeline file-path))
(defgeneric pipeline-format-title (pipeline file-path))
;;; Encoder protocol
(defgeneric encoder-encode (encoder pcm-data &key start end))
(defgeneric encoder-flush (encoder))
(defgeneric encoder-close (encoder))
;;; Server protocol
(defgeneric server-start (server))
(defgeneric server-stop (server))
(defgeneric server-add-mount (server path &key content-type bitrate name))
(defgeneric server-remove-mount (server path))
(defgeneric server-write-audio (server mount-path data &key start end))
1.3 Callback Protocol
Hook system implemented — replaces the old setf slot approach:
(defgeneric pipeline-add-hook (pipeline event function))
(defgeneric pipeline-remove-hook (pipeline event function))
;; Events: :track-change, :playlist-change, :pipeline-start, :pipeline-stop
1.4 Files Created/Modified
| File | Action | Description |
|---|---|---|
cl-streamer/protocol.lisp |
CREATED | Protocol classes and generic functions |
cl-streamer/package.lisp |
MODIFIED | Export protocol symbols |
cl-streamer/cl-streamer.asd |
MODIFIED | Add protocol.lisp to all systems |
cl-streamer/harmony-backend.lisp |
MODIFIED | Implement protocol for Harmony backend |
Phase 2: Clean Package Boundaries — DONE ✓
Implemented in b7266e3, with further refinement in Step 1 (c0f9c0e).
2.1 Asteroid-Specific Code Separation — DONE ✓
stream-harmony.lisp is now a thin adapter. All encoder/mount/server wiring is inside
cl-streamer's make-pipeline DSL.
| Before | After (current) |
|---|---|
| Creates encoders explicitly | make-pipeline :outputs handles it |
| Manages mount points manually | Pipeline auto-creates from output specs |
| Stores encoder instances as globals | Pipeline owns encoder lifecycle |
| Track-change callback via setf | Uses pipeline-add-hook |
| Docker path conversion | Stays in Asteroid (app-specific) |
| Playback state persistence | Stays in Asteroid (app-specific) |
| Playlist scheduling | Stays in Asteroid (app-specific) |
2.2 Declarative Pipeline Configuration (DSL) — DONE ✓
The declarative make-pipeline uses keyword arguments rather than the originally
proposed quoted-plist form. The result is functionally equivalent — both describe
what you want and let make-pipeline handle the wiring.
Why keyword args instead of a quoted plist:
- Idiomatic — Harmony and cl-mixed use keyword-heavy APIs throughout. Keyword args follow the same convention.
- Compile-time checking — typos like
:fromatare caught immediately; a quoted plist silently carries bad keys until runtime parsing catches them. - No parser needed — CLOS
&keydestructuring handles dispatch for free. A quoted plist requires a DSL interpreter to destructure, validate, and dispatch. - Composability — the
:serverparameter naturally accepts a live server object for pipeline sharing. A quoted plist would need to special-case:serverto accept either a spec or an existing object. - Extensible — new options (
:crossfade-duration,:loop-queue) are just additional&keyparameters. No parser changes required.
Asteroid calls this at startup:
;; Curated stream pipeline (start-harmony-streaming)
(setf *harmony-pipeline*
(cl-streamer/harmony:make-pipeline
:port 8000
:outputs (list (list :format :mp3
:mount "/asteroid.mp3"
:bitrate 128
:name "Asteroid Radio MP3")
(list :format :aac
:mount "/asteroid.aac"
:bitrate 96
:name "Asteroid Radio AAC"))))
;; Shuffle stream pipeline (start-shuffle-streaming)
;; Shares the curated pipeline's server via :server parameter
(setf *shuffle-pipeline*
(cl-streamer/harmony:make-pipeline
:server (cl-streamer/harmony:pipeline-server *harmony-pipeline*)
:outputs (list (list :format :mp3
:mount "/shuffle.mp3"
:bitrate 128
:name "Asteroid Radio Shuffle MP3")
(list :format :aac
:mount "/shuffle.aac"
:bitrate 128
:name "Asteroid Radio Shuffle AAC"))))
The DSL handles: encoder creation, mount registration, drain wiring, and server
ownership tracking. Multiple pipelines can share a server via :server.
2.3 Remove Global State — DONE ✓
*server* global eliminated (Step 1, c0f9c0e). All state is pipeline-scoped.
Multiple pipelines work simultaneously (curated + shuffle, verified in Step 2).
Phase 3: Refactor Stream Server to iolib — DONE ✓
Implemented in 2e86dc4. Replaced usocket with iolib.
3.1 What Changed
| File | Change |
|---|---|
cl-streamer/stream-server.lisp |
Rewrote socket handling to use iolib |
cl-streamer/cl-streamer.asd |
Replaced usocket dependency with iolib |
cl-streamer/package.lisp |
Updated exports |
3.2 iolib Features in Use
iolib:make-socketwith:connect :passivefor the listeneriolib:accept-connectionfor new clients- Per-client threads with iolib sockets
iolib:socket-optionforSO_KEEPALIVE,TCP_NODELAYSO_SNDTIMEOfor write timeouts to detect stale clients
3.3 Deployment Note
- iolib requires
libfixposix— must be installed on VPS before deployment
Phase 4: Separate Repository — DONE ✓
Extracted in 8d9d2b3. cl-streamer is now at https://github.com/glenneth1/cl-streamer
(default branch: master). Loaded into Asteroid as a git submodule.
4.1 What Stays in Asteroid
stream-harmony.lisp— glue layer / adapter (curated + shuffle pipelines)stream-control.lisp— queue management, playlist generationplaylist-scheduler.lisp— cron-based playlist switchingdj-session.lisp— DJ console logic (uses protocol generics)- All ParenScript/frontend code (channel selector, now-playing)
- Docker path conversion, playback state persistence
4.2 What Lives in cl-streamer (standalone repo)
cl-streamer/directory (git submodule)libfdkaac-shim.soandfdkaac-shim.c- Protocol definitions (
protocol.lisp) - Encoder implementations (LAME MP3, FDK-AAC)
- Harmony backend (
harmony-backend.lisp) - Stream server (
stream-server.lisp, iolib) - Broadcast buffer
- ICY protocol
4.3 Future: Quicklisp/Ultralisp
Optional — register on Ultralisp once the API stabilizes. Currently loaded via git submodule which is sufficient for Asteroid's needs.
Step 1: Eliminate server Global — DONE ✓
cl-streamer c0f9c0e, asteroid 3e6b496.
- Removed
*server*global andensure-server/start/stopconvenience functions write-audio-data,set-now-playing,get-listener-countnow take server arg- Added
serverslot tostreaming-drain(mix method no longer needs global) make-pipelineno longer sets global*server*pipeline-stoprespectspipeline-owns-server-pto avoid stopping shared servers- New DJ protocol generics:
pipeline-play-voice,pipeline-stop-voice,pipeline-stop-all-voices,pipeline-volume-ramp,pipeline-read-metadata,pipeline-format-title dj-session.lispupdated to use protocol generics (no moreharmony:*server*binding or::internal access)stream-harmony.lisp+listener-stats.lispusepipeline-listener-count
Step 2: Shuffle Stream as Second Pipeline — DONE ✓
asteroid 5efa493.
- Shared HTTP server (port 8000), separate mounts (
/shuffle.mp3,/shuffle.aac) - Separate Harmony instance with own MP3 + AAC encoders
- Random track selection from music library directory (2797 tracks, 1hr cache TTL)
- Auto-refill queue via
:track-changehook (batches of 20) - Frontend channel selector routes to correct mount URLs
listener-stats.lisppolls both curated and shuffle mounts- Fixed stale
cl-streamer:get-listener-countcalls - Normalized all now-playing polling to 15s
Task Checklist
Phase 1: Protocol Definition — DONE ✓ (c79cebc)
- Create
cl-streamer/protocol.lispwith protocol classes and generic functions - Update
cl-streamer/package.lispto export protocol symbols - Implement protocol in
harmony-backend.lisp(methods on existing classes) - Update
stream-harmony.lispto use protocol generics instead of direct calls - Verify:
make 2>&1compiles, audio plays, metadata updates
Phase 2: Clean Boundaries — DONE ✓ (b7266e3)
- Implement
make-pipelineDSL for declarative pipeline creation - Move encoder lifecycle into pipeline (pipeline owns encoders)
- Move mount creation into pipeline configuration
- Remove global
*server*— pipeline-scoped server - Add hook system for callbacks (
pipeline-add-hook/pipeline-remove-hook) - Update
dj-session.lispto use protocol generics - Verify: full integration test (auto-playlist + DJ console + skip + metadata)
Phase 3: iolib Refactor — DONE ✓ (2e86dc4)
- Install iolib and libfixposix on dev machine
- Rewrite
stream-server.lispsocket layer to use iolib - Add SO_KEEPALIVE and TCP_NODELAY to client sockets
- Add write timeouts for stale client detection
- Update
cl-streamer.asddependencies - Stress test: multiple concurrent listeners, client disconnect scenarios
- Test on VPS (ensure libfixposix available)
Phase 4: Extraction — DONE ✓ (8d9d2b3)
- Create separate git repository for cl-streamer
- Write proper README with usage examples
- Register on Ultralisp for Quicklisp distribution (optional, deferred)
- Update Asteroid to load cl-streamer as external dependency (git submodule)
Step 1: Eliminate server Global — DONE ✓ (c0f9c0e / 3e6b496)
- Remove
*server*global fromcl-streamer.lisp - Make
write-audio-data,set-now-playing,get-listener-counttake server arg - Add DJ protocol generics
- Update
dj-session.lispto use protocol generics - Update
stream-harmony.lisp+listener-stats.lispto usepipeline-listener-count - Build verified, runtime tested
Step 2: Shuffle Stream — DONE ✓ (5efa493)
- Implement shuffle pipeline in
stream-harmony.lisp - Wire shuffle start/stop into
asteroid.lispstartup/shutdown/restart - Add shuffle mounts to listener stats polling
- Frontend channel selector routes to shuffle mounts
- Build verified, runtime tested (both streams play simultaneously)
Remaining Work
- Step 3: Full integration test (auto-playlist + DJ console + shuffle + skip + metadata)
- Step 4: External encoder source protocol (Icecast source compatibility)
- VPS deployment (libfixposix needed for iolib)
Notes
- Asteroid branch:
glenneth/cl-streamer-standalone - cl-streamer repo: https://github.com/glenneth1/cl-streamer (default branch:
master) - Each phase ended with a working, tested system
- The C shim for FDK-AAC (
libfdkaac-shim.so) travels with cl-streamer — it's needed to avoid SBCL signal handler conflicts with FDK-AAC internals - TLS is handled externally (HAProxy or similar), not in cl-streamer
- Relay clustering deemed unnecessary for current scale