(in-package #:asteroid) ;;; Spectrum Analyzer - Parenscript Implementation ;;; Generates JavaScript for real-time audio visualization (define-api asteroid/spectrum-analyzer.js () () "Serve the spectrum analyzer JavaScript generated from Parenscript" (setf (content-type *response*) "application/javascript") (ps:ps (defvar *audio-context* nil) (defvar *analyser* nil) (defvar *canvas* nil) (defvar *canvas-ctx* nil) (defvar *animation-id* nil) (defvar *media-source* nil) (defvar *current-audio-element* nil) (defvar *current-theme* "green") (defvar *current-style* "bars") ;; Color themes for spectrum analyzer (defvar *themes* (ps:create "monotone" (ps:create "top" "#0047ab" "mid" "#002966" "bottom" "#000d1a") "green" (ps:create "top" "#00ff00" "mid" "#00aa00" "bottom" "#005500") "blue" (ps:create "top" "#00ffff" "mid" "#0088ff" "bottom" "#0044aa") "purple" (ps:create "top" "#ff00ff" "mid" "#aa00aa" "bottom" "#550055") "red" (ps:create "top" "#ff0000" "mid" "#aa0000" "bottom" "#550000") "amber" (ps:create "top" "#ffaa00" "mid" "#ff6600" "bottom" "#aa3300") "rainbow" (ps:create "top" "#ff00ff" "mid" "#00ffff" "bottom" "#00ff00"))) (defun reset-spectrum-analyzer () "Reset the spectrum analyzer to allow reconnection after audio element reload" (when *animation-id* (cancel-animation-frame *animation-id*) (setf *animation-id* nil)) ;; Close the old AudioContext if it exists (when *audio-context* (ps:try (ps:chain *audio-context* (close)) (:catch (e) (ps:chain console (log "Error closing AudioContext:" e))))) (setf *audio-context* nil) (setf *analyser* nil) (setf *media-source* nil) (setf *current-audio-element* nil) (ps:chain console (log "Spectrum analyzer reset for reconnection"))) (defun init-spectrum-analyzer () "Initialize the spectrum analyzer" (let ((audio-element nil) (canvas-element (ps:chain document (get-element-by-id "spectrum-canvas")))) ;; Try to find audio element in current frame first (setf audio-element (or (ps:chain document (get-element-by-id "live-audio")) (ps:chain document (get-element-by-id "persistent-audio")))) ;; If not found and we're in a frame, try to access from parent frameset (when (and (not audio-element) (ps:@ window parent) (not (eq (ps:@ window parent) window))) (ps:chain console (log "Trying to access audio from parent frame...")) (ps:try (progn ;; Try accessing via parent.frames (let ((player-frame (ps:getprop (ps:@ window parent) "player-frame"))) (when player-frame (setf audio-element (ps:chain player-frame document (get-element-by-id "persistent-audio"))) (ps:chain console (log "Found audio in player-frame:" audio-element))))) (:catch (e) (ps:chain console (log "Cross-frame access error:" e))))) (when (and audio-element canvas-element) ;; Store current audio element (setf *current-audio-element* audio-element) ;; Only create audio context and media source once (when (not *audio-context*) ;; Create Audio Context (setf *audio-context* (ps:new (or (ps:@ window |AudioContext|) (ps:@ window |webkitAudioContext|)))) ;; Create Analyser Node (setf *analyser* (ps:chain *audio-context* (create-analyser))) (setf (ps:@ *analyser* |fftSize|) 256) (setf (ps:@ *analyser* |smoothingTimeConstant|) 0.8) ;; Connect audio source to analyser (can only be done once per element) (setf *media-source* (ps:chain *audio-context* (create-media-element-source audio-element))) (ps:chain *media-source* (connect *analyser*)) (ps:chain *analyser* (connect (ps:@ *audio-context* destination))) (ps:chain console (log "Spectrum analyzer audio context created"))) ;; Setup canvas (setf *canvas* canvas-element) (setf *canvas-ctx* (ps:chain *canvas* (get-context "2d"))) ;; Start visualization if not already running (when (not *animation-id*) (draw-spectrum))))) (defun draw-spectrum () "Draw the spectrum analyzer visualization" (setf *animation-id* (request-animation-frame draw-spectrum)) (let* ((buffer-length (ps:@ *analyser* |frequencyBinCount|)) (data-array (ps:new (|Uint8Array| buffer-length))) (width (ps:@ *canvas* width)) (height (ps:@ *canvas* height)) (bar-width (/ width buffer-length)) (bar-height 0) (x 0) (is-muted (and *current-audio-element* (ps:@ *current-audio-element* muted)))) (ps:chain *analyser* (get-byte-frequency-data data-array)) ;; Clear canvas with fade effect (setf (ps:@ *canvas-ctx* |fillStyle|) "rgba(0, 0, 0, 0.2)") (ps:chain *canvas-ctx* (fill-rect 0 0 width height)) ;; Get current theme colors (let ((theme (ps:getprop *themes* *current-theme*))) (cond ;; Bar graph style ((= *current-style* "bars") (setf x 0) (dotimes (i buffer-length) (setf bar-height (/ (* (aref data-array i) height) 256)) ;; Create gradient for each bar using theme colors (let ((gradient (ps:chain *canvas-ctx* (create-linear-gradient 0 (- height bar-height) 0 height)))) (ps:chain gradient (add-color-stop 0 (ps:@ theme top))) (ps:chain gradient (add-color-stop 0.5 (ps:@ theme mid))) (ps:chain gradient (add-color-stop 1 (ps:@ theme bottom))) (setf (ps:@ *canvas-ctx* |fillStyle|) gradient) (ps:chain *canvas-ctx* (fill-rect x (- height bar-height) bar-width bar-height)) (incf x bar-width)))) ;; Wave/line style ((= *current-style* "wave") (setf x 0) (ps:chain *canvas-ctx* (begin-path)) (setf (ps:@ *canvas-ctx* |lineWidth|) 2) (setf (ps:@ *canvas-ctx* |strokeStyle|) (ps:@ theme top)) (dotimes (i buffer-length) (setf bar-height (/ (* (aref data-array i) height) 256)) (let ((y (- height bar-height))) (if (= i 0) (ps:chain *canvas-ctx* (move-to x y)) (ps:chain *canvas-ctx* (line-to x y))) (incf x bar-width))) (ps:chain *canvas-ctx* (stroke))) ;; Dots/particles style ((= *current-style* "dots") (setf x 0) (setf (ps:@ *canvas-ctx* |fillStyle|) (ps:@ theme top)) (dotimes (i buffer-length) (let* ((value (aref data-array i)) (normalized-height (/ (* value height) 256)) (y (- height normalized-height)) (dot-radius (ps:max 2 (/ normalized-height 20)))) (when (> value 0) (ps:chain *canvas-ctx* (begin-path)) (ps:chain *canvas-ctx* (arc x y dot-radius 0 6.283185307179586)) (ps:chain *canvas-ctx* (fill))) (incf x bar-width)))))) ;; Draw MUTED indicator if audio is muted (when is-muted (setf (ps:@ *canvas-ctx* |fillStyle|) "rgba(255, 0, 0, 0.8)") (setf (ps:@ *canvas-ctx* font) "bold 20px monospace") (setf (ps:@ *canvas-ctx* |textAlign|) "right") (setf (ps:@ *canvas-ctx* |textBaseline|) "top") (ps:chain *canvas-ctx* (fill-text "MUTED" (- width 10) 10))))) (defun stop-spectrum-analyzer () "Stop the spectrum analyzer" (when *animation-id* (cancel-animation-frame *animation-id*) (setf *animation-id* nil))) (defun set-spectrum-theme (theme-name) "Change the spectrum analyzer color theme and update dropdown colors" (when (ps:getprop *themes* theme-name) (setf *current-theme* theme-name) (ps:chain local-storage (set-item "spectrum-theme" theme-name)) (let ((theme (ps:getprop *themes* theme-name))) ;; Update canvas border color to match theme (when *canvas* (setf (ps:@ *canvas* style border-color) (ps:@ theme top))) ;; Update dropdown box colors (let ((theme-selector (ps:chain document (get-element-by-id "spectrum-theme-selector"))) (style-selector (ps:chain document (get-element-by-id "spectrum-style-selector")))) (when theme-selector (setf (ps:@ theme-selector style color) (ps:@ theme top)) (setf (ps:@ theme-selector style border-color) (ps:@ theme top))) (when style-selector (setf (ps:@ style-selector style color) (ps:@ theme top)) (setf (ps:@ style-selector style border-color) (ps:@ theme top))))) (ps:chain console (log (+ "Spectrum theme changed to: " theme-name))))) (defun get-available-themes () "Return array of available theme names" (ps:chain |Object| (keys *themes*))) (defun set-spectrum-style (style-name) "Change the spectrum analyzer visualization style" (when (or (= style-name "bars") (= style-name "wave") (= style-name "dots")) (setf *current-style* style-name) (ps:chain local-storage (set-item "spectrum-style" style-name)) (ps:chain console (log (+ "Spectrum style changed to: " style-name))))) (defun get-available-styles () "Return array of available visualization styles" (array "bars" "wave" "dots")) ;; Initialize when audio starts playing (ps:chain document (add-event-listener "DOMContentLoaded" (lambda () ;; Load saved theme and style preferences (let ((saved-theme (ps:chain local-storage (get-item "spectrum-theme"))) (saved-style (ps:chain local-storage (get-item "spectrum-style")))) (when (and saved-theme (ps:getprop *themes* saved-theme)) (setf *current-theme* saved-theme)) (when (and saved-style (or (= saved-style "bars") (= saved-style "wave") (= saved-style "dots"))) (setf *current-style* saved-style)) ;; Update UI selectors, canvas border, and dropdown colors (let ((theme-selector (ps:chain document (get-element-by-id "spectrum-theme-selector"))) (style-selector (ps:chain document (get-element-by-id "spectrum-style-selector"))) (canvas (ps:chain document (get-element-by-id "spectrum-canvas"))) (theme (ps:getprop *themes* *current-theme*))) (when theme-selector (setf (ps:@ theme-selector value) *current-theme*) (setf (ps:@ theme-selector style color) (ps:@ theme top)) (setf (ps:@ theme-selector style border-color) (ps:@ theme top))) (when style-selector (setf (ps:@ style-selector value) *current-style*) (setf (ps:@ style-selector style color) (ps:@ theme top)) (setf (ps:@ style-selector style border-color) (ps:@ theme top))) ;; Set initial canvas border color (when canvas (setf (ps:@ canvas style border-color) (ps:@ theme top))))) (let ((audio-element (or (ps:chain document (get-element-by-id "live-audio")) (ps:chain document (get-element-by-id "persistent-audio"))))) ;; If not found and we're in a frame, try parent (when (and (not audio-element) (ps:@ window parent) (not (eq (ps:@ window parent) window))) (ps:try (let ((player-frame (ps:getprop (ps:@ window parent) "player-frame"))) (when player-frame (setf audio-element (ps:chain player-frame document (get-element-by-id "persistent-audio"))))) (:catch (e) (ps:chain console (log "Event listener cross-frame error:" e))))) (when audio-element (ps:chain audio-element (add-event-listener "play" init-spectrum-analyzer)) (ps:chain audio-element (add-event-listener "pause" stop-spectrum-analyzer)))))))))