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
This commit is contained in:
Glenn Thompson 2025-12-06 08:21:30 +03:00 committed by Brian O'Reilly
parent 924f6498de
commit 6e8260172f
8 changed files with 210 additions and 26 deletions

View File

@ -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] * Setup asteroid.radio server at Hetzner [7/7]
- [X] Provision a VPS - [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 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 2) [X] icecast is also binding the external interface on b612, which it
should not be. HAproxy is there to mediate this flow. 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, 4) [X] The templates still advertise the default administrator password,
which is no bueno. 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. integrate it into HAproxy.
6) [ ] The administrative interface should be beefed up. 6) [ ] The administrative interface should be beefed up.
6.1) [ ] Deactivate users 6.1) [X] Deactivate users
6.2) [ ] Change user access permissions 6.2) [X] Change user access permissions
6.3) [ ] Listener statistics, breakdown by day/hour, new users, % changed &c 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. 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. 8) [ ] User profile pages should probably be fleshed out.
9) [ ] the stream management features aren't there for Admins or DJs. 9) [ ] the stream management features aren't there for Admins or DJs.
10) [ ] The "Scan Library" feature is not working in the main branch 10) [X] 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. 11) [X] 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. 12) [X] ensure each info field 'Listeners: ..' &c has only one instance per page.
* Server runtime configuration [0/1] * Server runtime configuration [0/1]
- [ ] parameterize all configuration for runtime loading [0/2] - [ ] parameterize all configuration for runtime loading [0/2]

View File

@ -10,7 +10,8 @@
<header-timeout>15</header-timeout> <header-timeout>15</header-timeout>
<source-timeout>10</source-timeout> <source-timeout>10</source-timeout>
<burst-on-connect>1</burst-on-connect> <burst-on-connect>1</burst-on-connect>
<burst-size>65535</burst-size> <!-- Reduced from 65535 to minimize buffer accumulation during pause -->
<burst-size>8192</burst-size>
</limits> </limits>
<authentication> <authentication>

View File

@ -12,6 +12,18 @@
(defvar *canvas* nil) (defvar *canvas* nil)
(defvar *canvas-ctx* nil) (defvar *canvas-ctx* nil)
(defvar *animation-id* 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 () (defun init-spectrum-analyzer ()
"Initialize the spectrum analyzer" "Initialize the spectrum analyzer"
@ -37,27 +49,35 @@
(:catch (e) (:catch (e)
(ps:chain console (log "Cross-frame access error:" e))))) (ps:chain console (log "Cross-frame access error:" e)))))
(when (and audio-element canvas-element (not *audio-context*)) (when (and audio-element canvas-element)
;; Create Audio Context ;; Store current audio element
(setf *audio-context* (ps:new (or (ps:@ window |AudioContext|) (setf *current-audio-element* audio-element)
(ps:@ window |webkitAudioContext|))))
;; Create Analyser Node ;; Only create audio context and media source once
(setf *analyser* (ps:chain *audio-context* (create-analyser))) (when (not *audio-context*)
(setf (ps:@ *analyser* |fftSize|) 256) ;; Create Audio Context
(setf (ps:@ *analyser* |smoothingTimeConstant|) 0.8) (setf *audio-context* (ps:new (or (ps:@ window |AudioContext|)
(ps:@ window |webkitAudioContext|))))
;; Connect audio source to analyser
(let ((source (ps:chain *audio-context* (create-media-element-source audio-element)))) ;; Create Analyser Node
(ps:chain source (connect *analyser*)) (setf *analyser* (ps:chain *audio-context* (create-analyser)))
(ps:chain *analyser* (connect (ps:@ *audio-context* destination)))) (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 ;; Setup canvas
(setf *canvas* canvas-element) (setf *canvas* canvas-element)
(setf *canvas-ctx* (ps:chain *canvas* (get-context "2d"))) (setf *canvas-ctx* (ps:chain *canvas* (get-context "2d")))
;; Start visualization ;; Start visualization if not already running
(draw-spectrum)))) (when (not *animation-id*)
(draw-spectrum)))))
(defun draw-spectrum () (defun draw-spectrum ()
"Draw the spectrum analyzer visualization" "Draw the spectrum analyzer visualization"

View File

@ -635,6 +635,12 @@ function displayQueueSearchResults(results) {
// Live stream info update // Live stream info update
async function updateLiveStreamInfo() { async function updateLiveStreamInfo() {
// Don't update if stream is paused
const audioElement = document.getElementById('live-stream-audio');
if (audioElement && audioElement.paused) {
return;
}
try { try {
const response = await fetch('/api/asteroid/partial/now-playing-inline'); const response = await fetch('/api/asteroid/partial/now-playing-inline');
const contentType = response.headers.get("content-type"); const contentType = response.headers.get("content-type");

View File

@ -55,6 +55,12 @@ function changeStreamQuality() {
// Update now playing info from Icecast // Update now playing info from Icecast
async function updateNowPlaying() { async function updateNowPlaying() {
// Don't update if stream is paused
const audioElement = document.getElementById('live-audio');
if (audioElement && audioElement.paused) {
return;
}
try { try {
const response = await fetch('/api/asteroid/partial/now-playing') const response = await fetch('/api/asteroid/partial/now-playing')
const contentType = response.headers.get("content-type") const contentType = response.headers.get("content-type")
@ -102,9 +108,42 @@ window.addEventListener('DOMContentLoaded', function() {
// Update playing information right after load // Update playing information right after load
updateNowPlaying(); updateNowPlaying();
// Auto-reconnect on stream errors // Auto-reconnect on stream errors and after long pauses
const audioElement = document.getElementById('live-audio'); const audioElement = document.getElementById('live-audio');
if (audioElement) { 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) { audioElement.addEventListener('error', function(e) {
console.log('Stream error, attempting reconnect in 3 seconds...'); console.log('Stream error, attempting reconnect in 3 seconds...');
setTimeout(function() { setTimeout(function() {

View File

@ -26,6 +26,39 @@ document.addEventListener('DOMContentLoaded', function() {
if (liveAudio) { if (liveAudio) {
// Reduce buffer to minimize delay // Reduce buffer to minimize delay
liveAudio.preload = 'none'; 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 // Restore user quality preference
const selector = document.getElementById('live-stream-quality'); const selector = document.getElementById('live-stream-quality');
@ -598,6 +631,12 @@ function changeLiveStreamQuality() {
// Live stream informatio update // Live stream informatio update
async function updateNowPlaying() { async function updateNowPlaying() {
// Don't update if stream is paused
const liveAudio = document.getElementById('live-stream-audio');
if (liveAudio && liveAudio.paused) {
return;
}
try { try {
const response = await fetch('/api/asteroid/partial/now-playing') const response = await fetch('/api/asteroid/partial/now-playing')
const contentType = response.headers.get("content-type") const contentType = response.headers.get("content-type")

View File

@ -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 // Add event listeners for debugging
audioElement.addEventListener('waiting', function() { audioElement.addEventListener('waiting', function() {
console.log('Audio buffering...'); console.log('Audio buffering...');
@ -60,6 +69,30 @@
console.error('Audio error:', e); 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 selector = document.getElementById('stream-quality');
const streamQuality = localStorage.getItem('stream-quality') || 'aac'; const streamQuality = localStorage.getItem('stream-quality') || 'aac';
if (selector && selector.value !== streamQuality) { if (selector && selector.value !== streamQuality) {
@ -112,6 +145,12 @@
// Update mini now playing display // Update mini now playing display
async function updateMiniNowPlaying() { async function updateMiniNowPlaying() {
// Don't update if stream is paused
const audioElement = document.getElementById('persistent-audio');
if (audioElement && audioElement.paused) {
return;
}
try { try {
const response = await fetch('/api/asteroid/partial/now-playing-inline'); const response = await fetch('/api/asteroid/partial/now-playing-inline');
if (response.ok) { if (response.ok) {

View File

@ -97,6 +97,12 @@
// Update now playing info for popout // Update now playing info for popout
async function updatePopoutNowPlaying() { async function updatePopoutNowPlaying() {
// Don't update if stream is paused
const audioElement = document.getElementById('live-audio');
if (audioElement && audioElement.paused) {
return;
}
try { try {
const response = await fetch('/api/asteroid/partial/now-playing-inline'); const response = await fetch('/api/asteroid/partial/now-playing-inline');
const html = await response.text(); const html = await response.text();
@ -125,8 +131,42 @@
// Initial update // Initial update
updatePopoutNowPlaying(); updatePopoutNowPlaying();
// Auto-reconnect on stream errors // Auto-reconnect on stream errors and after long pauses
const audioElement = document.getElementById('live-audio'); 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) { audioElement.addEventListener('error', function(e) {
console.log('Stream error, attempting reconnect in 3 seconds...'); console.log('Stream error, attempting reconnect in 3 seconds...');
setTimeout(function() { setTimeout(function() {