371 lines
17 KiB
Org Mode
371 lines
17 KiB
Org Mode
#+TITLE: CL-Streamer Standalone Library Refactor
|
|
#+AUTHOR: Glenn Thompson
|
|
#+DATE: 2026-03-07
|
|
#+UPDATED: 2026-03-08
|
|
#+DESCRIPTION: Plan for extracting cl-streamer into a standalone, reusable Common Lisp streaming library
|
|
|
|
* 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 in =protocol.lisp=
|
|
- *Phase 2* (b7266e3): Clean boundaries — =make-pipeline= DSL, =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)
|
|
|
|
#+begin_quote
|
|
"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."
|
|
#+end_quote
|
|
|
|
Current architecture (implemented):
|
|
|
|
#+begin_example
|
|
┌─────────────────────────┐ ┌──────────────────────────────┐
|
|
│ 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)
|
|
#+end_example
|
|
|
|
* 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:
|
|
|
|
#+begin_src lisp
|
|
;; 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."))
|
|
#+end_src
|
|
|
|
** 1.2 Generic Functions (the Protocol)
|
|
|
|
Implemented in =cl-streamer/protocol.lisp=, exported from =cl-streamer= package:
|
|
|
|
#+begin_src lisp
|
|
;;; 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))
|
|
#+end_src
|
|
|
|
** 1.3 Callback Protocol
|
|
|
|
Hook system implemented — replaces the old =setf= slot approach:
|
|
|
|
#+begin_src lisp
|
|
(defgeneric pipeline-add-hook (pipeline event function))
|
|
(defgeneric pipeline-remove-hook (pipeline event function))
|
|
|
|
;; Events: :track-change, :playlist-change, :pipeline-start, :pipeline-stop
|
|
#+end_src
|
|
|
|
** 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:*
|
|
|
|
1. *Idiomatic* — Harmony and cl-mixed use keyword-heavy APIs throughout.
|
|
Keyword args follow the same convention.
|
|
2. *Compile-time checking* — typos like =:fromat= are caught immediately;
|
|
a quoted plist silently carries bad keys until runtime parsing catches them.
|
|
3. *No parser needed* — CLOS =&key= destructuring handles dispatch for free.
|
|
A quoted plist requires a DSL interpreter to destructure, validate, and dispatch.
|
|
4. *Composability* — the =:server= parameter naturally accepts a live server
|
|
object for pipeline sharing. A quoted plist would need to special-case =:server= to accept either a spec /or/ an existing object.
|
|
5. *Extensible* — new options (=:crossfade-duration=, =:loop-queue=) are just
|
|
additional =&key= parameters. No parser changes required.
|
|
|
|
Asteroid calls this at startup:
|
|
|
|
#+begin_src lisp
|
|
;; 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"))))
|
|
#+end_src
|
|
|
|
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-socket= with =:connect :passive= for the listener
|
|
- =iolib:accept-connection= for new clients
|
|
- Per-client threads with iolib sockets
|
|
- =iolib:socket-option= for =SO_KEEPALIVE=, =TCP_NODELAY=
|
|
- =SO_SNDTIMEO= for 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 generation
|
|
- =playlist-scheduler.lisp= — cron-based playlist switching
|
|
- =dj-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.so= and =fdkaac-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 and =ensure-server= / =start= / =stop= convenience functions
|
|
- =write-audio-data=, =set-now-playing=, =get-listener-count= now take server arg
|
|
- Added =server= slot to =streaming-drain= (mix method no longer needs global)
|
|
- =make-pipeline= no longer sets global =*server*=
|
|
- =pipeline-stop= respects =pipeline-owns-server-p= to 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.lisp= updated to use protocol generics (no more =harmony:*server*=
|
|
binding or =::= internal access)
|
|
- =stream-harmony.lisp= + =listener-stats.lisp= use =pipeline-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-change= hook (batches of 20)
|
|
- Frontend channel selector routes to correct mount URLs
|
|
- =listener-stats.lisp= polls both curated and shuffle mounts
|
|
- Fixed stale =cl-streamer:get-listener-count= calls
|
|
- Normalized all now-playing polling to 15s
|
|
|
|
* Task Checklist
|
|
|
|
** Phase 1: Protocol Definition — DONE ✓ (c79cebc)
|
|
- [X] Create =cl-streamer/protocol.lisp= with protocol classes and generic functions
|
|
- [X] Update =cl-streamer/package.lisp= to export protocol symbols
|
|
- [X] Implement protocol in =harmony-backend.lisp= (methods on existing classes)
|
|
- [X] Update =stream-harmony.lisp= to use protocol generics instead of direct calls
|
|
- [X] Verify: =make 2>&1= compiles, audio plays, metadata updates
|
|
|
|
** Phase 2: Clean Boundaries — DONE ✓ (b7266e3)
|
|
- [X] Implement =make-pipeline= DSL for declarative pipeline creation
|
|
- [X] Move encoder lifecycle into pipeline (pipeline owns encoders)
|
|
- [X] Move mount creation into pipeline configuration
|
|
- [X] Remove global =*server*= — pipeline-scoped server
|
|
- [X] Add hook system for callbacks (=pipeline-add-hook= / =pipeline-remove-hook=)
|
|
- [X] Update =dj-session.lisp= to use protocol generics
|
|
- [X] Verify: full integration test (auto-playlist + DJ console + skip + metadata)
|
|
|
|
** Phase 3: iolib Refactor — DONE ✓ (2e86dc4)
|
|
- [X] Install iolib and libfixposix on dev machine
|
|
- [X] Rewrite =stream-server.lisp= socket layer to use iolib
|
|
- [X] Add SO_KEEPALIVE and TCP_NODELAY to client sockets
|
|
- [X] Add write timeouts for stale client detection
|
|
- [X] Update =cl-streamer.asd= dependencies
|
|
- [X] Stress test: multiple concurrent listeners, client disconnect scenarios
|
|
- [ ] Test on VPS (ensure libfixposix available)
|
|
|
|
** Phase 4: Extraction — DONE ✓ (8d9d2b3)
|
|
- [X] Create separate git repository for cl-streamer
|
|
- [X] Write proper README with usage examples
|
|
- [ ] Register on Ultralisp for Quicklisp distribution (optional, deferred)
|
|
- [X] Update Asteroid to load cl-streamer as external dependency (git submodule)
|
|
|
|
** Step 1: Eliminate *server* Global — DONE ✓ (c0f9c0e / 3e6b496)
|
|
- [X] Remove =*server*= global from =cl-streamer.lisp=
|
|
- [X] Make =write-audio-data=, =set-now-playing=, =get-listener-count= take server arg
|
|
- [X] Add DJ protocol generics
|
|
- [X] Update =dj-session.lisp= to use protocol generics
|
|
- [X] Update =stream-harmony.lisp= + =listener-stats.lisp= to use =pipeline-listener-count=
|
|
- [X] Build verified, runtime tested
|
|
|
|
** Step 2: Shuffle Stream — DONE ✓ (5efa493)
|
|
- [X] Implement shuffle pipeline in =stream-harmony.lisp=
|
|
- [X] Wire shuffle start/stop into =asteroid.lisp= startup/shutdown/restart
|
|
- [X] Add shuffle mounts to listener stats polling
|
|
- [X] Frontend channel selector routes to shuffle mounts
|
|
- [X] 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
|