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)
|
||||
}), 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')
|
||||
def stream_audio():
|
||||
"""Serve the audio file from the Stream folder with range request support"""
|
||||
|
|
@ -11807,99 +11874,37 @@ def stream_audio():
|
|||
with stream_lock:
|
||||
if stream_state["status"] != "ready" or not stream_state["file_path"]:
|
||||
return jsonify({"error": "No audio file ready for streaming"}), 404
|
||||
|
||||
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)}")
|
||||
|
||||
# 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
|
||||
|
||||
return _serve_audio_file_with_range(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving audio file: {e}")
|
||||
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'])
|
||||
def stream_stop():
|
||||
"""Stop streaming and clean up"""
|
||||
|
|
|
|||
|
|
@ -7007,6 +7007,8 @@
|
|||
|
||||
<!-- Hidden HTML5 Audio Player for Streaming -->
|
||||
<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 -->
|
||||
<div class="np-modal-overlay hidden" id="np-modal-overlay">
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ function initializeMediaPlayer() {
|
|||
if (audioPlayer) {
|
||||
// Set up audio event listeners
|
||||
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
|
||||
audioPlayer.addEventListener('timeupdate', npCrossfadeTick);
|
||||
audioPlayer.addEventListener('ended', onAudioEnded);
|
||||
audioPlayer.addEventListener('error', onAudioError);
|
||||
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
||||
|
|
@ -1468,13 +1469,17 @@ function initExpandedPlayer() {
|
|||
const sleepBtn = document.getElementById('np-sleep-btn');
|
||||
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');
|
||||
if (xfadeBtn) xfadeBtn.addEventListener('click', () => {
|
||||
npCrossfadeOn = !npCrossfadeOn;
|
||||
if (xfadeBtn) {
|
||||
try { npCrossfadeOn = localStorage.getItem('soulsync-crossfade') === '1'; } catch (e) {}
|
||||
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);
|
||||
repeatBtn.addEventListener('click', handleNpRepeat);
|
||||
|
|
@ -1800,6 +1805,79 @@ function npPunchUpColor(r, g, b) {
|
|||
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() {
|
||||
const modal = document.querySelector('.np-modal');
|
||||
if (modal) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue