From c668a5f40f15b44701231df594ab5916e2abf834 Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Mon, 24 Nov 2025 07:35:14 +0300 Subject: [PATCH 1/5] Add phase metadata to stream-queue.m3u --- static/asteroid.css | 265 +------------------------------------------- stream-queue.m3u | 5 + 2 files changed, 10 insertions(+), 260 deletions(-) diff --git a/static/asteroid.css b/static/asteroid.css index e6e3a4f..ca48246 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -11,30 +11,6 @@ body{ box-sizing: border-box; } -body select{ - font-family: VT323, monospace; -} - - - -body header .page-title{ - display: flex; - align-items: center; - justify-content: center; - gap: 15px; - margin: 0; -} - -body header .page-subtitle{ - color: #4488FF; - display: flex; - justify-content: center; - font-style: italic; - margin: 0; - margin-top: .8rem; - margin-bottom: 4rem; -} - body .container{ max-width: 1200px; margin: 0 auto; @@ -325,29 +301,19 @@ body .player-section .live-stream{ overflow: auto; } -body .live-stream{ - margin-top: 2rem; - font-size: 1.1rem; - color: #4488FF; -} -body .live-stream .live-stream-label{ - font-size: 1.2rem; - color: #00FFFF; + +body .live-stream p, +body .live-stream label{ + font-size: 1.5rem; } body .live-stream code{ - font-size: 0.9rem; -} - -body .live-stream .frame-enable-message{ - color: #00FFFF; + font-size: 1rem; } body .live-stream .live-stream-quality{ display: flex; - align-items: center; - flex-wrap: wrap; } body .live-stream .live-stream-quality label{ @@ -355,20 +321,9 @@ body .live-stream .live-stream-quality label{ } body .live-stream .live-stream-quality select{ - background: transparent; - color: #00ff00; - border: 1px solid #00ff00; - letter-spacing: 0.08rem; - font-size: 0.95rem; - min-width: 220px; - width: fit-content; padding: 5px; } -body .live-stream .live-stream-quality select:hover{ - background: #2a3441; -} - body .track-browser{ margin: 15px 0; } @@ -434,15 +389,7 @@ body .audio-player{ text-align: center; } - - -body @-moz-documenturl-prefix() audio{ - background-color: red; -} - body audio::-webkit-media-controls-panel{ - border: 1px solid; - border-color: #1a2332; background-color: #1a2332; } @@ -1224,191 +1171,6 @@ body .stat-card .stat-label{ margin-top: 0.5rem; } -body.persistent-player-container{ - margin: 0; - padding: 10px;; - background: #1a2332; -} - -body.persistent-player-container .persistent-player{ - display: flex; - align-items: center; - gap: 15px; - max-width: 100%; -} - -body.persistent-player-container .player-label{ - color: #00ff00; - font-weight: bold; - white-space: nowrap; -} - -body.persistent-player-container .quality-selector{ - display: flex; - align-items: center; - gap: 5px; -} - - - -body.persistent-player-container .quality-selector label{ - color: #00ff00; - font-size: 0.9em; -} - - - -body.persistent-player-container .quality-selector select{ - background: transparent; - color: #00ff00; - letter-spacing: 0.08rem; - border: 1px solid #00ff00; - padding: 3px 8px; -} - -body.persistent-player-container .quality-selector select:hover{ - background: #2a3441; -} - -body.persistent-player-container audio{ - flex: 1; - min-width: 200px; - border: 0; -} - -body.persistent-player-container .now-playing-mini{ - color: #00ff00; - font-size: 0.9em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 1; - min-width: 300px; -} - -body.persistent-player-container .persistent-disable-btn{ - background: transparent; - color: #00ff00; - border: 1px solid #00ff00; - padding: 5px 10px; - cursor: pointer; - font-family: VT323, monospace; - font-size: 0.85em; - white-space: nowrap; -} - -body.persistent-player-container .persistent-disable-btn:hover{ - background: #2a3441; -} - -body.popout-body{ - margin: 0; - padding: 10px; - background: #0a0a0a; - overflow: hidden; -} - -body.popout-body .popout-player{ - max-width: 400px; - margin: 0 auto; -} - -body.popout-body .popout-header{ - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; - padding-bottom: 10px; - border-bottom: 1px solid #2a3441; -} - -body.popout-body .popout-title{ - font-size: 1.2em; - color: #4488FF; -} - -body.popout-body .popout-title .popout-subtitle{ - margin-top: 0; - margin-left: 2rem; - font-style: italic; - font-size: .8rem; -} - -body.popout-body .close-btn{ - background: transparent; - font-family: VT323, monospace; - color: #00ff00; - border: 1px solid #00ff00; - padding: 5px 10px; - cursor: pointer; - border-radius: 3px; - font-size: 0.9em; -} - -body.popout-body .close-btn:hover{ - background: #2a3441; -} - -body.popout-body .now-playing-mini{ - background: #1a2332; - padding: 10px; - border-radius: 5px; - margin-bottom: 10px; - border: 1px solid #2a3441; -} - -body.popout-body .track-info-mini{ - font-size: 0.9em; -} - -body.popout-body .track-title-mini{ - color: #00ff00; - font-weight: bold; - margin-bottom: 3px; -} - -body.popout-body .track-artist-mini{ - color: #4488ff; - font-size: 0.85em; -} - -body.popout-body .quality-selector{ - margin: 10px 0; - padding: 10px; - background: #1a2332; - border-radius: 5px; - border: 1px solid #2a3441; -} - -body.popout-body .quality-selector label{ - color: #00ff00; - margin-right: 10px; -} - -body.popout-body .quality-selector select{ - background: transparent; - color: #00ff00; - border: 1px solid #00ff00; - padding: 5px; - border-radius: 3px; -} - -body.popout-body .quality-selector select:hover{ - background: #2a3441; -} - -body.popout-body audio{ - width: 100%; - margin: 10px 0; -} - -body.popout-body .status-mini{ - text-align: center; - color: #888; - font-size: 0.85em; - margin-top: 10px; -} - @media (max-width: 576px){ body .playlist-controls{ display: block; @@ -1420,21 +1182,4 @@ body.popout-body .status-mini{ margin-left: 0; margin-right: 0; } -} - -@supports (-moz-appearance: none){ - audio{ - opacity: 1; - background-color: #1a2332; - } -} - -.live-stream-indicator{ - animation: asteroid-blink 1s steps(5, start) infinite; -} - -@keyframes asteroid-blink{ - to{ - visibility: hidden; - } } \ No newline at end of file diff --git a/stream-queue.m3u b/stream-queue.m3u index 8611479..9412f2c 100644 --- a/stream-queue.m3u +++ b/stream-queue.m3u @@ -1,4 +1,9 @@ #EXTM3U +#PLAYLIST:Low Orbit - Asteroid Radio Main Rotation +#PHASE:Low Orbit +#DURATION:Variable +#CURATOR:Asteroid Radio +#DESCRIPTION:Fast-paced electronic journey through IDM, ambient, and experimental sounds #EXTINF:-1,Underworld - Underworld - Confusion The Waitress /app/music/Underworld/1996 - Second Toughest In The Infants/03. Underworld - Confusion The Waitress.flac #EXTINF:-1,The Orb - Towers Of Dub From 280b8f0690c02840011842f3002bad23eb42d78d Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Tue, 2 Dec 2025 08:47:35 +0300 Subject: [PATCH 2/5] fix: Use 'normal' mode instead of 'sequential' for playlist playback The 'sequential' mode in Liquidsoap starts playback at a random position in the playlist, causing tracks to play out of order. Switching to 'normal' mode ensures the playlist starts from the beginning and plays sequentially through all tracks in order. --- docker/asteroid-radio-docker.liq | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/asteroid-radio-docker.liq b/docker/asteroid-radio-docker.liq index 60ecacb..3376541 100644 --- a/docker/asteroid-radio-docker.liq +++ b/docker/asteroid-radio-docker.liq @@ -23,7 +23,7 @@ settings.server.telnet.bind_addr.set("0.0.0.0") # This file is managed by Asteroid's stream control system # Falls back to directory scan if playlist file doesn't exist radio = playlist( - mode="sequential", # Play through playlist in order, then loop + mode="normal", # Normal mode: play sequentially without initial randomization reload=300, # Check for playlist updates every 5 minutes reload_mode="watch", # Watch file for changes (more efficient than polling) "/app/stream-queue.m3u" From a1257af16ff3185625b4b1e237430fb9a014efc5 Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Tue, 2 Dec 2025 09:31:50 +0300 Subject: [PATCH 3/5] 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. --- SPECTRUM-ANALYZER.org | 62 ++++++++++++++++++++++++++ asteroid.asd | 3 +- module.lisp | 7 +++ spectrum-analyzer.lisp | 96 ++++++++++++++++++++++++++++++++++++++++ template/front-page.ctml | 7 +++ 5 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 SPECTRUM-ANALYZER.org create mode 100644 spectrum-analyzer.lisp 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

+ + +
+ +
+