feat: Add spectrum analyzer using Parenscript

Implements real-time audio visualization for the main page:
- Added Parenscript dependency to asteroid.asd
- Created spectrum-analyzer.lisp with Parenscript code
- Added canvas element to front-page.ctml above nav buttons
- Auto-generates JavaScript at compile time
- Green gradient bars matching Asteroid aesthetic
- Uses Web Audio API for FFT analysis

This is the first Parenscript component in Asteroid Radio,
demonstrating the approach for future JavaScript conversions.
This commit is contained in:
Glenn Thompson 2025-12-02 09:31:50 +03:00 committed by Brian O'Reilly
parent 280b8f0690
commit a1257af16f
5 changed files with 174 additions and 1 deletions

62
SPECTRUM-ANALYZER.org Normal file
View File

@ -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.

View File

@ -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")

View File

@ -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))))))

96
spectrum-analyzer.lisp Normal file
View File

@ -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)))

View File

@ -11,6 +11,7 @@
<script src="/asteroid/static/js/auth-ui.js"></script>
<script src="/asteroid/static/js/front-page.js"></script>
<script src="/asteroid/static/js/recently-played.js"></script>
<script src="/asteroid/static/js/spectrum-analyzer.js"></script>
</head>
<body>
<div class="container">
@ -21,6 +22,12 @@
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 60px; width: auto;">
</h1>
<h3 class="page-subtitle">The Station at the End of Time</h3>
<!-- Spectrum Analyzer Canvas -->
<div style="text-align: center; margin: 15px 0;">
<canvas id="spectrum-canvas" width="800" height="100" style="max-width: 100%; border: 1px solid #00ff00; background: #000; border-radius: 4px;"></canvas>
</div>
<nav class="nav">
<a href="/asteroid/">Home</a>
<a href="/asteroid/player">Player</a>