diff --git a/cl-streamer/README.org b/cl-streamer/README.org index 92f1e9c..0114ca4 100644 --- a/cl-streamer/README.org +++ b/cl-streamer/README.org @@ -1,6 +1,6 @@ #+TITLE: CL-Streamer #+AUTHOR: Glenn Thompson -#+DATE: 2026-03-03 +#+DATE: 2026-15-02 * Overview diff --git a/cl-streamer/harmony-backend.lisp b/cl-streamer/harmony-backend.lisp new file mode 100644 index 0000000..704262f --- /dev/null +++ b/cl-streamer/harmony-backend.lisp @@ -0,0 +1,122 @@ +(defpackage #:cl-streamer/harmony + (:use #:cl #:alexandria) + (:local-nicknames (#:harmony #:org.shirakumo.fraf.harmony) + (#:mixed #:org.shirakumo.fraf.mixed)) + (:export #:audio-pipeline + #:make-audio-pipeline + #:start-pipeline + #:stop-pipeline + #:play-file + #:pipeline-encoder + #:pipeline-server + #:make-streaming-server)) + +(in-package #:cl-streamer/harmony) + +(defclass audio-pipeline () + ((harmony-server :initform nil :accessor pipeline-harmony-server) + (encoder :initarg :encoder :accessor pipeline-encoder) + (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) + (channels :initarg :channels :accessor pipeline-channels :initform 2) + (running :initform nil :accessor pipeline-running-p) + (encode-thread :initform nil :accessor pipeline-encode-thread))) + +(defun make-streaming-server (&key (name "CL-Streamer") (samplerate 44100) (latency 0.02)) + "Create a Harmony server configured for streaming (no audio output). + Uses :dummy drain so audio goes to buffer instead of speakers." + (mixed:init) + (harmony:make-simple-server :name name + :samplerate samplerate + :latency latency + :drain :dummy + :output-channels 2)) + +(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)) + +(defun start-pipeline (pipeline) + "Start the audio pipeline - initializes Harmony and begins encoding." + (when (pipeline-running-p pipeline) + (error "Pipeline already running")) + (let ((server (make-streaming-server :samplerate (pipeline-sample-rate pipeline)))) + (setf (pipeline-harmony-server pipeline) server) + (mixed:start server)) + (setf (pipeline-running-p pipeline) t) + (setf (pipeline-encode-thread pipeline) + (bt:make-thread (lambda () (encode-loop pipeline)) + :name "cl-streamer-encode")) + (log:info "Audio pipeline started") + pipeline) + +(defun stop-pipeline (pipeline) + "Stop the audio pipeline." + (setf (pipeline-running-p pipeline) nil) + (when (pipeline-encode-thread pipeline) + (ignore-errors (bt:join-thread (pipeline-encode-thread pipeline))) + (setf (pipeline-encode-thread pipeline) nil)) + (when (pipeline-harmony-server pipeline) + (mixed:end (pipeline-harmony-server pipeline)) + (setf (pipeline-harmony-server pipeline) nil)) + (log:info "Audio pipeline stopped") + pipeline) + +(defun play-file (pipeline file-path &key (mixer :music)) + "Play an audio file through the pipeline. + The file will be decoded by Harmony and encoded for streaming." + (let* ((server (pipeline-harmony-server pipeline)) + (harmony:*server* server)) + (let ((voice (harmony:play file-path :mixer mixer))) + (log:info "Playing: ~A" file-path) + voice))) + +(defun get-pack-buffer (pipeline) + "Get the packer's pack (bip-buffer) from Harmony's output chain." + (let* ((server (pipeline-harmony-server pipeline)) + (output (harmony:segment :output server)) + (packer (harmony:segment :packer output))) + (mixed:pack packer))) + +(defun encode-loop (pipeline) + "Main encoding loop - reads PCM from Harmony's packer, encodes, writes to stream." + (let ((encoder (pipeline-encoder pipeline)) + (mount-path (pipeline-mount-path pipeline)) + (channels (pipeline-channels pipeline)) + (frame-size 1152)) + (loop while (pipeline-running-p pipeline) + do (handler-case + (let* ((pack (get-pack-buffer pipeline)) + (available (mixed:available-read pack)) + (needed (* frame-size channels 2))) + (if (>= available needed) + (multiple-value-bind (data start size) + (mixed:request-read pack needed) + (declare (ignore start)) + (when (and data (>= size needed)) + (let* ((samples (floor size (* channels 2))) + (pcm-buffer (make-array (* samples channels) + :element-type '(signed-byte 16)))) + (loop for i below (* samples channels) + do (setf (aref pcm-buffer i) + (let ((byte-offset (* i 2))) + (logior (aref data byte-offset) + (ash (let ((hi (aref data (1+ byte-offset)))) + (if (> hi 127) (- hi 256) hi)) + 8))))) + (mixed:finish-read pack size) + (let ((mp3-data (cl-streamer::encode-pcm-interleaved + encoder pcm-buffer samples))) + (when (> (length mp3-data) 0) + (cl-streamer::write-audio-data mount-path mp3-data)))))) + (sleep 0.005))) + (error (e) + (log:warn "Encode error: ~A" e) + (sleep 0.1))))))