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
|
||||
: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")
|
||||
|
|
|
|||
|
|
@ -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))))))
|
||||
|
|
|
|||
|
|
@ -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/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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue