From 6e8260172f9a26e2bdf7e0ba5a7f20393e4a333e Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Sat, 6 Dec 2025 08:21:30 +0300 Subject: [PATCH] fix: Reduce Icecast burst size and prevent now-playing updates during pause - Reduced Icecast burst-size from 64KB to 8KB to minimize buffer accumulation - Fixed spectrum analyzer to only create MediaElementSource once - Added resetSpectrumAnalyzer() function to allow reconnection - Prevent now-playing info updates when stream is paused across all players: * Player page * Front page * Pop-out player * Frame player * Admin page - After pause >10s, reconnect stream and reinitialize spectrum analyzer - Preserves spectrum analyzer functionality after pause/unpause - Eliminates stuttering and buffer accumulation issues --- TODO.org | 16 +++++----- docker/icecast.xml | 3 +- spectrum-analyzer.lisp | 50 ++++++++++++++++++++++---------- static/js/admin.js | 6 ++++ static/js/front-page.js | 41 +++++++++++++++++++++++++- static/js/player.js | 39 +++++++++++++++++++++++++ template/audio-player-frame.ctml | 39 +++++++++++++++++++++++++ template/popout-player.ctml | 42 ++++++++++++++++++++++++++- 8 files changed, 210 insertions(+), 26 deletions(-) diff --git a/TODO.org b/TODO.org index 83a37c7..e795b02 100644 --- a/TODO.org +++ b/TODO.org @@ -1,4 +1,4 @@ -* Rundown to Launch. Still to do: +** [#C] Rundown to Launch. Still to do: * Setup asteroid.radio server at Hetzner [7/7] - [X] Provision a VPS @@ -25,23 +25,23 @@ 1) [X] Liquidsoap is exposing its management console via telnet on the exterior network interface of b612.asteroid.radio 2) [X] icecast is also binding the external interface on b612, which it should not be. HAproxy is there to mediate this flow. -3) [ ] We're still on the built in i-lambdalite database +3) [X] We're still on the built in i-lambdalite database 4) [X] The templates still advertise the default administrator password, which is no bueno. -5) [ ] We need to work out the TLS situation with letsencrypt, and +5) [X] We need to work out the TLS situation with letsencrypt, and integrate it into HAproxy. 6) [ ] The administrative interface should be beefed up. - 6.1) [ ] Deactivate users - 6.2) [ ] Change user access permissions + 6.1) [X] Deactivate users + 6.2) [X] Change user access permissions 6.3) [ ] Listener statistics, breakdown by day/hour, new users, % changed &c 7) [ ] When the player is paused, there are pretty serious stream sync issues in the form of stuttering for some time after the stream is unpaused. 8) [ ] User profile pages should probably be fleshed out. 9) [ ] the stream management features aren't there for Admins or DJs. -10) [ ] The "Scan Library" feature is not working in the main branch -11) [ ] The player widget should be styled so it fits the site theme on systems running 'light' thmes. -12) [ ] ensure each info field 'Listeners: ..' &c has only one instance per page. +10) [X] The "Scan Library" feature is not working in the main branch +11) [X] The player widget should be styled so it fits the site theme on systems running 'light' thmes. +12) [X] ensure each info field 'Listeners: ..' &c has only one instance per page. * Server runtime configuration [0/1] - [ ] parameterize all configuration for runtime loading [0/2] diff --git a/docker/icecast.xml b/docker/icecast.xml index 1ec1f94..5d2113f 100644 --- a/docker/icecast.xml +++ b/docker/icecast.xml @@ -10,7 +10,8 @@ 15 10 1 - 65535 + + 8192 diff --git a/spectrum-analyzer.lisp b/spectrum-analyzer.lisp index 66c3977..c284ff6 100644 --- a/spectrum-analyzer.lisp +++ b/spectrum-analyzer.lisp @@ -12,6 +12,18 @@ (defvar *canvas* nil) (defvar *canvas-ctx* nil) (defvar *animation-id* nil) + (defvar *media-source* nil) + (defvar *current-audio-element* nil) + + (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)) + (setf *audio-context* nil) + (setf *analyser* nil) + (setf *media-source* nil) + (ps:chain console (log "Spectrum analyzer reset for reconnection"))) (defun init-spectrum-analyzer () "Initialize the spectrum analyzer" @@ -37,27 +49,35 @@ (:catch (e) (ps:chain console (log "Cross-frame access error:" e))))) - (when (and audio-element canvas-element (not *audio-context*)) - ;; Create Audio Context - (setf *audio-context* (ps:new (or (ps:@ window |AudioContext|) - (ps:@ window |webkitAudioContext|)))) + (when (and audio-element canvas-element) + ;; Store current audio element + (setf *current-audio-element* audio-element) - ;; 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 - (let ((source (ps:chain *audio-context* (create-media-element-source audio-element)))) - (ps:chain source (connect *analyser*)) - (ps:chain *analyser* (connect (ps:@ *audio-context* destination)))) + ;; 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 - (draw-spectrum)))) + ;; Start visualization if not already running + (when (not *animation-id*) + (draw-spectrum))))) (defun draw-spectrum () "Draw the spectrum analyzer visualization" diff --git a/static/js/admin.js b/static/js/admin.js index a69f9ca..86fb4e2 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -635,6 +635,12 @@ function displayQueueSearchResults(results) { // Live stream info update async function updateLiveStreamInfo() { + // Don't update if stream is paused + const audioElement = document.getElementById('live-stream-audio'); + if (audioElement && audioElement.paused) { + return; + } + try { const response = await fetch('/api/asteroid/partial/now-playing-inline'); const contentType = response.headers.get("content-type"); diff --git a/static/js/front-page.js b/static/js/front-page.js index 6f41ec1..af2fb68 100644 --- a/static/js/front-page.js +++ b/static/js/front-page.js @@ -55,6 +55,12 @@ function changeStreamQuality() { // Update now playing info from Icecast async function updateNowPlaying() { + // Don't update if stream is paused + const audioElement = document.getElementById('live-audio'); + if (audioElement && audioElement.paused) { + return; + } + try { const response = await fetch('/api/asteroid/partial/now-playing') const contentType = response.headers.get("content-type") @@ -102,9 +108,42 @@ window.addEventListener('DOMContentLoaded', function() { // Update playing information right after load updateNowPlaying(); - // Auto-reconnect on stream errors + // Auto-reconnect on stream errors and after long pauses const audioElement = document.getElementById('live-audio'); if (audioElement) { + // Track pause timestamp to detect long pauses and reconnect + let pauseTimestamp = null; + const PAUSE_RECONNECT_THRESHOLD = 10000; // 10 seconds + + audioElement.addEventListener('pause', function() { + pauseTimestamp = Date.now(); + console.log('Stream paused at:', pauseTimestamp); + }); + + audioElement.addEventListener('play', function() { + if (pauseTimestamp && (Date.now() - pauseTimestamp) > PAUSE_RECONNECT_THRESHOLD) { + console.log('Reconnecting stream after long pause to clear stale buffers...'); + + // Reset spectrum analyzer before reconnect + if (typeof resetSpectrumAnalyzer === 'function') { + resetSpectrumAnalyzer(); + } + + audioElement.load(); // Force reconnect to clear accumulated buffer + + // Start playing the fresh stream and reinitialize spectrum analyzer + setTimeout(function() { + audioElement.play().catch(err => console.log('Reconnect play failed:', err)); + + if (typeof initSpectrumAnalyzer === 'function') { + initSpectrumAnalyzer(); + console.log('Spectrum analyzer reinitialized after reconnect'); + } + }, 500); + } + pauseTimestamp = null; + }); + audioElement.addEventListener('error', function(e) { console.log('Stream error, attempting reconnect in 3 seconds...'); setTimeout(function() { diff --git a/static/js/player.js b/static/js/player.js index 51783f2..cb794eb 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -26,6 +26,39 @@ document.addEventListener('DOMContentLoaded', function() { if (liveAudio) { // Reduce buffer to minimize delay liveAudio.preload = 'none'; + + // Track pause timestamp to detect long pauses and reconnect + let pauseTimestamp = null; + const PAUSE_RECONNECT_THRESHOLD = 10000; // 10 seconds + + liveAudio.addEventListener('pause', function() { + pauseTimestamp = Date.now(); + console.log('Live stream paused at:', pauseTimestamp); + }); + + liveAudio.addEventListener('play', function() { + if (pauseTimestamp && (Date.now() - pauseTimestamp) > PAUSE_RECONNECT_THRESHOLD) { + console.log('Reconnecting live stream after long pause to clear stale buffers...'); + + // Reset spectrum analyzer before reconnect + if (typeof resetSpectrumAnalyzer === 'function') { + resetSpectrumAnalyzer(); + } + + liveAudio.load(); // Force reconnect to clear accumulated buffer + + // Start playing the fresh stream and reinitialize spectrum analyzer + setTimeout(function() { + liveAudio.play().catch(err => console.log('Reconnect play failed:', err)); + + if (typeof initSpectrumAnalyzer === 'function') { + initSpectrumAnalyzer(); + console.log('Spectrum analyzer reinitialized after reconnect'); + } + }, 500); + } + pauseTimestamp = null; + }); } // Restore user quality preference const selector = document.getElementById('live-stream-quality'); @@ -598,6 +631,12 @@ function changeLiveStreamQuality() { // Live stream informatio update async function updateNowPlaying() { + // Don't update if stream is paused + const liveAudio = document.getElementById('live-stream-audio'); + if (liveAudio && liveAudio.paused) { + return; + } + try { const response = await fetch('/api/asteroid/partial/now-playing') const contentType = response.headers.get("content-type") diff --git a/template/audio-player-frame.ctml b/template/audio-player-frame.ctml index 88777b1..b9398c9 100644 --- a/template/audio-player-frame.ctml +++ b/template/audio-player-frame.ctml @@ -47,6 +47,15 @@ }); } + // Track pause timestamp to detect long pauses and reconnect + let pauseTimestamp = null; + const PAUSE_RECONNECT_THRESHOLD = 10000; // 10 seconds + + audioElement.addEventListener('pause', function() { + pauseTimestamp = Date.now(); + console.log('Frame player stream paused at:', pauseTimestamp); + }); + // Add event listeners for debugging audioElement.addEventListener('waiting', function() { console.log('Audio buffering...'); @@ -60,6 +69,30 @@ console.error('Audio error:', e); }); + audioElement.addEventListener('play', function() { + if (pauseTimestamp && (Date.now() - pauseTimestamp) > PAUSE_RECONNECT_THRESHOLD) { + console.log('Reconnecting frame player stream after long pause to clear stale buffers...'); + + // Reset spectrum analyzer before reconnect + if (typeof resetSpectrumAnalyzer === 'function') { + resetSpectrumAnalyzer(); + } + + audioElement.load(); // Force reconnect to clear accumulated buffer + + // Start playing the fresh stream and reinitialize spectrum analyzer + setTimeout(function() { + audioElement.play().catch(err => console.log('Reconnect play failed:', err)); + + if (typeof initSpectrumAnalyzer === 'function') { + initSpectrumAnalyzer(); + console.log('Spectrum analyzer reinitialized after reconnect'); + } + }, 500); + } + pauseTimestamp = null; + }); + const selector = document.getElementById('stream-quality'); const streamQuality = localStorage.getItem('stream-quality') || 'aac'; if (selector && selector.value !== streamQuality) { @@ -112,6 +145,12 @@ // Update mini now playing display async function updateMiniNowPlaying() { + // Don't update if stream is paused + const audioElement = document.getElementById('persistent-audio'); + if (audioElement && audioElement.paused) { + return; + } + try { const response = await fetch('/api/asteroid/partial/now-playing-inline'); if (response.ok) { diff --git a/template/popout-player.ctml b/template/popout-player.ctml index 569182f..7582eaa 100644 --- a/template/popout-player.ctml +++ b/template/popout-player.ctml @@ -97,6 +97,12 @@ // Update now playing info for popout async function updatePopoutNowPlaying() { + // Don't update if stream is paused + const audioElement = document.getElementById('live-audio'); + if (audioElement && audioElement.paused) { + return; + } + try { const response = await fetch('/api/asteroid/partial/now-playing-inline'); const html = await response.text(); @@ -125,8 +131,42 @@ // Initial update updatePopoutNowPlaying(); - // Auto-reconnect on stream errors + // Auto-reconnect on stream errors and after long pauses const audioElement = document.getElementById('live-audio'); + + // Track pause timestamp to detect long pauses and reconnect + let pauseTimestamp = null; + const PAUSE_RECONNECT_THRESHOLD = 10000; // 10 seconds + + audioElement.addEventListener('pause', function() { + pauseTimestamp = Date.now(); + console.log('Popout stream paused at:', pauseTimestamp); + }); + + audioElement.addEventListener('play', function() { + if (pauseTimestamp && (Date.now() - pauseTimestamp) > PAUSE_RECONNECT_THRESHOLD) { + console.log('Reconnecting popout stream after long pause to clear stale buffers...'); + + // Reset spectrum analyzer before reconnect + if (typeof resetSpectrumAnalyzer === 'function') { + resetSpectrumAnalyzer(); + } + + audioElement.load(); // Force reconnect to clear accumulated buffer + + // Start playing the fresh stream and reinitialize spectrum analyzer + setTimeout(function() { + audioElement.play().catch(err => console.log('Reconnect play failed:', err)); + + if (typeof initSpectrumAnalyzer === 'function') { + initSpectrumAnalyzer(); + console.log('Spectrum analyzer reinitialized after reconnect'); + } + }, 500); + } + pauseTimestamp = null; + }); + audioElement.addEventListener('error', function(e) { console.log('Stream error, attempting reconnect in 3 seconds...'); setTimeout(function() {