diff --git a/SPECTRUM-ANALYZER.org b/SPECTRUM-ANALYZER.org new file mode 100644 index 0000000..f4e16b4 --- /dev/null +++ b/SPECTRUM-ANALYZER.org @@ -0,0 +1,62 @@ +#+TITLE: Spectrum Analyzer Implementation +#+AUTHOR: Asteroid Radio +#+DATE: 2025-12-02 + +* Overview +Real-time audio spectrum analyzer for Asteroid Radio, implemented using Parenscript (Lisp-to-JavaScript compiler). + +* Features +- *Real-time visualization* of audio frequencies using Web Audio API +- *Green gradient bars* matching Asteroid Radio's aesthetic +- *Smooth animations* with fade effects +- *Responsive design* - canvas scales to fit container +- *Auto-start* - begins when audio playback starts + +* Implementation Details + +** Files Modified +1. *asteroid.asd* - Added Parenscript dependency +2. *spectrum-analyzer.lisp* - Parenscript code that generates JavaScript +3. *module.lisp* - Compile-time hook to generate JS file +4. *template/front-page.ctml* - Added canvas element and script tag + +** Technical Approach +- Uses *Web Audio API* =AnalyserNode= for FFT analysis +- *256 frequency bins* for balanced detail and performance +- *Canvas 2D* rendering with gradient fills +- *RequestAnimationFrame* for smooth 60fps animation +- *Event-driven* - starts on audio play, stops on pause + +** Parenscript Benefits +- *Type safety* - Lisp macros catch errors at compile time +- *Code reuse* - Share utilities between server and client +- *Maintainability* - Single language for full stack +- *Integration* - Seamless with existing Radiance/Clip infrastructure + +* Usage + +** Generating JavaScript +The JavaScript is automatically generated at compile time. To manually regenerate: + +#+BEGIN_SRC lisp +(asteroid:write-spectrum-analyzer-js) +#+END_SRC + +This creates =static/js/spectrum-analyzer.js= from the Parenscript code. + +** Customization +Edit =spectrum-analyzer.lisp= to modify: +- *FFT size* - =(setf (@ *analyser* fft-size) 256)= +- *Colors* - Gradient color stops in =draw-spectrum= +- *Smoothing* - =(setf (@ *analyser* smoothing-time-constant) 0.8)= +- *Canvas size* - Update width/height in template + +* Future Enhancements +- Multiple visualization modes (waveform, circular, particle effects) +- User-selectable color schemes +- Frequency range filtering +- Beat detection and reactive animations +- Integration with now-playing metadata + +* Notes +This is the first Parenscript component in Asteroid Radio. Future work will convert existing JavaScript files to Parenscript as part of the =glenneth/parenscript-conversion= branch. diff --git a/asteroid.asd b/asteroid.asd index a4ad468..718182f 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -15,6 +15,7 @@ :cl-json :radiance :lass + :parenscript :local-time :taglib :ironclad @@ -31,7 +32,6 @@ :r-simple-rate (:interface :auth) (:interface :database) - (:interface :relational-database) (:interface :user)) :pathname "./" :components ((:file "app-utils") @@ -41,6 +41,7 @@ (:file "conditions") (:file "database") (:file "template-utils") + (:file "spectrum-analyzer") (:file "stream-media") (:file "user-management") (:file "playlist-management") diff --git a/module.lisp b/module.lisp index f9bafef..5a2239c 100644 --- a/module.lisp +++ b/module.lisp @@ -4,3 +4,10 @@ (:use #:cl #:radiance #:lass #:r-clip #:asteroid.app-utils) (:domain "asteroid") (:export #:-main)) + +;; Generate Parenscript files at compile time +(eval-when (:compile-toplevel :load-toplevel :execute) + (when (find-package :asteroid) + (let ((pkg (find-package :asteroid))) + (when (fboundp (find-symbol "WRITE-SPECTRUM-ANALYZER-JS" pkg)) + (funcall (find-symbol "WRITE-SPECTRUM-ANALYZER-JS" pkg)))))) diff --git a/spectrum-analyzer.lisp b/spectrum-analyzer.lisp new file mode 100644 index 0000000..f0b8640 --- /dev/null +++ b/spectrum-analyzer.lisp @@ -0,0 +1,96 @@ +(in-package #:asteroid) + +;;; Spectrum Analyzer - Parenscript Implementation +;;; Generates JavaScript for real-time audio visualization + +(defun generate-spectrum-analyzer-js () + "Generate JavaScript code for the spectrum analyzer using Parenscript" + (ps:ps + (defvar *audio-context* nil) + (defvar *analyser* nil) + (defvar *canvas* nil) + (defvar *canvas-ctx* nil) + (defvar *animation-id* nil) + + (defun init-spectrum-analyzer () + "Initialize the spectrum analyzer" + (let ((audio-element (chain document (get-element-by-id "live-audio"))) + (canvas-element (chain document (get-element-by-id "spectrum-canvas")))) + + (when (and audio-element canvas-element) + ;; Create Audio Context + (setf *audio-context* (new (or (@ window -audio-context) + (@ window -webkit-audio-context)))) + + ;; Create Analyser Node + (setf *analyser* (chain *audio-context* (create-analyser))) + (setf (@ *analyser* fft-size) 256) + (setf (@ *analyser* smoothing-time-constant) 0.8) + + ;; Connect audio source to analyser + (let ((source (chain *audio-context* (create-media-element-source audio-element)))) + (chain source (connect *analyser*)) + (chain *analyser* (connect (@ *audio-context* destination)))) + + ;; Setup canvas + (setf *canvas* canvas-element) + (setf *canvas-ctx* (chain *canvas* (get-context "2d"))) + + ;; Start visualization + (draw-spectrum)))) + + (defun draw-spectrum () + "Draw the spectrum analyzer visualization" + (setf *animation-id* (request-animation-frame draw-spectrum)) + + (let* ((buffer-length (@ *analyser* frequency-bin-count)) + (data-array (new (-uint8-array buffer-length))) + (width (@ *canvas* width)) + (height (@ *canvas* height)) + (bar-width (/ width buffer-length)) + (bar-height 0) + (x 0)) + + (chain *analyser* (get-byte-frequency-data data-array)) + + ;; Clear canvas with fade effect + (setf (@ *canvas-ctx* fill-style) "rgba(0, 0, 0, 0.2)") + (chain *canvas-ctx* (fill-rect 0 0 width height)) + + ;; Draw bars + (dotimes (i buffer-length) + (setf bar-height (/ (* (aref data-array i) height) 256)) + + ;; Create gradient for each bar + (let ((gradient (chain *canvas-ctx* + (create-linear-gradient 0 (- height bar-height) 0 height)))) + (chain gradient (add-color-stop 0 "#00ff00")) + (chain gradient (add-color-stop 0.5 "#00aa00")) + (chain gradient (add-color-stop 1 "#005500")) + + (setf (@ *canvas-ctx* fill-style) gradient) + (chain *canvas-ctx* (fill-rect x (- height bar-height) bar-width bar-height)) + + (incf x bar-width))))) + + (defun stop-spectrum-analyzer () + "Stop the spectrum analyzer" + (when *animation-id* + (cancel-animation-frame *animation-id*) + (setf *animation-id* nil))) + + ;; Initialize when audio starts playing + (chain document (add-event-listener "DOMContentLoaded" + (lambda () + (let ((audio-element (chain document (get-element-by-id "live-audio")))) + (when audio-element + (chain audio-element (add-event-listener "play" init-spectrum-analyzer)) + (chain audio-element (add-event-listener "pause" stop-spectrum-analyzer))))))))) + +(defun write-spectrum-analyzer-js () + "Write the generated JavaScript to a file" + (with-open-file (stream (asdf:system-relative-pathname :asteroid "static/js/spectrum-analyzer.js") + :direction :output + :if-exists :supersede + :if-does-not-exist :create) + (write-string (generate-spectrum-analyzer-js) stream))) diff --git a/template/front-page.ctml b/template/front-page.ctml index 9c73780..c10c3d9 100644 --- a/template/front-page.ctml +++ b/template/front-page.ctml @@ -11,6 +11,7 @@ +
@@ -21,6 +22,12 @@ Asteroid

The Station at the End of Time

+ + +
+ +
+