From 4a1844ae1b10fcccf9f57e24b610f75ee1469267 Mon Sep 17 00:00:00 2001 From: 3nd3r Date: Sun, 4 Jan 2026 08:14:52 -0600 Subject: [PATCH] Update TechDJ: fix viewer controls, boost listener volume, improve deck sync --- index.html | 24 ++- script.js | 353 ++++++++++++++++++++++++++++++------ server.py | 510 ++++++++++++++++++++++++++++++++++++++++++++++++++--- style.css | 60 +++++++ 4 files changed, 860 insertions(+), 87 deletions(-) diff --git a/index.html b/index.html index 505270a..8471505 100644 --- a/index.html +++ b/index.html @@ -139,17 +139,17 @@
-
-
-
-
@@ -278,17 +278,17 @@
-
-
-
-
@@ -364,6 +364,12 @@
Offline
+
+
Controller: Unknown
+ +
If another DJ has control, this will be denied.
+
+
👂 @@ -435,7 +441,7 @@
-
Connecting...
diff --git a/script.js b/script.js index 15a2ed8..b3e7d31 100644 --- a/script.js +++ b/script.js @@ -3,7 +3,21 @@ // ========================================== // Server-side audio mode (true = server processes audio, false = browser processes) -const SERVER_SIDE_AUDIO = false; +const SERVER_SIDE_AUDIO = true; + +function getDjIdentity() { + try { + const existing = localStorage.getItem('techdj_identity'); + if (existing) return existing; + const id = (crypto && typeof crypto.randomUUID === 'function') + ? crypto.randomUUID() + : String(Date.now()) + '-' + String(Math.random()).slice(2); + localStorage.setItem('techdj_identity', id); + return id; + } catch { + return 'anon'; + } +} let audioCtx; const decks = { @@ -12,6 +26,8 @@ const decks = { playing: false, pausedAt: 0, duration: 0, + serverPitch: 1.0, + lastAnchorWallTime: 0, localBuffer: null, localSource: null, gainNode: null, @@ -35,6 +51,8 @@ const decks = { playing: false, pausedAt: 0, duration: 0, + serverPitch: 1.0, + lastAnchorWallTime: 0, localBuffer: null, localSource: null, gainNode: null, @@ -499,41 +517,43 @@ function updateTimeDisplays() { if (!anyPlaying) return; ['A', 'B'].forEach(id => { - if (decks[id].playing && decks[id].localBuffer) { - const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0; - const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; - let current = decks[id].lastAnchorPosition + (realElapsed * playbackRate); + if (!decks[id].playing) return; - // Handle Looping wrapping for UI - if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) { - const loopLen = decks[id].loopEnd - decks[id].loopStart; - if (current >= decks[id].loopEnd && loopLen > 0) { - current = ((current - decks[id].loopStart) % loopLen) + decks[id].loopStart; - } - } else if (settings[`repeat${id}`]) { - // Full song repeat wrapping for UI - current = current % decks[id].duration; - } else { - current = Math.min(current, decks[id].duration); - } + // Server-side mode: viewers may not have AudioContext or localBuffer. + if (!decks[id].localBuffer && !SERVER_SIDE_AUDIO) return; - document.getElementById('time-current-' + id).textContent = formatTime(current); + const current = getCurrentPosition(id); + const timerEl = document.getElementById('time-current-' + id); + if (timerEl) timerEl.textContent = formatTime(current); - // Update playhead - const progress = (current / decks[id].duration) * 100; - const playhead = document.getElementById('playhead-' + id); - if (playhead) playhead.style.left = progress + '%'; + // Update playhead (guard against zero duration) + const dur = Number(decks[id].duration) || 0; + const playhead = document.getElementById('playhead-' + id); + if (playhead && dur > 0) { + const progress = (current / dur) * 100; + playhead.style.left = progress + '%'; } }); } function getCurrentPosition(id) { if (!decks[id].playing) return decks[id].pausedAt; - if (!audioCtx) return decks[id].pausedAt; - const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0; - const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; - let pos = decks[id].lastAnchorPosition + (realElapsed * playbackRate); + // If AudioContext isn't initialized (common in server-side mode / viewers), + // still advance position using wall-clock time + server pitch. + let pos; + if (!audioCtx) { + const now = (performance && typeof performance.now === 'function') + ? performance.now() / 1000 + : Date.now() / 1000; + const realElapsed = now - (decks[id].lastAnchorWallTime || 0); + const playbackRate = Number.isFinite(decks[id].serverPitch) ? decks[id].serverPitch : 1.0; + pos = (decks[id].lastAnchorPosition || 0) + (realElapsed * playbackRate); + } else { + const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0; + const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; + pos = decks[id].lastAnchorPosition + (realElapsed * playbackRate); + } // Handle wrapping for correct position return if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) { @@ -565,6 +585,42 @@ function playDeck(id) { decks[id].playing = true; const deckEl = document.getElementById('deck-' + id); if (deckEl) deckEl.classList.add('playing'); + + // Local monitoring (controller only): keep the DJ able to hear while the server streams. + if (djController?.youAreController && audioCtx && decks[id].localBuffer) { + try { + if (decks[id].localSource) { + try { + decks[id].localSource.stop(); + } catch (e) { } + decks[id].localSource.onended = null; + } + + const src = audioCtx.createBufferSource(); + src.buffer = decks[id].localBuffer; + + if (decks[id].loopActive) { + src.loop = true; + src.loopStart = decks[id].loopStart || 0; + src.loopEnd = decks[id].loopEnd || decks[id].duration; + } + + src.connect(decks[id].filters.low); + const speedSlider = document.querySelector(`#deck-${id} .speed-slider`); + const speed = speedSlider ? parseFloat(speedSlider.value) : 1.0; + src.playbackRate.value = speed; + + decks[id].localSource = src; + decks[id].lastAnchorTime = audioCtx.currentTime; + decks[id].lastAnchorPosition = decks[id].pausedAt || 0; + + src.start(0, decks[id].pausedAt || 0); + if (audioCtx.state === 'suspended') audioCtx.resume(); + } catch (e) { + console.warn(`[Deck ${id}] Local monitor play failed:`, e); + } + } + console.log(`[Deck ${id}] Play command sent to server`); return; } @@ -612,6 +668,16 @@ function pauseDeck(id) { socket.emit('audio_pause', { deck: id }); decks[id].playing = false; document.getElementById('deck-' + id).classList.remove('playing'); + + // Local monitoring (controller only) + if (djController?.youAreController && decks[id].localSource) { + try { + decks[id].localSource.stop(); + decks[id].localSource.onended = null; + } catch (e) { } + decks[id].localSource = null; + } + console.log(`[Deck ${id}] Pause command sent to server`); return; } @@ -1274,6 +1340,21 @@ async function loadFromServer(id, url, title) { socket.emit('audio_load_track', { deck: id, filename: filename }); console.log(`[Deck ${id}] 📡 Load command sent to server: ${filename}`); + // In server-side mode, if the user is controller and AudioContext isn't initialized, + // init it so they can have local monitoring + waveform. + if (SERVER_SIDE_AUDIO && djController.youAreController && !audioCtx) { + initSystem(); + } + + // If AudioContext still isn't initialized (e.g., viewer or failed init), skip local loading. + if (!audioCtx) { + decks[id].currentFile = url; + decks[id].localBuffer = null; + d.innerText = title.toUpperCase(); + d.classList.remove('blink'); + return; + } + // We DON'T return here anymore. We continue below to load for the UI. } @@ -1527,12 +1608,21 @@ window.addEventListener('DOMContentLoaded', () => { } if (!isListenerPort && !isListenerHostname) { + // In server-side audio mode, allow viewing the UI without initializing AudioContext. + if (SERVER_SIDE_AUDIO) { + const overlay = document.getElementById('start-overlay'); + if (overlay) overlay.style.display = 'none'; + } + // Set stream URL to the listener domain const streamUrl = window.location.hostname.startsWith('dj.') ? `${window.location.protocol}//music.${window.location.hostname.split('.').slice(1).join('.')}` : `${window.location.protocol}//${window.location.hostname}:5001`; const streamInput = document.getElementById('stream-url'); if (streamInput) streamInput.value = streamUrl; + + // Connect early so controller/viewer status is visible and UI can be synced. + initSocket(); } }); @@ -1543,6 +1633,7 @@ let streamDestination = null; let streamProcessor = null; let isBroadcasting = false; let autoStartStream = false; +let djController = { controllerActive: false, youAreController: false, controllerSid: null }; let listenerAudioContext = null; let listenerGainNode = null; let listenerAnalyserNode = null; @@ -1645,6 +1736,30 @@ function initSocket() { console.log('✅ Connected to streaming server'); console.log(` Socket ID: ${socket.id}`); console.log(` Transport: ${socket.io.engine.transport.name}`); + + // DJ page reconnect hydration: explicitly request the latest mixer state. + // (Server also pushes on connect, but this ensures we never miss it.) + const isDjUi = !!document.getElementById('deck-A') || !!document.getElementById('deck-B'); + if (isDjUi) { + socket.emit('dj_identity', { + id: getDjIdentity(), + auto_reclaim: (localStorage.getItem('techdj_wants_autoreclaim') ?? 'true') === 'true' + }); + socket.emit('get_mixer_status'); + socket.emit('get_listener_count'); + } + }); + + socket.on('controller_status', (data) => { + djController.controllerActive = !!data.controller_active; + djController.youAreController = !!data.you_are_controller; + djController.controllerSid = data.controller_sid || null; + + // If we ever become controller, remember that we want auto-reclaim on reconnect. + if (djController.youAreController) { + try { localStorage.setItem('techdj_wants_autoreclaim', 'true'); } catch { } + } + updateDjControllerUI(); }); socket.on('connect_error', (error) => { @@ -1662,6 +1777,30 @@ function initSocket() { if (el) el.textContent = data.count; }); + // DJ-side: keep broadcast UI in sync for multi-DJ viewing. + socket.on('stream_status', (data) => { + const broadcastBtn = document.getElementById('broadcast-btn'); + const broadcastText = document.getElementById('broadcast-text'); + const broadcastStatus = document.getElementById('broadcast-status'); + + // If we're not on the DJ page, these elements won't exist. + if (!broadcastBtn || !broadcastText || !broadcastStatus) return; + + isBroadcasting = !!data.active; + + if (isBroadcasting) { + broadcastBtn.classList.add('active'); + broadcastText.textContent = 'STOP BROADCAST'; + broadcastStatus.textContent = '🔴 LIVE'; + broadcastStatus.classList.add('live'); + } else { + broadcastBtn.classList.remove('active'); + broadcastText.textContent = 'START BROADCAST'; + broadcastStatus.textContent = 'Offline'; + broadcastStatus.classList.remove('live'); + } + }); + socket.on('broadcast_started', () => { console.log('🎙️ Broadcast started notification received'); // Update relay UI if it's a relay @@ -1691,6 +1830,12 @@ function initSocket() { socket.on('error', (data) => { console.error('📡 Server error:', data.message); + // Avoid spamming alerts for expected controller lock denials. + if (data?.message === 'Control is currently held by another DJ' || + data?.message === 'No active DJ controller. Click Take Control.') { + setDjControlHint(data.message, true); + return; + } alert(`SERVER ERROR: ${data.message}`); // Reset relay UI on error document.getElementById('start-relay-btn').style.display = 'inline-block'; @@ -1701,6 +1846,67 @@ function initSocket() { return socket; } +function setDjControlHint(text, isError = false) { + const hint = document.getElementById('dj-control-hint'); + if (!hint) return; + hint.textContent = text || ''; + hint.style.color = isError ? '#ff4444' : ''; +} + +function updateDjControllerUI() { + const statusEl = document.getElementById('dj-control-status'); + const btn = document.getElementById('take-control-btn'); + + if (statusEl) { + if (!djController.controllerActive) { + statusEl.textContent = 'Controller: None'; + } else if (djController.youAreController) { + statusEl.textContent = 'Controller: YOU'; + } else { + statusEl.textContent = 'Controller: Another DJ'; + } + } + + if (btn) { + btn.disabled = djController.controllerActive; + btn.textContent = djController.youAreController ? 'YOU HAVE CONTROL' : 'TAKE CONTROL'; + } + + // Update hint text based on latest state. + if (!djController.controllerActive) { + setDjControlHint('No controller. Click TAKE CONTROL.', false); + } else if (djController.youAreController) { + setDjControlHint('You have control.', false); + } else { + setDjControlHint('Another DJ has control. You are in view-only mode.', false); + } + + const isViewer = djController.controllerActive && !djController.youAreController; + document.body.classList.toggle('viewer-mode', isViewer); + + // Disable broadcast + relay controls for viewers + const broadcastBtn = document.getElementById('broadcast-btn'); + if (broadcastBtn) broadcastBtn.disabled = isViewer; + const relayStart = document.getElementById('start-relay-btn'); + const relayStop = document.getElementById('stop-relay-btn'); + const relayUrl = document.getElementById('remote-stream-url'); + if (relayStart) relayStart.disabled = isViewer; + if (relayStop) relayStop.disabled = isViewer; + if (relayUrl) relayUrl.disabled = isViewer; + + // Disable volume/EQ sliders and crossfader for viewers + const controlSliders = document.querySelectorAll('input[data-role="volume"], input[data-role="eq"], #crossfader, .speed-slider'); + controlSliders.forEach(slider => { + slider.disabled = isViewer; + }); +} + +function takeDjControl() { + if (!socket) initSocket(); + setDjControlHint('Requesting control...', false); + socket.emit('take_control'); +} + // Update DJ UI from server status function updateUIFromMixerStatus(status) { if (!status) return; @@ -1709,6 +1915,29 @@ function updateUIFromMixerStatus(status) { const deckStatus = id === 'A' ? status.deck_a : status.deck_b; if (!deckStatus) return; + // Reflect playing state in the UI (important for reconnects/viewers). + const deckEl = document.getElementById('deck-' + id); + if (deckEl) { + deckEl.classList.toggle('playing', !!deckStatus.playing); + } + + // Mirror volume + EQ sliders for viewers (and keep controllers visually in sync too). + const volSlider = document.querySelector(`input[data-role="volume"][data-deck="${id}"]`); + if (volSlider && typeof deckStatus.volume === 'number') { + const target = Math.max(0, Math.min(100, Math.round(deckStatus.volume * 100))); + if (volSlider.value !== String(target)) volSlider.value = String(target); + } + + const eq = deckStatus.eq || {}; + ['high', 'mid', 'low'].forEach((band) => { + const eqSlider = document.querySelector(`input[data-role="eq"][data-deck="${id}"][data-band="${band}"]`); + if (!eqSlider) return; + const val = Number(eq[band]); + if (!Number.isFinite(val)) return; + const clamped = Math.max(-20, Math.min(20, val)); + if (eqSlider.value !== String(clamped)) eqSlider.value = String(clamped); + }); + // Update position (only if not currently dragging the waveform) const timeSinceSeek = Date.now() - (decks[id].lastSeekTime || 0); if (timeSinceSeek < 1500) { @@ -1719,8 +1948,8 @@ function updateUIFromMixerStatus(status) { // Update playing state decks[id].playing = deckStatus.playing; - // Update loaded track if changed - if (deckStatus.filename && (!decks[id].currentFile || decks[id].currentFile !== deckStatus.filename)) { + // Update loaded track (always sync from server to ensure UI matches authoritative state) + if (deckStatus.filename) { console.log(`📡 Server synced: Deck ${id} is playing ${deckStatus.filename}`); decks[id].currentFile = deckStatus.filename; decks[id].duration = deckStatus.duration; @@ -1737,9 +1966,18 @@ function updateUIFromMixerStatus(status) { const speedSlider = document.querySelector(`#deck-${id} .speed-slider`); if (speedSlider) speedSlider.value = deckStatus.pitch; + // Store pitch so viewers (no AudioContext) can still animate time/playhead. + if (typeof deckStatus.pitch === 'number' && Number.isFinite(deckStatus.pitch)) { + decks[id].serverPitch = deckStatus.pitch; + } + // Update anchor for local interpolation decks[id].lastAnchorPosition = deckStatus.position; decks[id].lastAnchorTime = audioCtx ? audioCtx.currentTime : 0; + const now = (performance && typeof performance.now === 'function') + ? performance.now() / 1000 + : Date.now() / 1000; + decks[id].lastAnchorWallTime = now; if (!decks[id].playing) { decks[id].pausedAt = deckStatus.position; @@ -1754,9 +1992,10 @@ function updateUIFromMixerStatus(status) { if (timer) timer.textContent = formatTime(currentPos); }); - // Update crossfader if changed significantly - if (Math.abs(decks.crossfader - status.crossfader) > 1) { - // We'd update the UI slider here + // Update crossfader UI (do NOT call updateCrossfader here; viewers must not emit). + const xf = document.getElementById('crossfader'); + if (xf && typeof status.crossfader === 'number') { + xf.value = String(status.crossfader); } } @@ -1773,7 +2012,8 @@ function toggleStreamingPanel() { // Toggle broadcast function toggleBroadcast() { - if (!audioCtx) { + // In server-side audio mode, broadcast does not require the browser AudioContext. + if (!SERVER_SIDE_AUDIO && !audioCtx) { alert('Please initialize the system first (click INITIALIZE SYSTEM)'); return; } @@ -1793,18 +2033,12 @@ function startBroadcast() { try { console.log('🎙️ Starting broadcast...'); - if (!audioCtx) { - alert('Please initialize the system first!'); - return; - } - // Server-side audio mode if (SERVER_SIDE_AUDIO) { - isBroadcasting = true; - document.getElementById('broadcast-btn').classList.add('active'); - document.getElementById('broadcast-text').textContent = 'STOP BROADCAST'; - document.getElementById('broadcast-status').textContent = '🔴 LIVE'; - document.getElementById('broadcast-status').classList.add('live'); + if (!djController?.youAreController) { + setDjControlHint('You must TAKE CONTROL before starting the broadcast.', true); + return; + } if (!socket) initSocket(); socket.emit('start_broadcast'); @@ -1814,6 +2048,11 @@ function startBroadcast() { return; } + if (!audioCtx) { + alert('Please initialize the system first!'); + return; + } + // Browser-side audio mode (original code) // Check if any audio is playing const anyPlaying = decks.A.playing || decks.B.playing; @@ -2038,10 +2277,11 @@ function stopBroadcast() { console.log('🛑 Stopping broadcast...'); if (SERVER_SIDE_AUDIO) { - isBroadcasting = false; - if (socket) { - socket.emit('stop_broadcast'); + if (!djController?.youAreController) { + setDjControlHint('Only the controller can stop the broadcast.', true); + return; } + if (socket) socket.emit('stop_broadcast'); } else { if (streamProcessor) { streamProcessor.stop(); @@ -2080,11 +2320,13 @@ function stopBroadcast() { } } - // Update UI - document.getElementById('broadcast-btn').classList.remove('active'); - document.getElementById('broadcast-text').textContent = 'START BROADCAST'; - document.getElementById('broadcast-status').textContent = 'Offline'; - document.getElementById('broadcast-status').classList.remove('live'); + // Update UI only in browser-side mode. In server-side mode, rely on stream_status. + if (!SERVER_SIDE_AUDIO) { + document.getElementById('broadcast-btn').classList.remove('active'); + document.getElementById('broadcast-text').textContent = 'START BROADCAST'; + document.getElementById('broadcast-status').textContent = 'Offline'; + document.getElementById('broadcast-status').classList.remove('live'); + } console.log('✅ Broadcast stopped'); } @@ -2384,7 +2626,7 @@ async function enableListenerAudio() { try { if (!listenerGainNode) { listenerGainNode = listenerAudioContext.createGain(); - listenerGainNode.gain.value = 0.8; + listenerGainNode.gain.value = 1.0; listenerGainNode.connect(listenerAudioContext.destination); } @@ -2431,8 +2673,11 @@ async function enableListenerAudio() { window.listenerAudio.volume = 1.0; const volEl = document.getElementById('listener-volume'); - const volValue = volEl ? parseInt(volEl.value, 10) : 80; - setListenerVolume(Number.isFinite(volValue) ? volValue : 80); + const volValue = volEl ? parseInt(volEl.value, 10) : 100; + const savedVol = localStorage.getItem('listenerVolume'); + const finalVol = savedVol ? parseInt(savedVol, 10) : volValue; + if (volEl) volEl.value = finalVol; + setListenerVolume(Number.isFinite(finalVol) ? finalVol : 100); const hasBufferedData = () => { return window.listenerAudio.buffered && window.listenerAudio.buffered.length > 0; @@ -2497,6 +2742,8 @@ function setListenerVolume(value) { if (listenerGainNode) { listenerGainNode.gain.value = value / 100; } + // Save to localStorage + localStorage.setItem('listenerVolume', value); } // Load auto-start preference diff --git a/server.py b/server.py index bb1c009..77b9650 100644 --- a/server.py +++ b/server.py @@ -38,16 +38,87 @@ DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD) # Relay State broadcast_state = { 'active': False, + 'remote_relay': False, + 'server_mix': False, } listener_sids = set() dj_sids = set() +# DJ identity mapping (for auto-reclaim) +dj_identity_by_sid: dict[str, str] = {} +last_controller_identity: str | None = None +last_controller_released_at: float = 0.0 + +# === Multi-DJ controller lock (one active controller at a time) === +active_controller_sid = None + + +def _emit_controller_status(to_sid: str | None = None): + payload = { + 'controller_active': active_controller_sid is not None, + 'controller_sid': active_controller_sid, + } + if to_sid: + payload['you_are_controller'] = (to_sid == active_controller_sid) + dj_socketio.emit('controller_status', payload, to=to_sid, namespace='/') + else: + # Send individualized payload so each DJ can reliably know whether they are the controller. + for sid in list(dj_sids): + dj_socketio.emit( + 'controller_status', + { + **payload, + 'you_are_controller': (sid == active_controller_sid), + }, + to=sid, + namespace='/', + ) + + +def _deny_if_not_controller() -> bool: + """Returns True if caller is NOT the controller and was denied.""" + if active_controller_sid is None: + dj_socketio.emit('error', {'message': 'No active DJ controller. Click Take Control.'}, to=request.sid) + return True + if request.sid != active_controller_sid: + dj_socketio.emit('error', {'message': 'Control is currently held by another DJ'}, to=request.sid) + return True + return False + + +def _sid_identity(sid: str) -> str | None: + return dj_identity_by_sid.get(sid) + + +# === Server-side mixer state (authoritative UI sync) === +def _default_deck_state(): + return { + 'filename': None, + 'duration': 0.0, + 'position': 0.0, + 'playing': False, + 'pitch': 1.0, + 'volume': 0.8, + 'eq': {'low': 0.0, 'mid': 0.0, 'high': 0.0}, + # Internal anchors for time interpolation + '_started_at': None, + '_started_pos': 0.0, + } + + +mixer_state = { + 'deck_a': _default_deck_state(), + 'deck_b': _default_deck_state(), + 'crossfader': 50, +} + + # === Optional MP3 fallback stream (server-side transcoding) === # This allows listeners on browsers that don't support WebM/Opus via MediaSource # (notably some Safari / locked-down environments) to still hear the stream. _ffmpeg_proc = None -_ffmpeg_in_q = queue.Queue(maxsize=200) -_mp3_clients = set() # set[queue.Queue] +_ffmpeg_in_q = eventlet.queue.LightQueue(maxsize=200) +_mp3_clients = set() # set[eventlet.queue.LightQueue] _mp3_lock = threading.Lock() _transcode_threads_started = False _transcoder_bytes_out = 0 @@ -55,6 +126,9 @@ _transcoder_last_error = None _last_audio_chunk_ts = 0.0 _remote_stream_url = None # For relaying remote streams +_mix_restart_timer = None +_mix_restart_lock = threading.Lock() + def _start_transcoder_if_needed(): global _ffmpeg_proc, _transcode_threads_started @@ -62,12 +136,105 @@ def _start_transcoder_if_needed(): if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None: return - if _remote_stream_url: - # Remote relay mode: input from URL + def _safe_float(val, default=0.0): + try: + return float(val) + except Exception: + return float(default) + + def _clamp(val, lo, hi): + return max(lo, min(hi, val)) + + def _deck_runtime_position(d: dict) -> float: + if not d.get('playing'): + return _safe_float(d.get('position'), 0.0) + started_at = d.get('_started_at') + started_pos = _safe_float(d.get('_started_pos'), _safe_float(d.get('position'), 0.0)) + if started_at is None: + return _safe_float(d.get('position'), 0.0) + pitch = _safe_float(d.get('pitch'), 1.0) + return max(0.0, started_pos + (time.time() - _safe_float(started_at)) * pitch) + + def _ffmpeg_atempo_chain(speed: float) -> str: + # atempo supports ~[0.5, 2.0] per filter; clamp for now + s = _clamp(speed, 0.5, 2.0) + return f"atempo={s:.4f}" + + def _build_server_mix_cmd() -> list[str]: + # Always include an infinite silent input so ffmpeg never exits. + silence_src = 'anullsrc=channel_layout=stereo:sample_rate=44100' + + deck_a = mixer_state['deck_a'] + deck_b = mixer_state['deck_b'] + cf = int(mixer_state.get('crossfader', 50)) + + # Volumes: crossfader scaling * per-deck volume + cf_a = (100 - cf) / 100.0 + cf_b = cf / 100.0 + vol_a = _clamp(_safe_float(deck_a.get('volume'), 0.8) * cf_a, 0.0, 1.5) + vol_b = _clamp(_safe_float(deck_b.get('volume'), 0.8) * cf_b, 0.0, 1.5) + + # Source selection + def _input_args(deck: dict) -> list[str]: + fn = deck.get('filename') + if fn and deck.get('playing'): + pos = _deck_runtime_position(deck) + path = os.path.join(os.getcwd(), fn) + return ['-re', '-ss', f"{pos:.3f}", '-i', path] + return ['-re', '-f', 'lavfi', '-i', silence_src] + cmd = [ 'ffmpeg', '-hide_banner', '-loglevel', 'error', + *_input_args(deck_a), + *_input_args(deck_b), + '-re', '-f', 'lavfi', '-i', silence_src, + ] + + # Filters per deck + def _deck_filters(deck: dict, vol: float) -> str: + parts = [f"volume={vol:.4f}"] + + eq = deck.get('eq') or {} + low = _clamp(_safe_float(eq.get('low'), 0.0), -20.0, 20.0) + mid = _clamp(_safe_float(eq.get('mid'), 0.0), -20.0, 20.0) + high = _clamp(_safe_float(eq.get('high'), 0.0), -20.0, 20.0) + # Use octave width (o) so it's somewhat musical. + if abs(low) > 0.001: + parts.append(f"equalizer=f=320:width_type=o:width=1:g={low:.2f}") + if abs(mid) > 0.001: + parts.append(f"equalizer=f=1000:width_type=o:width=1:g={mid:.2f}") + if abs(high) > 0.001: + parts.append(f"equalizer=f=3200:width_type=o:width=1:g={high:.2f}") + return ','.join(parts) + + fc = ( + f"[0:a]{_deck_filters(deck_a, vol_a)}[a0];" + f"[1:a]{_deck_filters(deck_b, vol_b)}[a1];" + f"[2:a]volume=0[sil];" + f"[a0][a1][sil]amix=inputs=3:duration=longest:dropout_transition=0[m]" + ) + + cmd += [ + '-filter_complex', fc, + '-map', '[m]', + '-vn', + '-ac', '2', + '-ar', '44100', + '-acodec', 'libmp3lame', + '-b:a', '192k', + '-f', 'mp3', + 'pipe:1', + ] + return cmd + + if _remote_stream_url: + cmd = [ + 'ffmpeg', + '-hide_banner', + '-loglevel', 'error', + '-re', '-i', _remote_stream_url, '-vn', '-acodec', 'libmp3lame', @@ -75,8 +242,10 @@ def _start_transcoder_if_needed(): '-f', 'mp3', 'pipe:1', ] + elif broadcast_state.get('server_mix'): + cmd = _build_server_mix_cmd() else: - # Local broadcast mode: input from pipe + # Local browser-broadcast mode: input from pipe cmd = [ 'ffmpeg', '-hide_banner', @@ -89,10 +258,13 @@ def _start_transcoder_if_needed(): 'pipe:1', ] + needs_stdin = (not _remote_stream_url) and (not broadcast_state.get('server_mix')) + try: - if _remote_stream_url: + if needs_stdin: _ffmpeg_proc = subprocess.Popen( cmd, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0, @@ -100,7 +272,6 @@ def _start_transcoder_if_needed(): else: _ffmpeg_proc = subprocess.Popen( cmd, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0, @@ -110,14 +281,15 @@ def _start_transcoder_if_needed(): print('⚠️ ffmpeg not found; /stream.mp3 fallback disabled') return - print(f'🎛️ ffmpeg transcoder started for /stream.mp3 ({ "remote relay" if _remote_stream_url else "local broadcast" })') + mode = 'remote relay' if _remote_stream_url else ('server mix' if broadcast_state.get('server_mix') else 'local broadcast') + print(f'🎛️ ffmpeg transcoder started for /stream.mp3 ({mode})') def _writer(): global _transcoder_last_error while True: chunk = _ffmpeg_in_q.get() if chunk is None: - break + continue proc = _ffmpeg_proc if proc is None or proc.stdin is None: continue @@ -152,18 +324,14 @@ def _start_transcoder_if_needed(): pass if not _transcode_threads_started: - threading.Thread(target=_writer, daemon=True).start() - threading.Thread(target=_reader, daemon=True).start() + eventlet.spawn_n(_writer) _transcode_threads_started = True + eventlet.spawn_n(_reader) + def _stop_transcoder(): global _ffmpeg_proc - try: - _ffmpeg_in_q.put_nowait(None) - except Exception: - pass - proc = _ffmpeg_proc _ffmpeg_proc = None if proc is None: @@ -174,9 +342,30 @@ def _stop_transcoder(): pass +def _schedule_mix_restart(): + global _mix_restart_timer + if not broadcast_state.get('active') or not broadcast_state.get('server_mix'): + return + if _remote_stream_url: + return + with _mix_restart_lock: + if _mix_restart_timer is not None: + try: + _mix_restart_timer.cancel() + except Exception: + pass + + def _do(): + # Restart ffmpeg so changes apply. + _stop_transcoder() + _start_transcoder_if_needed() + + _mix_restart_timer = eventlet.spawn_after(0.20, _do) + + def _feed_transcoder(data: bytes): global _last_audio_chunk_ts - if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None or _remote_stream_url: + if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None or _remote_stream_url or broadcast_state.get('server_mix'): return _last_audio_chunk_ts = time.time() try: @@ -313,7 +502,7 @@ def setup_shared_routes(app): if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: return jsonify({"success": False, "error": "MP3 stream not available"}), 503 - client_q: queue.Queue = queue.Queue(maxsize=200) + client_q = eventlet.queue.LightQueue(maxsize=200) with _mp3_lock: _mp3_clients.add(client_q) @@ -473,39 +662,120 @@ def dj_connect(): print(f"🎧 DJ connected: {request.sid}") dj_sids.add(request.sid) + # Send controller status + current stream status to the new DJ + _emit_controller_status(to_sid=request.sid) + dj_socketio.emit('mixer_status', { + 'deck_a': _public_deck_state(mixer_state['deck_a']), + 'deck_b': _public_deck_state(mixer_state['deck_b']), + 'crossfader': mixer_state.get('crossfader', 50), + }, to=request.sid, namespace='/') + dj_socketio.emit('stream_status', { + 'active': broadcast_state.get('active', False), + 'remote_relay': bool(broadcast_state.get('remote_relay', False)), + 'server_mix': bool(broadcast_state.get('server_mix', False)), + }, to=request.sid, namespace='/') + @dj_socketio.on('disconnect') def dj_disconnect(): dj_sids.discard(request.sid) + ident = dj_identity_by_sid.get(request.sid) + dj_identity_by_sid.pop(request.sid, None) + global active_controller_sid + was_controller = (request.sid == active_controller_sid) + if was_controller: + global last_controller_identity, last_controller_released_at + last_controller_identity = ident + last_controller_released_at = time.time() + active_controller_sid = None + print("🧑‍✈️ DJ controller disconnected; control released") + _emit_controller_status() print("⚠️ DJ disconnected - broadcast will continue until manually stopped") + +@dj_socketio.on('dj_identity') +def dj_identity(data): + """Associate a stable client identity with this socket and optionally auto-reclaim control.""" + global active_controller_sid + ident = (data or {}).get('id') + auto_reclaim = bool((data or {}).get('auto_reclaim')) + if not ident or not isinstance(ident, str): + return + dj_identity_by_sid[request.sid] = ident + + # Auto-reclaim: only if no controller exists AND you were the last controller. + if auto_reclaim and active_controller_sid is None: + if last_controller_identity and ident == last_controller_identity: + active_controller_sid = request.sid + print(f"🧑‍✈️ Auto-reclaimed control for identity: {ident}") + _emit_controller_status() + + +@dj_socketio.on('take_control') +def dj_take_control(): + global active_controller_sid + if active_controller_sid is not None and active_controller_sid != request.sid: + dj_socketio.emit('error', {'message': 'Control is currently held by another DJ'}, to=request.sid) + _emit_controller_status(to_sid=request.sid) + return + active_controller_sid = request.sid + global last_controller_identity + last_controller_identity = _sid_identity(request.sid) + print(f"🧑‍✈️ DJ took control: {request.sid}") + _emit_controller_status() + def stop_broadcast_after_timeout(): """No longer used - broadcasts don't auto-stop""" pass @dj_socketio.on('start_broadcast') def dj_start(data=None): + if _deny_if_not_controller(): + return + global _remote_stream_url + _remote_stream_url = None broadcast_state['active'] = True + broadcast_state['remote_relay'] = False + broadcast_state['server_mix'] = True session['is_dj'] = True print("🎙️ Broadcast -> ACTIVE") _start_transcoder_if_needed() + + dj_socketio.emit('stream_status', { + 'active': True, + 'remote_relay': False, + 'server_mix': True, + }, namespace='/') listener_socketio.emit('broadcast_started', namespace='/') - listener_socketio.emit('stream_status', {'active': True}, namespace='/') + listener_socketio.emit('stream_status', { + 'active': True, + 'remote_relay': bool(broadcast_state.get('remote_relay', False)), + 'server_mix': bool(broadcast_state.get('server_mix', False)), + }, namespace='/') @dj_socketio.on('stop_broadcast') def dj_stop(): + if _deny_if_not_controller(): + return broadcast_state['active'] = False session['is_dj'] = False print("🛑 DJ stopped broadcasting") + broadcast_state['remote_relay'] = False + broadcast_state['server_mix'] = False + _stop_transcoder() + + dj_socketio.emit('stream_status', {'active': False, 'remote_relay': False, 'server_mix': False}, namespace='/') listener_socketio.emit('broadcast_stopped', namespace='/') - listener_socketio.emit('stream_status', {'active': False}, namespace='/') + listener_socketio.emit('stream_status', {'active': False, 'remote_relay': False, 'server_mix': False}, namespace='/') @dj_socketio.on('start_remote_relay') def dj_start_remote_relay(data): + if _deny_if_not_controller(): + return global _remote_stream_url url = data.get('url', '').strip() if not url: @@ -519,32 +789,40 @@ def dj_start_remote_relay(data): _remote_stream_url = url broadcast_state['active'] = True broadcast_state['remote_relay'] = True + broadcast_state['server_mix'] = False session['is_dj'] = True print(f"🔗 Starting remote relay from: {url}") _start_transcoder_if_needed() + + dj_socketio.emit('stream_status', {'active': True, 'remote_relay': True, 'server_mix': False}, namespace='/') listener_socketio.emit('broadcast_started', namespace='/') - listener_socketio.emit('stream_status', {'active': True, 'remote_relay': True}, namespace='/') + listener_socketio.emit('stream_status', {'active': True, 'remote_relay': True, 'server_mix': False}, namespace='/') @dj_socketio.on('stop_remote_relay') def dj_stop_remote_relay(): + if _deny_if_not_controller(): + return global _remote_stream_url _remote_stream_url = None broadcast_state['active'] = False broadcast_state['remote_relay'] = False + broadcast_state['server_mix'] = False session['is_dj'] = False print("🛑 Remote relay stopped") _stop_transcoder() + + dj_socketio.emit('stream_status', {'active': False, 'remote_relay': False, 'server_mix': False}, namespace='/') listener_socketio.emit('broadcast_stopped', namespace='/') - listener_socketio.emit('stream_status', {'active': False}, namespace='/') + listener_socketio.emit('stream_status', {'active': False, 'remote_relay': False, 'server_mix': False}, namespace='/') @dj_socketio.on('audio_chunk') def dj_audio(data): # MP3-only mode: do not relay raw chunks to listeners; feed transcoder only. - if broadcast_state['active']: + if broadcast_state['active'] and not broadcast_state.get('server_mix'): # Ensure MP3 fallback transcoder is running (if ffmpeg is installed) if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: _start_transcoder_if_needed() @@ -589,7 +867,11 @@ def listener_join(): listener_socketio.emit('listener_count', {'count': count}, namespace='/') dj_socketio.emit('listener_count', {'count': count}, namespace='/') - emit('stream_status', {'active': broadcast_state['active']}) + emit('stream_status', { + 'active': broadcast_state.get('active', False), + 'remote_relay': bool(broadcast_state.get('remote_relay', False)), + 'server_mix': bool(broadcast_state.get('server_mix', False)), + }) @listener_socketio.on('get_listener_count') def listener_get_count(): @@ -598,7 +880,185 @@ def listener_get_count(): # DJ Panel Routes (No engine commands needed in local mode) @dj_socketio.on('get_mixer_status') def get_mixer_status(): - pass + emit('mixer_status', { + 'deck_a': _public_deck_state(mixer_state['deck_a']), + 'deck_b': _public_deck_state(mixer_state['deck_b']), + 'crossfader': mixer_state.get('crossfader', 50), + }) + + +def _ffprobe_duration_seconds(path: str) -> float: + try: + out = subprocess.check_output( + ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', path], + stderr=subprocess.DEVNULL, + ) + return float(out.decode('utf-8').strip() or 0.0) + except Exception: + return 0.0 + + +def _deck_key(deck: str) -> str: + return 'deck_a' if deck == 'A' else 'deck_b' + + +def _deck_current_position(d: dict) -> float: + if not d.get('playing'): + return float(d.get('position') or 0.0) + started_at = d.get('_started_at') + started_pos = float(d.get('_started_pos') or 0.0) + if started_at is None: + return float(d.get('position') or 0.0) + pitch = float(d.get('pitch') or 1.0) + return max(0.0, started_pos + (time.time() - float(started_at)) * pitch) + + +def _public_deck_state(d: dict) -> dict: + out = {k: v for k, v in d.items() if not k.startswith('_')} + out['position'] = _deck_current_position(d) + return out + + +def _broadcast_mixer_status(): + dj_socketio.emit('mixer_status', { + 'deck_a': _public_deck_state(mixer_state['deck_a']), + 'deck_b': _public_deck_state(mixer_state['deck_b']), + 'crossfader': mixer_state.get('crossfader', 50), + }, namespace='/') + _schedule_mix_restart() + + +@dj_socketio.on('audio_load_track') +def audio_load_track(data): + if _deny_if_not_controller(): + return + deck = (data or {}).get('deck') + filename = (data or {}).get('filename') + if deck not in ('A', 'B') or not filename: + dj_socketio.emit('error', {'message': 'Invalid load request'}, to=request.sid) + return + path = os.path.join(MUSIC_FOLDER, filename) + if not os.path.exists(path): + dj_socketio.emit('error', {'message': f'Track not found: {filename}'}, to=request.sid) + return + + key = _deck_key(deck) + d = mixer_state[key] + d['filename'] = f"music/{filename}" + d['duration'] = _ffprobe_duration_seconds(path) + d['position'] = 0.0 + d['playing'] = False + d['_started_at'] = None + d['_started_pos'] = 0.0 + _broadcast_mixer_status() + + +@dj_socketio.on('audio_play') +def audio_play(data): + if _deny_if_not_controller(): + return + deck = (data or {}).get('deck') + if deck not in ('A', 'B'): + return + d = mixer_state[_deck_key(deck)] + if not d.get('filename'): + dj_socketio.emit('error', {'message': f'No track loaded on Deck {deck}'}, to=request.sid) + return + # Anchor for interpolation + d['position'] = _deck_current_position(d) + d['playing'] = True + d['_started_at'] = time.time() + d['_started_pos'] = float(d['position']) + _broadcast_mixer_status() + + +@dj_socketio.on('audio_pause') +def audio_pause(data): + if _deny_if_not_controller(): + return + deck = (data or {}).get('deck') + if deck not in ('A', 'B'): + return + d = mixer_state[_deck_key(deck)] + d['position'] = _deck_current_position(d) + d['playing'] = False + d['_started_at'] = None + d['_started_pos'] = float(d['position']) + _broadcast_mixer_status() + + +@dj_socketio.on('audio_seek') +def audio_seek(data): + if _deny_if_not_controller(): + return + deck = (data or {}).get('deck') + pos = float((data or {}).get('position') or 0.0) + if deck not in ('A', 'B'): + return + d = mixer_state[_deck_key(deck)] + d['position'] = max(0.0, pos) + if d.get('playing'): + d['_started_at'] = time.time() + d['_started_pos'] = float(d['position']) + _broadcast_mixer_status() + + +@dj_socketio.on('audio_set_volume') +def audio_set_volume(data): + if _deny_if_not_controller(): + return + deck = (data or {}).get('deck') + vol = float((data or {}).get('volume') or 0.0) + if deck not in ('A', 'B'): + return + d = mixer_state[_deck_key(deck)] + d['volume'] = max(0.0, min(1.0, vol)) + _broadcast_mixer_status() + + +@dj_socketio.on('audio_set_pitch') +def audio_set_pitch(data): + if _deny_if_not_controller(): + return + deck = (data or {}).get('deck') + pitch = float((data or {}).get('pitch') or 1.0) + if deck not in ('A', 'B'): + return + d = mixer_state[_deck_key(deck)] + d['pitch'] = max(0.5, min(2.0, pitch)) + # Re-anchor so position interpolation is consistent + d['position'] = _deck_current_position(d) + if d.get('playing'): + d['_started_at'] = time.time() + d['_started_pos'] = float(d['position']) + _broadcast_mixer_status() + + +@dj_socketio.on('audio_set_eq') +def audio_set_eq(data): + if _deny_if_not_controller(): + return + deck = (data or {}).get('deck') + band = (data or {}).get('band') + value = float((data or {}).get('value') or 0.0) + if deck not in ('A', 'B'): + return + if band not in ('low', 'mid', 'high'): + return + d = mixer_state[_deck_key(deck)] + eq = d.get('eq') or {'low': 0.0, 'mid': 0.0, 'high': 0.0} + eq[band] = max(-20.0, min(20.0, value)) + d['eq'] = eq + _broadcast_mixer_status() + + +@dj_socketio.on('audio_set_crossfader') +def audio_set_crossfader(data): + if _deny_if_not_controller(): + return + val = int((data or {}).get('value') or 50) + mixer_state['crossfader'] = max(0, min(100, val)) + _broadcast_mixer_status() @dj_socketio.on('audio_sync_queue') def audio_sync_queue(data): diff --git a/style.css b/style.css index 6c68af9..a39dd07 100644 --- a/style.css +++ b/style.css @@ -2177,6 +2177,66 @@ input[type=range] { text-shadow: 0 0 10px var(--secondary-magenta); } +/* DJ Controller Lock UI */ +.dj-control-section { + padding: 15px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(0, 243, 255, 0.25); + border-radius: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.dj-control-status { + font-family: 'Orbitron', sans-serif; + font-size: 0.9rem; + color: var(--primary-cyan); +} + +.dj-control-btn { + padding: 12px; + background: linear-gradient(145deg, #1a1a1a, #0a0a0a); + border: 2px solid var(--primary-cyan); + color: var(--primary-cyan); + font-family: 'Orbitron', sans-serif; + font-size: 0.9rem; + font-weight: bold; + cursor: pointer; + border-radius: 8px; + transition: all 0.3s; + box-shadow: 0 0 15px rgba(0, 243, 255, 0.15); +} + +.dj-control-btn:hover { + background: linear-gradient(145deg, #2a2a2a, #1a1a1a); + box-shadow: 0 0 25px rgba(0, 243, 255, 0.35); + transform: translateY(-1px); +} + +.dj-control-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +.dj-control-hint { + font-family: 'Rajdhani', sans-serif; + font-size: 0.8rem; + color: var(--text-dim); + opacity: 0.8; +} + +/* Viewer mode: dim + prevent accidental interactions */ +body.viewer-mode .deck, +body.viewer-mode .mixer-section, +body.viewer-mode .library-section, +body.viewer-mode #settings-panel { + opacity: 0.6; + pointer-events: none; +} + /* Listener Info */ .listener-info { background: rgba(0, 0, 0, 0.3);