Now Playing: real crossfade for library tracks (experimental)
Crossfade was a no-op toggle. Real crossfade needs two tracks audible at once, but /stream/audio only serves the ONE current track (single global stream_state). So: - web_server: extracted the range-serving body of /stream/audio into _serve_audio_file_with_range, and added /stream/library-audio?path= which serves an arbitrary LIBRARY file through it. Security: the path is resolved via _resolve_library_file_path (same validator /api/library/play uses) so it only serves files inside the configured transfer/download/media-library dirs — not arbitrary disk. - frontend: a second hidden <audio> (#audio-player-xfade) preloads the NEXT library track when the current one is within 6s of ending (crossfade on, not repeat-one), ramps the two volumes in opposite directions, then hands off to playQueueItem so all normal now-playing state is set. Honest limits (documented in code): library→library only (streamed tracks hard-cut as before); there's a brief silent reload at hand-off because playQueueItem re-points the single stream_state — the perceived crossfade has already happened by then. EXPERIMENTAL — needs Boulder's live audio verification; I can't test audio in-sandbox. 33 streaming tests still pass (stream_audio refactor is behavior-preserving).
This commit is contained in:
parent
ffbe669c67
commit
ccfb3fb042
3 changed files with 177 additions and 92 deletions
179
web_server.py
179
web_server.py
|
|
@ -11800,6 +11800,73 @@ def stream_status():
|
||||||
"error_message": str(e)
|
"error_message": str(e)
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
_AUDIO_MIME_TYPES = {
|
||||||
|
'.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.ogg': 'audio/ogg',
|
||||||
|
'.aac': 'audio/aac', '.m4a': 'audio/mp4', '.wav': 'audio/wav',
|
||||||
|
'.opus': 'audio/ogg', '.webm': 'audio/webm', '.wma': 'audio/x-ms-wma',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serve_audio_file_with_range(file_path):
|
||||||
|
"""Serve an on-disk audio file with HTTP range support (HTML5 seeking).
|
||||||
|
|
||||||
|
Shared by /stream/audio (current track) and /stream/library-audio (the
|
||||||
|
crossfade pre-loader, which plays the NEXT track on a second <audio>).
|
||||||
|
"""
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return jsonify({"error": "Audio file not found"}), 404
|
||||||
|
|
||||||
|
file_ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
mimetype = _AUDIO_MIME_TYPES.get(file_ext, 'audio/mpeg')
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
range_header = request.headers.get('Range', None)
|
||||||
|
if range_header:
|
||||||
|
byte_start = 0
|
||||||
|
byte_end = file_size - 1
|
||||||
|
try:
|
||||||
|
range_match = re.match(r'bytes=(\d*)-(\d*)', range_header)
|
||||||
|
if range_match:
|
||||||
|
start_str, end_str = range_match.groups()
|
||||||
|
if start_str:
|
||||||
|
byte_start = int(start_str)
|
||||||
|
if end_str:
|
||||||
|
byte_end = int(end_str)
|
||||||
|
else:
|
||||||
|
byte_end = file_size - 1
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
byte_start = max(0, byte_start)
|
||||||
|
byte_end = min(file_size - 1, byte_end)
|
||||||
|
content_length = byte_end - byte_start + 1
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
f.seek(byte_start)
|
||||||
|
remaining = content_length
|
||||||
|
while remaining:
|
||||||
|
chunk_size = min(8192, remaining)
|
||||||
|
chunk = f.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
remaining -= len(chunk)
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
response = Response(generate(), status=206, mimetype=mimetype, direct_passthrough=True)
|
||||||
|
response.headers.add('Content-Range', f'bytes {byte_start}-{byte_end}/{file_size}')
|
||||||
|
response.headers.add('Accept-Ranges', 'bytes')
|
||||||
|
response.headers.add('Content-Length', str(content_length))
|
||||||
|
response.headers.add('Cache-Control', 'no-cache')
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
response = send_file(file_path, as_attachment=False, mimetype=mimetype)
|
||||||
|
response.headers.add('Accept-Ranges', 'bytes')
|
||||||
|
response.headers.add('Content-Length', str(file_size))
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.route('/stream/audio')
|
@app.route('/stream/audio')
|
||||||
def stream_audio():
|
def stream_audio():
|
||||||
"""Serve the audio file from the Stream folder with range request support"""
|
"""Serve the audio file from the Stream folder with range request support"""
|
||||||
|
|
@ -11807,99 +11874,37 @@ def stream_audio():
|
||||||
with stream_lock:
|
with stream_lock:
|
||||||
if stream_state["status"] != "ready" or not stream_state["file_path"]:
|
if stream_state["status"] != "ready" or not stream_state["file_path"]:
|
||||||
return jsonify({"error": "No audio file ready for streaming"}), 404
|
return jsonify({"error": "No audio file ready for streaming"}), 404
|
||||||
|
|
||||||
file_path = stream_state["file_path"]
|
file_path = stream_state["file_path"]
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
|
||||||
return jsonify({"error": "Audio file not found"}), 404
|
|
||||||
|
|
||||||
logger.info(f"Serving audio file: {os.path.basename(file_path)}")
|
logger.info(f"Serving audio file: {os.path.basename(file_path)}")
|
||||||
|
return _serve_audio_file_with_range(file_path)
|
||||||
# Determine MIME type based on file extension
|
|
||||||
file_ext = os.path.splitext(file_path)[1].lower()
|
|
||||||
mime_types = {
|
|
||||||
'.mp3': 'audio/mpeg',
|
|
||||||
'.flac': 'audio/flac',
|
|
||||||
'.ogg': 'audio/ogg',
|
|
||||||
'.aac': 'audio/aac',
|
|
||||||
'.m4a': 'audio/mp4',
|
|
||||||
'.wav': 'audio/wav',
|
|
||||||
'.opus': 'audio/ogg',
|
|
||||||
'.webm': 'audio/webm',
|
|
||||||
'.wma': 'audio/x-ms-wma'
|
|
||||||
}
|
|
||||||
|
|
||||||
mimetype = mime_types.get(file_ext, 'audio/mpeg')
|
|
||||||
|
|
||||||
# Get file size
|
|
||||||
file_size = os.path.getsize(file_path)
|
|
||||||
|
|
||||||
# Handle range requests (important for HTML5 audio seeking)
|
|
||||||
range_header = request.headers.get('Range', None)
|
|
||||||
if range_header:
|
|
||||||
byte_start = 0
|
|
||||||
byte_end = file_size - 1
|
|
||||||
|
|
||||||
# Parse range header (format: "bytes=start-end")
|
|
||||||
try:
|
|
||||||
range_match = re.match(r'bytes=(\d*)-(\d*)', range_header)
|
|
||||||
if range_match:
|
|
||||||
start_str, end_str = range_match.groups()
|
|
||||||
if start_str:
|
|
||||||
byte_start = int(start_str)
|
|
||||||
if end_str:
|
|
||||||
byte_end = int(end_str)
|
|
||||||
else:
|
|
||||||
# If no end specified, serve from start to end of file
|
|
||||||
byte_end = file_size - 1
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
# Invalid range header, serve full file
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Ensure valid range
|
|
||||||
byte_start = max(0, byte_start)
|
|
||||||
byte_end = min(file_size - 1, byte_end)
|
|
||||||
content_length = byte_end - byte_start + 1
|
|
||||||
|
|
||||||
# Create response with partial content
|
|
||||||
def generate():
|
|
||||||
with open(file_path, 'rb') as f:
|
|
||||||
f.seek(byte_start)
|
|
||||||
remaining = content_length
|
|
||||||
while remaining:
|
|
||||||
chunk_size = min(8192, remaining) # 8KB chunks
|
|
||||||
chunk = f.read(chunk_size)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
remaining -= len(chunk)
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
response = Response(generate(),
|
|
||||||
status=206, # Partial Content
|
|
||||||
mimetype=mimetype,
|
|
||||||
direct_passthrough=True)
|
|
||||||
|
|
||||||
# Set range headers
|
|
||||||
response.headers.add('Content-Range', f'bytes {byte_start}-{byte_end}/{file_size}')
|
|
||||||
response.headers.add('Accept-Ranges', 'bytes')
|
|
||||||
response.headers.add('Content-Length', str(content_length))
|
|
||||||
response.headers.add('Cache-Control', 'no-cache')
|
|
||||||
|
|
||||||
return response
|
|
||||||
else:
|
|
||||||
# No range request, serve entire file
|
|
||||||
response = send_file(file_path, as_attachment=False, mimetype=mimetype)
|
|
||||||
response.headers.add('Accept-Ranges', 'bytes')
|
|
||||||
response.headers.add('Content-Length', str(file_size))
|
|
||||||
# Override the default static-cache max-age — streaming media
|
|
||||||
# bypasses caching (range requests, mid-track seeks).
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error serving audio file: {e}")
|
logger.error(f"Error serving audio file: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/stream/library-audio')
|
||||||
|
def stream_library_audio():
|
||||||
|
"""Serve an arbitrary LIBRARY audio file by path, with range support.
|
||||||
|
|
||||||
|
Powers crossfade: the player preloads the NEXT queued track on a second
|
||||||
|
<audio> element and fades between them. Security: the path is resolved
|
||||||
|
through ``_resolve_library_file_path`` — the same validator /api/library/play
|
||||||
|
uses — which only resolves files within the configured transfer/download/
|
||||||
|
media-library directories, so this can't be used to read arbitrary disk.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw_path = request.args.get('path', '')
|
||||||
|
if not raw_path:
|
||||||
|
return jsonify({"error": "path is required"}), 400
|
||||||
|
resolved = _resolve_library_file_path(raw_path)
|
||||||
|
if not resolved or not os.path.exists(resolved):
|
||||||
|
return jsonify({"error": _get_file_not_found_error(raw_path)}), 404
|
||||||
|
return _serve_audio_file_with_range(resolved)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error serving library audio file: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/api/stream/stop', methods=['POST'])
|
@app.route('/api/stream/stop', methods=['POST'])
|
||||||
def stream_stop():
|
def stream_stop():
|
||||||
"""Stop streaming and clean up"""
|
"""Stop streaming and clean up"""
|
||||||
|
|
|
||||||
|
|
@ -7007,6 +7007,8 @@
|
||||||
|
|
||||||
<!-- Hidden HTML5 Audio Player for Streaming -->
|
<!-- Hidden HTML5 Audio Player for Streaming -->
|
||||||
<audio id="audio-player" style="display: none;"></audio>
|
<audio id="audio-player" style="display: none;"></audio>
|
||||||
|
<!-- Secondary audio element used only for crossfade preloading of the next track -->
|
||||||
|
<audio id="audio-player-xfade" style="display: none;"></audio>
|
||||||
|
|
||||||
<!-- Expanded Now Playing Modal -->
|
<!-- Expanded Now Playing Modal -->
|
||||||
<div class="np-modal-overlay hidden" id="np-modal-overlay">
|
<div class="np-modal-overlay hidden" id="np-modal-overlay">
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ function initializeMediaPlayer() {
|
||||||
if (audioPlayer) {
|
if (audioPlayer) {
|
||||||
// Set up audio event listeners
|
// Set up audio event listeners
|
||||||
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
|
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
|
||||||
|
audioPlayer.addEventListener('timeupdate', npCrossfadeTick);
|
||||||
audioPlayer.addEventListener('ended', onAudioEnded);
|
audioPlayer.addEventListener('ended', onAudioEnded);
|
||||||
audioPlayer.addEventListener('error', onAudioError);
|
audioPlayer.addEventListener('error', onAudioError);
|
||||||
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
||||||
|
|
@ -1468,13 +1469,17 @@ function initExpandedPlayer() {
|
||||||
const sleepBtn = document.getElementById('np-sleep-btn');
|
const sleepBtn = document.getElementById('np-sleep-btn');
|
||||||
if (sleepBtn) sleepBtn.addEventListener('click', npCycleSleepTimer);
|
if (sleepBtn) sleepBtn.addEventListener('click', npCycleSleepTimer);
|
||||||
|
|
||||||
// Crossfade toggle (visual state now; dual-audio crossfade wired later)
|
// Crossfade toggle (real dual-audio crossfade for library tracks)
|
||||||
const xfadeBtn = document.getElementById('np-crossfade-btn');
|
const xfadeBtn = document.getElementById('np-crossfade-btn');
|
||||||
if (xfadeBtn) xfadeBtn.addEventListener('click', () => {
|
if (xfadeBtn) {
|
||||||
npCrossfadeOn = !npCrossfadeOn;
|
try { npCrossfadeOn = localStorage.getItem('soulsync-crossfade') === '1'; } catch (e) {}
|
||||||
xfadeBtn.classList.toggle('active', npCrossfadeOn);
|
xfadeBtn.classList.toggle('active', npCrossfadeOn);
|
||||||
try { localStorage.setItem('soulsync-crossfade', npCrossfadeOn ? '1' : '0'); } catch (e) {}
|
xfadeBtn.addEventListener('click', () => {
|
||||||
});
|
npCrossfadeOn = !npCrossfadeOn;
|
||||||
|
xfadeBtn.classList.toggle('active', npCrossfadeOn);
|
||||||
|
try { localStorage.setItem('soulsync-crossfade', npCrossfadeOn ? '1' : '0'); } catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
shuffleBtn.addEventListener('click', handleNpShuffle);
|
shuffleBtn.addEventListener('click', handleNpShuffle);
|
||||||
repeatBtn.addEventListener('click', handleNpRepeat);
|
repeatBtn.addEventListener('click', handleNpRepeat);
|
||||||
|
|
@ -1800,6 +1805,79 @@ function npPunchUpColor(r, g, b) {
|
||||||
return [clamp(nr), clamp(ng), clamp(nb)];
|
return [clamp(nr), clamp(ng), clamp(nb)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Crossfade engine (library tracks only) ──
|
||||||
|
// EXPERIMENTAL. Real crossfade needs two tracks playing at once; /stream/audio
|
||||||
|
// only serves the ONE current track (single global stream_state), so we use a
|
||||||
|
// dedicated /stream/library-audio endpoint + a second <audio> to play the NEXT
|
||||||
|
// library track and ramp volumes. Streamed (non-library) tracks can't crossfade
|
||||||
|
// and fall back to the normal hard cut.
|
||||||
|
const NP_CROSSFADE_SECONDS = 6;
|
||||||
|
let npXfadeAudio = null;
|
||||||
|
let npXfadeActive = false;
|
||||||
|
|
||||||
|
function npCrossfadeTick() {
|
||||||
|
if (!npCrossfadeOn || npXfadeActive || npRepeatMode === 'one') return;
|
||||||
|
if (!audioPlayer || !audioPlayer.duration || !isFinite(audioPlayer.duration)) return;
|
||||||
|
const remaining = audioPlayer.duration - audioPlayer.currentTime;
|
||||||
|
if (remaining > NP_CROSSFADE_SECONDS || remaining <= 0.2) return;
|
||||||
|
|
||||||
|
// Determine the next track (respects shuffle/repeat-all the same way
|
||||||
|
// playNextInQueue does, but we only crossfade plain sequential next).
|
||||||
|
const nextIdx = npQueueIndex + 1;
|
||||||
|
const next = npQueue[nextIdx];
|
||||||
|
if (!next || !next.is_library || !next.file_path) return; // only library→library
|
||||||
|
|
||||||
|
npStartCrossfade(nextIdx, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function npStartCrossfade(nextIdx, next) {
|
||||||
|
npXfadeActive = true;
|
||||||
|
const xa = document.getElementById('audio-player-xfade');
|
||||||
|
if (!xa) { npXfadeActive = false; return; }
|
||||||
|
npXfadeAudio = xa;
|
||||||
|
|
||||||
|
const targetVol = audioPlayer.volume; // fade the new track up to current level
|
||||||
|
xa.src = `/stream/library-audio?path=${encodeURIComponent(next.file_path)}&t=${Date.now()}`;
|
||||||
|
xa.volume = 0;
|
||||||
|
xa.play().then(() => {
|
||||||
|
const fadeMs = NP_CROSSFADE_SECONDS * 1000;
|
||||||
|
const step = 60; // ms between volume steps
|
||||||
|
const steps = Math.max(1, Math.floor(fadeMs / step));
|
||||||
|
let n = 0;
|
||||||
|
const startOutVol = audioPlayer.volume;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
n++;
|
||||||
|
const t = Math.min(1, n / steps);
|
||||||
|
audioPlayer.volume = Math.max(0, startOutVol * (1 - t));
|
||||||
|
xa.volume = Math.min(targetVol, targetVol * t);
|
||||||
|
if (t >= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
npFinishCrossfade(nextIdx, targetVol);
|
||||||
|
}
|
||||||
|
}, step);
|
||||||
|
}).catch(() => {
|
||||||
|
// Couldn't preload (e.g. endpoint/file issue) — abort gracefully, let
|
||||||
|
// the normal 'ended' hard-cut advance handle it.
|
||||||
|
npXfadeActive = false;
|
||||||
|
npXfadeAudio = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function npFinishCrossfade(nextIdx, restoreVol) {
|
||||||
|
// The crossfade audio has fully faded in; promote the queue index and let
|
||||||
|
// the normal play path take over so all the usual state (track info, art,
|
||||||
|
// visualizer, server stream_state) is set for the now-current track.
|
||||||
|
const xa = npXfadeAudio;
|
||||||
|
if (xa) { try { xa.pause(); } catch (_) {} xa.src = ''; xa.volume = 0; }
|
||||||
|
npXfadeAudio = null;
|
||||||
|
npXfadeActive = false;
|
||||||
|
if (audioPlayer) audioPlayer.volume = restoreVol;
|
||||||
|
// playQueueItem re-points stream_state + reloads audioPlayer for the next
|
||||||
|
// track; there's a brief silent reload, but the perceived crossfade already
|
||||||
|
// happened. Honest trade-off of the single-stream-state design.
|
||||||
|
playQueueItem(nextIdx);
|
||||||
|
}
|
||||||
|
|
||||||
function npResetAmbientGlow() {
|
function npResetAmbientGlow() {
|
||||||
const modal = document.querySelector('.np-modal');
|
const modal = document.querySelector('.np-modal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue