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).
pull/761/head
BoulderBadgeDad 3 weeks ago
parent ffbe669c67
commit ccfb3fb042

@ -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…
Cancel
Save