# Monkey patch MUST be first - before any other imports! import eventlet eventlet.monkey_patch() import os import json import subprocess import threading import queue import time from flask import Flask, send_from_directory, jsonify, request, session, Response, stream_with_context from flask_socketio import SocketIO, emit from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() import downloader def _load_config(): """Loads optional config.json from the project root. If missing or invalid, returns an empty dict. """ try: with open('config.json', 'r', encoding='utf-8') as f: data = json.load(f) return data if isinstance(data, dict) else {} except FileNotFoundError: return {} except Exception: return {} CONFIG = _load_config() DJ_PANEL_PASSWORD = (CONFIG.get('dj_panel_password') or '').strip() 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 = eventlet.queue.LightQueue(maxsize=200) _mp3_clients = set() # set[eventlet.queue.LightQueue] _mp3_lock = threading.Lock() _transcode_threads_started = False _transcoder_bytes_out = 0 _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 if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None: return 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', '-b:a', '192k', '-f', 'mp3', 'pipe:1', ] elif broadcast_state.get('server_mix'): cmd = _build_server_mix_cmd() else: # Local browser-broadcast mode: input from pipe cmd = [ 'ffmpeg', '-hide_banner', '-loglevel', 'error', '-i', 'pipe:0', '-vn', '-acodec', 'libmp3lame', '-b:a', '192k', '-f', 'mp3', 'pipe:1', ] needs_stdin = (not _remote_stream_url) and (not broadcast_state.get('server_mix')) try: if needs_stdin: _ffmpeg_proc = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0, ) else: _ffmpeg_proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0, ) except FileNotFoundError: _ffmpeg_proc = None print('⚠️ ffmpeg not found; /stream.mp3 fallback disabled') return 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: continue proc = _ffmpeg_proc if proc is None or proc.stdin is None: continue try: proc.stdin.write(chunk) except Exception: # If ffmpeg dies or pipe breaks, just stop writing. _transcoder_last_error = 'stdin write failed' break def _reader(): global _transcoder_bytes_out, _transcoder_last_error proc = _ffmpeg_proc if proc is None or proc.stdout is None: return while True: try: data = proc.stdout.read(4096) except Exception: _transcoder_last_error = 'stdout read failed' break if not data: break _transcoder_bytes_out += len(data) with _mp3_lock: clients = list(_mp3_clients) for q in clients: try: q.put_nowait(data) except Exception: # Drop if client queue is full or gone. pass if not _transcode_threads_started: eventlet.spawn_n(_writer) _transcode_threads_started = True eventlet.spawn_n(_reader) def _stop_transcoder(): global _ffmpeg_proc proc = _ffmpeg_proc _ffmpeg_proc = None if proc is None: return try: proc.terminate() except Exception: 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 or broadcast_state.get('server_mix'): return _last_audio_chunk_ts = time.time() try: _ffmpeg_in_q.put_nowait(data) except Exception: # Queue full; drop to keep latency bounded. pass MUSIC_FOLDER = "music" # Ensure music folder exists if not os.path.exists(MUSIC_FOLDER): os.makedirs(MUSIC_FOLDER) # Helper for shared routes def setup_shared_routes(app): @app.route('/library.json') def get_library(): library = [] if os.path.exists(MUSIC_FOLDER): for filename in sorted(os.listdir(MUSIC_FOLDER)): if filename.lower().endswith(('.mp3', '.m4a', '.wav', '.flac', '.ogg')): library.append({ "title": os.path.splitext(filename)[0], "file": f"music/{filename}" }) return jsonify(library) @app.route('/download', methods=['POST']) def download(): data = request.get_json(silent=True) or {} url = data.get('url') quality = data.get('quality', '320') if not url: return jsonify({"success": False, "error": "No URL provided"}), 400 result = downloader.download_mp3(url, quality) return jsonify(result) @app.route('/search_youtube', methods=['GET']) def search_youtube(): query = request.args.get('q', '') if not query: return jsonify({"success": False, "error": "No query provided"}), 400 # Get API key from environment variable api_key = os.environ.get('YOUTUBE_API_KEY', '') if not api_key: return jsonify({ "success": False, "error": "YouTube API key not configured. Set YOUTUBE_API_KEY environment variable." }), 500 try: import requests # Search YouTube using Data API v3 url = 'https://www.googleapis.com/youtube/v3/search' params = { 'part': 'snippet', 'q': query, 'type': 'video', 'videoCategoryId': '10', # Music category 'maxResults': 20, 'key': api_key } response = requests.get(url, params=params) data = response.json() if 'error' in data: return jsonify({ "success": False, "error": data['error'].get('message', 'YouTube API error') }), 400 # Format results results = [] for item in data.get('items', []): results.append({ 'videoId': item['id']['videoId'], 'title': item['snippet']['title'], 'channel': item['snippet']['channelTitle'], 'thumbnail': item['snippet']['thumbnails']['medium']['url'], 'url': f"https://www.youtube.com/watch?v={item['id']['videoId']}" }) return jsonify({"success": True, "results": results}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/') def serve_static(filename): response = send_from_directory('.', filename) if filename.endswith(('.css', '.js', '.html')): response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' return response @app.route('/') def index(): return send_from_directory('.', 'index.html') @app.route('/upload', methods=['POST']) def upload_file(): if 'file' not in request.files: return jsonify({"success": False, "error": "No file provided"}), 400 file = request.files['file'] if file.filename == '': return jsonify({"success": False, "error": "No file selected"}), 400 if not file.filename.endswith('.mp3'): return jsonify({"success": False, "error": "Only MP3 files are allowed"}), 400 # Sanitize filename (keep extension) import re name_without_ext = os.path.splitext(file.filename)[0] name_without_ext = re.sub(r'[^\w\s-]', '', name_without_ext) name_without_ext = re.sub(r'[-\s]+', '-', name_without_ext) filename = f"{name_without_ext}.mp3" filepath = os.path.join(MUSIC_FOLDER, filename) try: file.save(filepath) print(f"✅ Uploaded: {filename}") return jsonify({"success": True, "filename": filename}) except Exception as e: print(f"❌ Upload error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/stream.mp3') def stream_mp3(): # Streaming response from the ffmpeg transcoder output. # If ffmpeg isn't available, return 503. if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: return jsonify({"success": False, "error": "MP3 stream not available"}), 503 client_q = eventlet.queue.LightQueue(maxsize=200) with _mp3_lock: _mp3_clients.add(client_q) def gen(): try: while True: chunk = client_q.get() if chunk is None: break yield chunk finally: with _mp3_lock: _mp3_clients.discard(client_q) return Response( stream_with_context(gen()), mimetype='audio/mpeg', headers={ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Connection': 'keep-alive', }, ) @app.route('/stream_debug') def stream_debug(): proc = _ffmpeg_proc running = proc is not None and proc.poll() is None return jsonify({ 'broadcast_active': broadcast_state.get('active', False), 'broadcast_mimeType': broadcast_state.get('mimeType'), 'ffmpeg_running': running, 'ffmpeg_found': (proc is not None), 'mp3_clients': len(_mp3_clients), 'transcoder_bytes_out': _transcoder_bytes_out, 'transcoder_last_error': _transcoder_last_error, 'last_audio_chunk_ts': _last_audio_chunk_ts, }) # === DJ SERVER (Port 5000) === dj_app = Flask(__name__, static_folder='.', static_url_path='') dj_app.config['SECRET_KEY'] = 'dj_panel_secret' setup_shared_routes(dj_app) @dj_app.before_request def _protect_dj_panel(): """Optionally require a password for the DJ panel only (port 5000). This does not affect the listener server (port 5001). """ if not DJ_AUTH_ENABLED: return None # Allow login/logout endpoints if request.path in ('/login', '/logout'): return None # If already authenticated, allow if session.get('dj_authed') is True: return None # Redirect everything else to login return ( "" "Redirecting to /login...", 302, {'Location': '/login'} ) @dj_app.route('/login', methods=['GET', 'POST']) def dj_login(): if not DJ_AUTH_ENABLED: # If auth is disabled, just go to the panel. session['dj_authed'] = True return ( "" "Auth disabled. Redirecting...", 302, {'Location': '/'} ) error = None if request.method == 'POST': pw = (request.form.get('password') or '').strip() if pw == DJ_PANEL_PASSWORD: session['dj_authed'] = True return ( "" "Logged in. Redirecting...", 302, {'Location': '/'} ) error = 'Invalid password' # Minimal inline login page (no new assets) return f""" TechDJ - DJ Login

DJ Panel Locked

{f"
{error}
" if error else ""}
Set/disable this in config.json (dj_panel_password).
""" @dj_app.route('/logout') def dj_logout(): session.pop('dj_authed', None) return ( "" "Logged out. Redirecting...", 302, {'Location': '/login'} ) dj_socketio = SocketIO( dj_app, cors_allowed_origins="*", async_mode='eventlet', max_http_buffer_size=1e8, # 100MB buffer ping_timeout=10, ping_interval=5, logger=False, engineio_logger=False ) @dj_socketio.on('connect') def dj_connect(): if DJ_AUTH_ENABLED and session.get('dj_authed') is not True: print(f"⛔ DJ socket rejected (unauthorized): {request.sid}") return False 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, '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, '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: dj_socketio.emit('error', {'message': 'No URL provided for remote relay'}) return # Stop any existing broadcast/relay if broadcast_state['active']: dj_stop() _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, '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, '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'] 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() if isinstance(data, (bytes, bytearray)): _feed_transcoder(bytes(data)) # === LISTENER SERVER (Port 5001) === listener_app = Flask(__name__, static_folder='.', static_url_path='') listener_app.config['SECRET_KEY'] = 'listener_secret' setup_shared_routes(listener_app) listener_socketio = SocketIO( listener_app, cors_allowed_origins="*", async_mode='eventlet', max_http_buffer_size=1e8, # 100MB buffer ping_timeout=10, ping_interval=5, logger=False, engineio_logger=False ) @listener_socketio.on('connect') def listener_connect(): print(f"👂 Listener Socket Connected: {request.sid}") @listener_socketio.on('disconnect') def listener_disconnect(): if request.sid in listener_sids: listener_sids.discard(request.sid) count = len(listener_sids) print(f"❌ Listener left. Total: {count}") listener_socketio.emit('listener_count', {'count': count}, namespace='/') dj_socketio.emit('listener_count', {'count': count}, namespace='/') @listener_socketio.on('join_listener') def listener_join(): if request.sid not in listener_sids: listener_sids.add(request.sid) count = len(listener_sids) print(f"👂 New listener joined. Total: {count}") listener_socketio.emit('listener_count', {'count': count}, namespace='/') dj_socketio.emit('listener_count', {'count': count}, namespace='/') 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(): emit('listener_count', {'count': len(listener_sids)}) # DJ Panel Routes (No engine commands needed in local mode) @dj_socketio.on('get_mixer_status') def get_mixer_status(): 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): pass if __name__ == '__main__': print("=" * 50) print("🎧 TECHDJ PRO - DUAL PORT ARCHITECTURE") print("=" * 50) print("👉 DJ PANEL: http://localhost:5000") print("👉 LISTEN PAGE: http://localhost:5001") print("=" * 50) # Audio engine DISABLED print("✅ Local Radio server ready") # Run both servers using eventlet's spawn eventlet.spawn(dj_socketio.run, dj_app, host='0.0.0.0', port=5000, debug=False) listener_socketio.run(listener_app, host='0.0.0.0', port=5001, debug=False)