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:
parent
280b8f0690
commit
a1257af16f
|
|
@ -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.
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
:cl-json
|
:cl-json
|
||||||
:radiance
|
:radiance
|
||||||
:lass
|
:lass
|
||||||
|
:parenscript
|
||||||
:local-time
|
:local-time
|
||||||
:taglib
|
:taglib
|
||||||
:ironclad
|
:ironclad
|
||||||
|
|
@ -31,7 +32,6 @@
|
||||||
:r-simple-rate
|
:r-simple-rate
|
||||||
(:interface :auth)
|
(:interface :auth)
|
||||||
(:interface :database)
|
(:interface :database)
|
||||||
(:interface :relational-database)
|
|
||||||
(:interface :user))
|
(:interface :user))
|
||||||
:pathname "./"
|
:pathname "./"
|
||||||
:components ((:file "app-utils")
|
:components ((:file "app-utils")
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
(:file "conditions")
|
(:file "conditions")
|
||||||
(:file "database")
|
(:file "database")
|
||||||
(:file "template-utils")
|
(:file "template-utils")
|
||||||
|
(:file "spectrum-analyzer")
|
||||||
(:file "stream-media")
|
(:file "stream-media")
|
||||||
(:file "user-management")
|
(:file "user-management")
|
||||||
(:file "playlist-management")
|
(:file "playlist-management")
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,10 @@
|
||||||
(:use #:cl #:radiance #:lass #:r-clip #:asteroid.app-utils)
|
(:use #:cl #:radiance #:lass #:r-clip #:asteroid.app-utils)
|
||||||
(:domain "asteroid")
|
(:domain "asteroid")
|
||||||
(:export #:-main))
|
(: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))))))
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||||
<script src="/asteroid/static/js/front-page.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/recently-played.js"></script>
|
||||||
|
<script src="/asteroid/static/js/spectrum-analyzer.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
@ -21,6 +22,12 @@
|
||||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 60px; width: auto;">
|
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 60px; width: auto;">
|
||||||
</h1>
|
</h1>
|
||||||
<h3 class="page-subtitle">The Station at the End of Time</h3>
|
<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">
|
<nav class="nav">
|
||||||
<a href="/asteroid/">Home</a>
|
<a href="/asteroid/">Home</a>
|
||||||
<a href="/asteroid/player">Player</a>
|
<a href="/asteroid/player">Player</a>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue