#!/usr/bin/env python3 import sys import os import json import random import math import time import queue import shutil import threading import requests import re import socketio from urllib.parse import urlparse import subprocess import atexit from pathlib import Path import soundfile as sf import numpy as np try: import librosa HAS_LIBROSA = True except ImportError: HAS_LIBROSA = False print("[INFO] librosa not found - BPM detection disabled. Run 'pip install librosa'") # --- BACKEND OVERRIDE --- # On Linux, GStreamer (default) often miscalculates MP3 duration for VBR files. # FFmpeg backend is much more reliable if available. os.environ["QT_MULTIMEDIA_BACKEND"] = "ffmpeg" # --- SUPPRESS FFMPEG AV_LOG NOISE --- # Qt6's internal FFmpeg multimedia backend emits AV_LOG_WARNING messages # (e.g. "Could not update timestamps for skipped/discarded samples") directly # to stderr via av_log. Set the threshold to AV_LOG_ERROR so only genuine # errors are printed. This affects only the in-process libavutil shared # library; subprocess ffmpeg invocations are unaffected. try: import ctypes, ctypes.util as _ctypes_util _avutil_name = _ctypes_util.find_library('avutil') if _avutil_name: _libavutil = ctypes.CDLL(_avutil_name) _AV_LOG_ERROR = 16 # suppress warnings (24) but keep errors (16) _libavutil.av_log_set_level(_AV_LOG_ERROR) except Exception: pass # --- DEPENDENCY CHECK --- try: import yt_dlp HAS_YTDLP = True except ImportError: HAS_YTDLP = False print("CRITICAL: yt-dlp not found. Run 'pip install yt-dlp'") from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QSlider, QLabel, QListWidget, QGroupBox, QListWidgetItem, QLineEdit, QGridLayout, QAbstractItemView, QDialog, QMessageBox, QFrame, QComboBox, QProgressBar, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, QCheckBox, QSpinBox, QFileDialog, QInputDialog) from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from PyQt6.QtCore import Qt, QUrl, QTimer, QPointF, QRectF, pyqtSignal, QProcess, QThread from PyQt6.QtGui import (QPainter, QColor, QPen, QBrush, QKeySequence, QIcon, QRadialGradient, QPainterPath, QShortcut, QLinearGradient, QPolygonF) # --- CONFIGURATION --- DEFAULT_BPM = 124 ANIMATION_FPS = 30 ANIMATION_INTERVAL = 1000 // ANIMATION_FPS # ms between animation frames LOOP_CHECK_INTERVAL = 20 # ms between loop boundary checks MS_PER_MINUTE = 60000 NUM_EQ_BANDS = 3 MAX_SLIDER_VALUE = 100 # --- EFFECTS DEFINITIONS --- # Each entry maps an effect ID to its ffmpeg -af filter fragment. # Segments are joined with ',' to build sequential filter chains. FX_DEFS = { 'echo': ['aecho=0.8:0.88:60:0.4'], 'reverb': ['aecho=0.8:0.7:100:0.35', 'aecho=0.5:0.5:300:0.2', 'aecho=0.3:0.3:600:0.1'], 'flange': ['flanger=delay=0:depth=2:regen=0:width=71:speed=0.5:phase=25:shape=sinusoidal'], 'phase': ['aphaser=in_gain=0.4:out_gain=0.74:delay=3:decay=0.4:speed=0.5:type=t'], 'lpf': ['lowpass=f=700:poles=2'], 'hpf': ['highpass=f=2800:poles=2'], 'crush': ['acompressor=threshold=0.04:ratio=20:attack=0:release=0.01', 'volume=5', 'lowpass=f=7000'], 'comb': ['aecho=0.9:0.9:11:0.9'], 'bass': ['bass=g=9'], 'treble': ['treble=g=8'], } # Per-effect display config: (label, accent_color) STYLESHEET = """ QMainWindow { background-color: #020408; } QWidget { color: #e0e0e0; } /* ── Group boxes ── */ QGroupBox { background-color: #06090f; border-radius: 6px; margin-top: 14px; font-family: "Courier New"; font-size: 11px; } QGroupBox::title { subcontrol-origin: margin; left: 12px; padding: 0 5px; font-weight: bold; font-size: 12px; letter-spacing: 2px; } QGroupBox#Deck_A { border: 2px solid #00e5ff; background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #030d12, stop:1 #020408); } QGroupBox#Deck_A::title { color: #00e5ff; } QGroupBox#Deck_B { border: 2px solid #ea00ff; background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #0d0312, stop:1 #020408); } QGroupBox#Deck_B::title { color: #ea00ff; } /* ── Generic buttons ── */ QPushButton { background-color: #0d1117; color: #b0b8c8; border: 1px solid #2a3040; padding: 6px 10px; font-weight: bold; font-family: "Courier New"; letter-spacing: 1px; border-radius: 3px; } QPushButton:hover { background-color: #151c28; border: 1px solid #00e5ff; color: #00e5ff; } QPushButton:pressed { background-color: #00e5ff; color: #000; } /* ── Neon toggle ── */ QPushButton#btn_neon { font-family: "Courier New"; font-size: 11px; letter-spacing: 2px; border: 1px solid #2a3040; color: #556677; } /* ── YouTube GO button ── */ QPushButton#btn_yt_go { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #cc0000, stop:1 #880000); border: 1px solid #ff2222; color: #fff; font-weight: bold; letter-spacing: 1px; } QPushButton#btn_yt_go:hover { background: #ff0000; border-color: #ff6666; color: #fff; } /* ── Remove button ── */ QPushButton#btn_remove { background-color: #1a0000; color: #ff3333; border: 1px solid #440000; padding: 0px; font-size: 10px; min-width: 22px; min-height: 22px; border-radius: 2px; } QPushButton#btn_remove:hover { background-color: #cc0000; color: #fff; border-color: #ff4444; } /* ── Loop beat buttons ── */ QPushButton#btn_loop { background-color: #0d0d0d; color: #556066; border: 1px solid #1a2233; font-size: 11px; font-family: "Courier New"; padding: 4px 2px; } QPushButton#btn_loop:hover { border-color: #ff9900; color: #ff9900; background: #111800; } QPushButton#btn_loop:checked { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #ffcc00, stop:1 #ff6600); color: #000; border: 1px solid #ffcc00; font-weight: bold; } /* ── Cue buttons ── */ QPushButton#btn_cue { background-color: #0a1500; color: #44cc00; border: 1px solid #1a4400; font-size: 11px; font-family: "Courier New"; font-weight: bold; padding: 5px 4px; } QPushButton#btn_cue:hover { border-color: #88ff00; color: #88ff00; background: #0f1f00; } QPushButton#btn_cue:pressed { background: #44cc00; color: #000; border-color: #88ff22; } /* ── Hot-cue buttons (1-4) ── */ QPushButton#btn_hotcue1 { background: #0a0015; color: #9933ff; border: 1px solid #330066; font-family: "Courier New"; font-size: 10px; font-weight: bold; } QPushButton#btn_hotcue1:hover { background: #220033; border-color: #cc44ff; color: #cc44ff; } QPushButton#btn_hotcue1:checked { background: #9933ff; color: #fff; border-color: #cc66ff; } QPushButton#btn_hotcue2 { background: #001515; color: #00ccbb; border: 1px solid #004444; font-family: "Courier New"; font-size: 10px; font-weight: bold; } QPushButton#btn_hotcue2:hover { background: #002222; border-color: #00ffee; color: #00ffee; } QPushButton#btn_hotcue2:checked { background: #00ccbb; color: #000; border-color: #00ffee; } QPushButton#btn_hotcue3 { background: #150a00; color: #ff7700; border: 1px solid #442200; font-family: "Courier New"; font-size: 10px; font-weight: bold; } QPushButton#btn_hotcue3:hover { background: #221100; border-color: #ffaa33; color: #ffaa33; } QPushButton#btn_hotcue3:checked { background: #ff7700; color: #000; border-color: #ffaa33; } QPushButton#btn_hotcue4 { background: #15000a; color: #ff3388; border: 1px solid #440022; font-family: "Courier New"; font-size: 10px; font-weight: bold; } QPushButton#btn_hotcue4:hover { background: #220011; border-color: #ff66aa; color: #ff66aa; } QPushButton#btn_hotcue4:checked { background: #ff3388; color: #fff; border-color: #ff66aa; } /* ── Loop exit ── */ QPushButton#btn_loop_exit { color: #ff3333; border: 1px solid #440000; font-size: 11px; font-family: "Courier New"; } QPushButton#btn_loop_exit:hover { background-color: #220000; border-color: #ff0000; } QPushButton#btn_loop_exit:pressed { background-color: #ff0000; color: #fff; } /* ── Playback mode button states ── */ QPushButton[mode="0"] { color: #00ff66; border-color: #005522; background: #030f06; } QPushButton[mode="1"] { color: #ffaa00; border-color: #554400; background: #0f0900; } QPushButton[mode="2"] { color: #ff3333; border-color: #440000; background: #0f0000; } /* ── Library mode buttons ── */ QPushButton#btn_lib_local { color: #00e5ff; border-color: #005566; font-family: "Courier New"; letter-spacing: 1px; } QPushButton#btn_lib_local:checked { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #00e5ff, stop:1 #007788); color: #000; font-weight: bold; border: 1px solid #00ffff; } QPushButton#btn_lib_server { color: #ea00ff; border-color: #550066; font-family: "Courier New"; letter-spacing: 1px; } QPushButton#btn_lib_server:checked { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #ea00ff, stop:1 #880099); color: #000; font-weight: bold; border: 1px solid #ff00ff; } /* ── Play / Pause / Stop ── */ QPushButton#btn_play { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #003300, stop:1 #001a00); color: #00ff66; border: 1px solid #007733; font-family: "Courier New"; font-size: 13px; font-weight: bold; letter-spacing: 2px; } QPushButton#btn_play:hover { background: #005500; border-color: #00ff66; } QPushButton#btn_play:pressed { background: #00ff66; color: #000; } QPushButton#btn_pause { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #1a1100, stop:1 #0d0900); color: #ffcc00; border: 1px solid #554400; font-family: "Courier New"; font-size: 13px; font-weight: bold; letter-spacing: 2px; } QPushButton#btn_pause:hover { background: #332200; border-color: #ffcc00; } QPushButton#btn_pause:pressed { background: #ffcc00; color: #000; } QPushButton#btn_stop { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #1a0000, stop:1 #0d0000); color: #ff3333; border: 1px solid #550000; font-family: "Courier New"; font-size: 13px; font-weight: bold; letter-spacing: 2px; } QPushButton#btn_stop:hover { background: #330000; border-color: #ff0000; } QPushButton#btn_stop:pressed { background: #ff0000; color: #fff; } /* ── Line edits ── */ QLineEdit { background-color: #080d14; color: #d0f0ff; border: 1px solid #1a3044; padding: 6px 8px; font-family: "Courier New"; border-radius: 2px; } QLineEdit:focus { border: 1px solid #00e5ff; color: #fff; background: #0a1520; } QLineEdit::placeholder { color: #334455; } /* ── List widgets ── */ QListWidget { background-color: #050a10; border: 1px solid #1a2233; color: #7a8fa8; font-family: "Courier New"; font-size: 12px; alternate-background-color: #070e16; } QListWidget::item { padding: 3px 6px; border-bottom: 1px solid #0d1520; } QListWidget::item:hover { background: #0d1a26; color: #aaccdd; } QListWidget::item:selected { background-color: #0d2030; color: #00e5ff; border: 1px solid #00e5ff; } QListWidget#queue_list { border: 1px solid #1a0d26; background: #050208; } QListWidget#queue_list::item:selected { background-color: #1a0d22; color: #ea00ff; border: 1px solid #660099; } /* ── Scrollbar (thin neon style) ── */ QScrollBar:vertical { background: #050a10; width: 8px; border: none; } QScrollBar::handle:vertical { background: #1a3044; border-radius: 4px; min-height: 20px; } QScrollBar::handle:vertical:hover { background: #00e5ff; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } /* ── Horizontal sliders (pitch etc.) ── */ QSlider::groove:horizontal { border: 1px solid #1a2233; height: 4px; background: #0d1520; border-radius: 2px; } QSlider::handle:horizontal { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #ddeeff, stop:1 #8899aa); border: 1px solid #aabbcc; width: 14px; height: 14px; margin: -6px 0; border-radius: 7px; } QSlider::handle:horizontal:hover { background: #fff; border-color: #00e5ff; } QSlider::sub-page:horizontal { background: #003344; border-radius: 2px; } /* ── Vertical EQ / fader sliders ── */ QSlider::groove:vertical { border: 1px solid #1a2233; width: 6px; background: #080d14; border-radius: 3px; } QSlider::handle:vertical { background: #99b0c0; border: 1px solid #ccd0d8; height: 14px; width: 14px; margin: 0 -5px; border-radius: 3px; } QSlider::sub-page:vertical { background: #1a3344; border-radius: 3px; } QSlider::add-page:vertical { background: #0d1520; border-radius: 3px; } QSlider[eq="vol"]::handle:vertical { background: #ccd8e8; border: 1px solid #fff; } QSlider[eq="vol"]::sub-page:vertical { background: qlineargradient(x1:0,y1:1,x2:0,y2:0, stop:0 #005588, stop:1 #00aadd); } QSlider[eq="high"]::handle:vertical { background: #00e5ff; border: 1px solid #00ffff; } QSlider[eq="high"]::sub-page:vertical { background: qlineargradient(x1:0,y1:1,x2:0,y2:0, stop:0 #003344, stop:1 #00aacc); } QSlider[eq="mid"]::handle:vertical { background: #00ff88; border: 1px solid #00ff66; } QSlider[eq="mid"]::sub-page:vertical { background: qlineargradient(x1:0,y1:1,x2:0,y2:0, stop:0 #003322, stop:1 #00aa55); } QSlider[eq="low"]::handle:vertical { background: #ff3344; border: 1px solid #ff0000; } QSlider[eq="low"]::sub-page:vertical { background: qlineargradient(x1:0,y1:1,x2:0,y2:0, stop:0 #330000, stop:1 #cc0022); } /* ── Crossfader ── */ QSlider#crossfader::groove:horizontal { border: 1px solid #334455; height: 18px; background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0.0 #001a22, stop:0.15 #00e5ff, stop:0.45 #001520, stop:0.5 #050a0f, stop:0.55 #1a0022, stop:0.85 #ea00ff, stop:1.0 #1a0022); border-radius: 9px; } QSlider#crossfader::handle:horizontal { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #e8eef8, stop:0.5 #9aaabb, stop:1 #556677); border: 2px solid #00e5ff; width: 34px; height: 40px; margin: -12px 0; border-radius: 5px; } QSlider#crossfader::handle:horizontal:hover { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #fff, stop:1 #aabbcc); border-color: #00ff88; } /* ── Combo box ── */ QComboBox { background: #080d14; color: #aaccdd; border: 1px solid #1a3044; padding: 5px 8px; font-family: "Courier New"; font-size: 11px; border-radius: 2px; } QComboBox:hover { border-color: #00e5ff; } QComboBox QAbstractItemView { background: #0d1520; color: #aaccdd; border: 1px solid #1a3044; selection-background-color: #0d2030; selection-color: #00e5ff; } QComboBox::drop-down { border: none; width: 18px; } QComboBox::down-arrow { border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 6px solid #00e5ff; } /* ── Labels ── */ QLabel { color: #7a8fa8; } /* ── Progress bar ── */ QProgressBar { border: 1px solid #1a3044; border-radius: 3px; text-align: center; background: #080d14; color: #00ff88; font-family: "Courier New"; font-size: 11px; height: 14px; } QProgressBar::chunk { background: qlineargradient(x1:0,y1:0,x2:1,y2:0, stop:0 #007840, stop:0.6 #00cc66, stop:1 #00ff88); border-radius: 2px; } /* ── Tab widget (settings dialog) ── */ QTabWidget::pane { border: 1px solid #1a2233; background: #06090f; } QTabBar::tab { background: #0d1117; color: #556677; padding: 8px 18px; margin: 2px; font-family: "Courier New"; letter-spacing: 1px; border: 1px solid #1a2233; border-radius: 2px; } QTabBar::tab:selected { background: #001a22; color: #00e5ff; border-color: #00e5ff; font-weight: bold; } QTabBar::tab:hover { background: #0d1520; color: #aabbcc; } /* ── Table (shortcuts) ── */ QTableWidget { background: #050a10; color: #7a8fa8; border: 1px solid #1a2233; font-family: "Courier New"; gridline-color: #0d1520; } QHeaderView::section { background: #0d1520; color: #00e5ff; border: 1px solid #1a2233; padding: 4px; font-family: "Courier New"; letter-spacing: 1px; } QTableWidget::item:selected { background: #0d2030; color: #00e5ff; } /* ── Spin box ── */ QSpinBox { background: #080d14; color: #aaccdd; border: 1px solid #1a3044; padding: 4px; font-family: "Courier New"; } QSpinBox:focus { border-color: #00e5ff; } /* ── Check box ── */ QCheckBox { color: #7a8fa8; font-family: "Courier New"; spacing: 6px; } QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #1a3044; background: #080d14; border-radius: 2px; } QCheckBox::indicator:checked { background: #00e5ff; border-color: #00ffff; } """ # --- AUDIO ISOLATION --- # We create a PulseAudio virtual null sink and set PULSE_SINK *before* Qt # initialises audio. That forces ALL audio from this process to the virtual # sink automatically – no fragile PID-based routing needed. # # A loopback copies the virtual-sink audio back to the real speakers so the # DJ still hears their mix. Streaming/recording capture from the virtual # sink's monitor, which contains ONLY this app's audio. _AUDIO_SINK_NAME = "techdj_stream" _AUDIO_MONITOR = f"{_AUDIO_SINK_NAME}.monitor" _audio_sink_module = None # pactl module ID for the null sink _audio_lb_module = None # pactl module ID for the loopback _audio_isolated = False # True when the virtual sink is active def _cleanup_stale_sinks(): """Remove leftover techdj_stream modules from a previous crash.""" try: result = subprocess.run( ['pactl', 'list', 'modules', 'short'], capture_output=True, text=True, timeout=5, ) for line in result.stdout.strip().split('\n'): if _AUDIO_SINK_NAME in line and ( 'module-null-sink' in line or 'module-loopback' in line): mod_id = line.split()[0] subprocess.run(['pactl', 'unload-module', mod_id], capture_output=True, timeout=5) print(f"[AUDIO] Cleaned up stale module {mod_id}") except Exception: pass def _find_physical_sink(): """Return the name of the best physical (non-virtual, non-Bluetooth) output sink, or None if none can be found. Prefers ALSA/PCI (built-in) sinks.""" try: r = subprocess.run(['pactl', 'list', 'sinks', 'short'], capture_output=True, text=True, timeout=5) for line in r.stdout.splitlines(): parts = line.split() if len(parts) < 2: continue name = parts[1] # Prefer built-in analogue / PCI entries if 'alsa' in name.lower() or 'pci' in name.lower() or 'analog' in name.lower(): return name # Second pass: accept any non-virtual, non-Bluetooth entry for line in r.stdout.splitlines(): parts = line.split() if len(parts) < 2: continue name = parts[1] if 'bluez' not in name.lower() and _AUDIO_SINK_NAME not in name: return name except Exception: pass return None def _setup_audio_isolation(): """Create the virtual sink and set PULSE_SINK. **MUST** be called before QApplication() so that Qt's audio output is automatically routed to the virtual sink. """ global _audio_sink_module, _audio_lb_module, _audio_isolated if not shutil.which('pactl'): print('[AUDIO] pactl not found – audio isolation disabled ' '(install pulseaudio-utils or pipewire-pulse)') return _cleanup_stale_sinks() # 1. Create null sink r = subprocess.run( ['pactl', 'load-module', 'module-null-sink', f'sink_name={_AUDIO_SINK_NAME}', f'sink_properties=device.description="TechDJ_Stream"'], capture_output=True, text=True, timeout=5, ) if r.returncode != 0: print(f'[AUDIO] Failed to create virtual sink: {r.stderr.strip()}') return _audio_sink_module = r.stdout.strip() # 2. Loopback → real speakers so the DJ hears the mix. # Explicitly target the best physical sink so Bluetooth-as-default doesn't # silently swallow the audio. phys_sink = _find_physical_sink() lb_cmd = ['pactl', 'load-module', 'module-loopback', f'source={_AUDIO_MONITOR}', 'latency_msec=30'] if phys_sink: lb_cmd.append(f'sink={phys_sink}') print(f'[AUDIO] Routing loopback to: {phys_sink}') r = subprocess.run(lb_cmd, capture_output=True, text=True, timeout=5) if r.returncode == 0: _audio_lb_module = r.stdout.strip() else: print(f'[AUDIO] Loopback failed (DJ may not hear audio): {r.stderr.strip()}') # 3. Verify the sink and its monitor actually exist verify = subprocess.run( ['pactl', 'list', 'sources', 'short'], capture_output=True, text=True, timeout=5, ) if _AUDIO_MONITOR not in verify.stdout: print(f'[AUDIO] ERROR: monitor source "{_AUDIO_MONITOR}" not found!') print(f'[AUDIO] Available sources:\n{verify.stdout.strip()}') # Tear down the sink we just created since the monitor isn't there if _audio_sink_module: subprocess.run(['pactl', 'unload-module', _audio_sink_module], capture_output=True, timeout=5) _audio_sink_module = None return # 4. Force this process's audio to the virtual sink os.environ['PULSE_SINK'] = _AUDIO_SINK_NAME _audio_isolated = True print(f'[AUDIO] ✓ Virtual sink "{_AUDIO_SINK_NAME}" active (module {_audio_sink_module})') print(f'[AUDIO] ✓ Loopback active (module {_audio_lb_module})') print(f'[AUDIO] ✓ PULSE_SINK={_AUDIO_SINK_NAME} — all app audio routed to virtual sink') print(f'[AUDIO] ✓ Capture source: {_AUDIO_MONITOR}') def _teardown_audio_isolation(): """Remove the virtual sink (called at process exit via atexit).""" global _audio_sink_module, _audio_lb_module, _audio_isolated for mod_id in (_audio_lb_module, _audio_sink_module): if mod_id: try: subprocess.run(['pactl', 'unload-module', mod_id], capture_output=True, timeout=5) except Exception: pass _audio_sink_module = None _audio_lb_module = None if _audio_isolated: _audio_isolated = False os.environ.pop('PULSE_SINK', None) print('[AUDIO] Virtual sink removed') def get_audio_capture_source(): """Return the PulseAudio source to capture from. If the virtual sink is active this returns its monitor; otherwise falls back to default.monitor (which captures ALL system audio). """ if _audio_isolated: # Double-check the monitor source still exists (PipeWire can be fussy) try: r = subprocess.run( ['pactl', 'list', 'sources', 'short'], capture_output=True, text=True, timeout=5, ) if _AUDIO_MONITOR in r.stdout: print(f'[AUDIO] Capturing from isolated source: {_AUDIO_MONITOR}') return _AUDIO_MONITOR else: print(f'[AUDIO] ERROR: {_AUDIO_MONITOR} disappeared! ' f'Available: {r.stdout.strip()}') except Exception as e: print(f'[AUDIO] pactl check failed: {e}') print('[AUDIO] WARNING: virtual sink not active – capturing ALL system audio!') print('[AUDIO] The listener WILL hear all your system audio (YouTube, Spotify, etc).') return 'default.monitor' def _route_qt_audio_to_virtual_sink(): """Move any PulseAudio/PipeWire sink-inputs from this process to the virtual techdj_stream sink. Needed because Qt6 may output audio via the PipeWire-native backend instead of the PulseAudio compatibility layer, which means PULSE_SINK has no effect and the monitor source used by the streaming ffmpeg would capture silence instead of the DJ's music. Calling pactl move-sink-input works regardless of whether the client originally used PulseAudio or PipeWire-native, as PipeWire exposes a PulseAudio-compatible socket. """ if not _audio_isolated: return pid_str = str(os.getpid()) try: result = subprocess.run( ['pactl', 'list', 'sink-inputs'], capture_output=True, text=True, timeout=3, ) current_id = None current_app_pid = None for line in result.stdout.splitlines(): stripped = line.strip() if stripped.startswith('Sink Input #'): # Flush previous block if current_id and current_app_pid == pid_str: subprocess.run( ['pactl', 'move-sink-input', current_id, _AUDIO_SINK_NAME], capture_output=True, timeout=3, ) current_id = stripped.split('#')[1].strip() current_app_pid = None elif 'application.process.id' in stripped and '"' in stripped: current_app_pid = stripped.split('"')[1] # Handle last block if current_id and current_app_pid == pid_str: subprocess.run( ['pactl', 'move-sink-input', current_id, _AUDIO_SINK_NAME], capture_output=True, timeout=3, ) except Exception: pass atexit.register(_teardown_audio_isolation) # --- WORKERS --- class DownloadThread(QThread): progress = pyqtSignal(int) finished = pyqtSignal(str, bool) def __init__(self, url, filepath): super().__init__() self.url = url self.filepath = filepath def run(self): try: response = requests.get(self.url, stream=True, timeout=30) if response.status_code != 200: self.finished.emit(self.filepath, False) return total_size = int(response.headers.get('content-length', 0)) os.makedirs(os.path.dirname(self.filepath), exist_ok=True) downloaded = 0 with open(self.filepath, 'wb') as f: for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) downloaded += len(chunk) if total_size > 0: self.progress.emit(int((downloaded / total_size) * 100)) self.finished.emit(self.filepath, True) except Exception: self.finished.emit(self.filepath, False) class LibraryScannerThread(QThread): files_found = pyqtSignal(list) def __init__(self, lib_path): super().__init__() self.lib_path = lib_path def run(self): files = [] if self.lib_path.exists(): for f in self.lib_path.rglob('*'): if f.suffix.lower() in ['.mp3', '.wav', '.ogg', '.m4a', '.flac']: files.append(f) files.sort(key=lambda x: x.name) self.files_found.emit(files) class ServerLibraryFetcher(QThread): finished = pyqtSignal(list, str, bool) def __init__(self, url): super().__init__() self.url = url def run(self): try: response = requests.get(self.url, timeout=5) if response.status_code == 200: self.finished.emit(response.json(), "", True) else: self.finished.emit([], f"Server error: {response.status_code}", False) except Exception as e: self.finished.emit([], str(e), False) class YTSearchWorker(QProcess): results_ready = pyqtSignal(list) error_occurred = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) self.output_buffer = b"" self.readyReadStandardOutput.connect(self.handle_output) self.finished.connect(self.handle_finished) def search(self, query): self.output_buffer = b"" print(f"[DEBUG] Searching for: {query}") cmd = sys.executable args = [ "-m", "yt_dlp", f"ytsearch5:{query}", "--dump-json", "--flat-playlist", "--quiet", "--no-warnings", "--compat-options", "no-youtube-unavailable-videos" ] self.start(cmd, args) def handle_output(self): self.output_buffer += self.readAllStandardOutput().data() def handle_finished(self): try: results = [] decoded = self.output_buffer.decode('utf-8', errors='ignore').strip() for line in decoded.split('\n'): if line: try: results.append(json.loads(line)) except json.JSONDecodeError: pass if results: self.results_ready.emit(results) else: self.error_occurred.emit("No results found or network error.") except Exception as e: self.error_occurred.emit(str(e)) class YTDownloadWorker(QProcess): download_finished = pyqtSignal(str) error_occurred = pyqtSignal(str) download_progress = pyqtSignal(float) # Progress percentage (0-100) def __init__(self, parent=None): super().__init__(parent) self.final_filename = "" self.error_log = "" self.readyReadStandardOutput.connect(self.handle_output) self.readyReadStandardError.connect(self.handle_error) self.finished.connect(self.handle_finished) def download(self, url, dest, audio_format="mp3"): # 1. Ensure Dest exists if not os.path.exists(dest): try: os.makedirs(dest) print(f"[DEBUG] Created directory: {dest}") except Exception as e: self.error_occurred.emit(f"Could not create folder: {e}") return # 2. Check FFmpeg (only needed for MP3 conversion) if audio_format == "mp3" and not shutil.which("ffmpeg"): self.error_occurred.emit("CRITICAL ERROR: FFmpeg is missing.\nRun 'sudo apt install ffmpeg' in terminal.") return self.final_filename = "" self.error_log = "" print(f"[DEBUG] Starting download: {url} -> {dest} (format: {audio_format})") cmd = sys.executable out_tmpl = os.path.join(dest, '%(title)s.%(ext)s') # Build args based on format choice args = ["-m", "yt_dlp"] if audio_format == "mp3": # MP3: Convert to MP3 (slower, universal) args.extend([ "-f", "bestaudio/best", "-x", "--audio-format", "mp3", "--audio-quality", "192K", ]) else: # Best Quality: Download original audio (faster, better quality) args.extend([ "-f", "bestaudio[ext=m4a]/bestaudio", # Prefer m4a, fallback to best ]) # Common args args.extend([ "-o", out_tmpl, "--no-playlist", "--newline", "--no-warnings", "--progress", "--print", "after_move:filepath", url ]) self.start(cmd, args) def handle_output(self): chunks = self.readAllStandardOutput().data().decode('utf-8', errors='ignore').splitlines() for chunk in chunks: line = chunk.strip() if line: # Progress parsing from stdout (newline mode) if '[download]' in line and '%' in line: try: parts = line.split() for part in parts: if '%' in part: p_str = part.replace('%', '') self.download_progress.emit(float(p_str)) break except: pass # yt-dlp prints the final filepath via --print after_move:filepath # Store it unconditionally — the file may not exist yet if FFmpeg # post-processing is still running, so DON'T gate on os.path.exists here. elif os.path.isabs(line) or (os.path.sep in line and any( line.endswith(ext) for ext in ('.mp3', '.m4a', '.opus', '.ogg', '.wav', '.flac'))): self.final_filename = line print(f"[DEBUG] Captured output path: {line}") def handle_error(self): err_data = self.readAllStandardError().data().decode('utf-8', errors='ignore').strip() if err_data: # Only log actual errors if "error" in err_data.lower(): print(f"[YT-DLP ERR] {err_data}") self.error_log += err_data + "\n" def handle_finished(self): if self.exitCode() == 0 and self.final_filename: # Use a non-blocking timer to poll for the file instead of blocking the GUI thread. self._poll_deadline = time.time() + 10.0 self._poll_timer = QTimer() self._poll_timer.setInterval(100) self._poll_timer.timeout.connect(self._check_file_ready) self._poll_timer.start() elif self.exitCode() == 0 and not self.final_filename: self.error_occurred.emit("Download finished but could not determine output filename.\nCheck the download folder manually.") else: self.error_occurred.emit(f"Download process failed.\n{self.error_log}") def _check_file_ready(self): """Non-blocking poll: check if the downloaded file has appeared on disk.""" if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0: self._poll_timer.stop() print(f"[DEBUG] Download complete: {self.final_filename} ({os.path.getsize(self.final_filename)} bytes)") self.download_finished.emit(self.final_filename) elif time.time() > self._poll_deadline: self._poll_timer.stop() self.error_occurred.emit(f"Download finished but file missing or empty:\n{self.final_filename}") class SettingsDialog(QDialog): def __init__(self, settings_data, parent=None): super().__init__(parent) self.setWindowTitle("Settings") self.resize(650, 650) self.setStyleSheet("background-color: #111; color: #fff;") # Store all settings self.shortcuts = settings_data.get("shortcuts", {}).copy() self.audio_settings = settings_data.get("audio", {}).copy() self.ui_settings = settings_data.get("ui", {}).copy() self.library_settings = settings_data.get("library", {}).copy() layout = QVBoxLayout(self) # Create tab widget self.tabs = QTabWidget() self.tabs.setStyleSheet(""" QTabWidget::pane { border: 1px solid #333; background: #0a0a0a; } QTabBar::tab { background: #222; color: #888; padding: 8px 16px; margin: 2px; } QTabBar::tab:selected { background: #00ffff; color: #000; font-weight: bold; } QTabBar::tab:hover { background: #333; color: #fff; } """) # Tab 1: Keyboard Shortcuts self.shortcuts_tab = self.create_shortcuts_tab() self.tabs.addTab(self.shortcuts_tab, "Keyboard") # Tab 2: Audio & Recording self.audio_tab = self.create_audio_tab() self.tabs.addTab(self.audio_tab, "Audio") # Tab 3: UI Preferences self.ui_tab = self.create_ui_tab() self.tabs.addTab(self.ui_tab, "UI") # Tab 4: Library self.library_tab = self.create_library_tab() self.tabs.addTab(self.library_tab, "Library") layout.addWidget(self.tabs) # Buttons btn_layout = QHBoxLayout() self.save_btn = QPushButton("Save All") self.save_btn.clicked.connect(self.accept) self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self.reject) btn_layout.addWidget(self.save_btn) btn_layout.addWidget(self.cancel_btn) layout.addLayout(btn_layout) def create_shortcuts_tab(self): widget = QWidget() layout = QVBoxLayout(widget) self.shortcuts_table = QTableWidget(len(self.shortcuts), 2) self.shortcuts_table.setHorizontalHeaderLabels(["Action", "Key"]) self.shortcuts_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.shortcuts_table.setStyleSheet("background-color: #000; border: 1px solid #333;") self.shortcuts_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.shortcuts_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.shortcuts_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) actions = sorted(self.shortcuts.keys()) for row, action in enumerate(actions): self.shortcuts_table.setItem(row, 0, QTableWidgetItem(action)) self.shortcuts_table.setItem(row, 1, QTableWidgetItem(self.shortcuts[action])) layout.addWidget(self.shortcuts_table) rebind_btn = QPushButton("Rebind Selected Shortcut") rebind_btn.clicked.connect(self.rebind_selected) layout.addWidget(rebind_btn) return widget def create_audio_tab(self): widget = QWidget() layout = QVBoxLayout(widget) layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Streaming section stream_group = QLabel("Live Streaming") stream_group.setStyleSheet("font-size: 14px; font-weight: bold; color: #00ffff; margin-top: 10px;") layout.addWidget(stream_group) stream_url_label = QLabel("Stream Server URL:") self.stream_url_input = QLineEdit() self.stream_url_input.setPlaceholderText("http://YOUR_SERVER_IP:8080/api/stream") current_stream_url = self.audio_settings.get("stream_server_url", "http://localhost:8080/api/stream") self.stream_url_input.setText(current_stream_url) self.stream_url_input.setStyleSheet(""" QLineEdit { background: #1a1a1a; border: 1px solid #333; padding: 8px; color: #fff; border-radius: 4px; } """) pw_label = QLabel("DJ Panel Password (leave blank if none):") self.dj_password_input = QLineEdit() self.dj_password_input.setEchoMode(QLineEdit.EchoMode.Password) self.dj_password_input.setPlaceholderText("Leave blank if no password is set") self.dj_password_input.setText(self.audio_settings.get("dj_panel_password", "")) self.dj_password_input.setStyleSheet(""" QLineEdit { background: #1a1a1a; border: 1px solid #333; padding: 8px; color: #fff; border-radius: 4px; } """) layout.addWidget(stream_url_label) layout.addWidget(self.stream_url_input) layout.addSpacing(8) layout.addWidget(pw_label) layout.addWidget(self.dj_password_input) layout.addSpacing(20) # Recording section rec_group = QLabel("Recording") rec_group.setStyleSheet("font-size: 14px; font-weight: bold; color: #ff00ff; margin-top: 10px;") layout.addWidget(rec_group) # Sample rate rate_label = QLabel("Recording Sample Rate:") self.sample_rate_combo = QComboBox() self.sample_rate_combo.addItem("44.1 kHz", 44100) self.sample_rate_combo.addItem("48 kHz (Recommended)", 48000) current_rate = self.audio_settings.get("recording_sample_rate", 48000) self.sample_rate_combo.setCurrentIndex(0 if current_rate == 44100 else 1) # Format format_label = QLabel("Recording Format:") self.format_combo = QComboBox() self.format_combo.addItem("WAV (Lossless)", "wav") self.format_combo.addItem("MP3 (Compressed)", "mp3") current_format = self.audio_settings.get("recording_format", "wav") self.format_combo.setCurrentIndex(0 if current_format == "wav" else 1) layout.addWidget(rate_label) layout.addWidget(self.sample_rate_combo) layout.addSpacing(10) layout.addWidget(format_label) layout.addWidget(self.format_combo) layout.addStretch() return widget def create_ui_tab(self): widget = QWidget() layout = QVBoxLayout(widget) layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Neon mode default neon_label = QLabel("Default Neon Edge Mode:") self.neon_combo = QComboBox() self.neon_combo.addItem("Off", 0) self.neon_combo.addItem("Blue (Cyan)", 1) self.neon_combo.addItem("Purple (Magenta)", 2) current_neon = self.ui_settings.get("neon_mode", 0) self.neon_combo.setCurrentIndex(current_neon) layout.addWidget(neon_label) layout.addWidget(self.neon_combo) layout.addStretch() return widget def create_library_tab(self): widget = QWidget() layout = QVBoxLayout(widget) layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Auto-scan self.auto_scan_check = QCheckBox("Auto-scan library on startup") self.auto_scan_check.setChecked(self.library_settings.get("auto_scan", True)) # YouTube default format yt_label = QLabel("YouTube Download Default Format:") self.yt_format_combo = QComboBox() self.yt_format_combo.addItem("MP3 (Universal)", "mp3") self.yt_format_combo.addItem("Best Quality (Faster)", "best") current_yt = self.library_settings.get("yt_default_format", "mp3") self.yt_format_combo.setCurrentIndex(0 if current_yt == "mp3" else 1) layout.addWidget(self.auto_scan_check) layout.addSpacing(10) layout.addWidget(yt_label) layout.addWidget(self.yt_format_combo) layout.addStretch() return widget def rebind_selected(self): row = self.shortcuts_table.currentRow() if row < 0: QMessageBox.warning(self, "No Selection", "Please select an action to rebind.") return action = self.shortcuts_table.item(row, 0).text() new_key, ok = QInputDialog.getText(self, "Rebind Key", f"Enter new key sequence for {action}:", text=self.shortcuts[action]) if ok and new_key: self.shortcuts[action] = new_key self.shortcuts_table.item(row, 1).setText(new_key) def get_all_settings(self): """Return all settings as a dictionary""" return { "shortcuts": self.shortcuts, "audio": { "recording_sample_rate": self.sample_rate_combo.currentData(), "recording_format": self.format_combo.currentData(), "stream_server_url": self.stream_url_input.text(), "dj_panel_password": self.dj_password_input.text(), }, "ui": { "neon_mode": self.neon_combo.currentData(), }, "library": { "auto_scan": self.auto_scan_check.isChecked(), "yt_default_format": self.yt_format_combo.currentData(), } } class YTResultDialog(QDialog): def __init__(self, results, parent=None): super().__init__(parent) self.setWindowTitle("YouTube Pro Search") self.resize(600, 450) self.setStyleSheet(""" QDialog { background-color: #0a0a0a; border: 2px solid #cc0000; } QListWidget { background-color: #000; color: #0f0; border: 1px solid #333; font-family: 'Courier New'; font-size: 13px; } QListWidget::item { padding: 10px; border-bottom: 1px solid #111; } QListWidget::item:selected { background-color: #222; color: #fff; border: 1px solid #cc0000; } QLabel { color: #fff; font-weight: bold; font-size: 16px; margin-bottom: 10px; } QPushButton { background-color: #cc0000; color: white; border: none; padding: 12px; font-weight: bold; border-radius: 5px; } QPushButton:hover { background-color: #ff0000; } """) layout = QVBoxLayout(self) header = QLabel("YouTube Search Results") layout.addWidget(header) self.list_widget = QListWidget() layout.addWidget(self.list_widget) for vid in results: duration_sec = vid.get('duration', 0) if not duration_sec: duration_sec = 0 m, s = divmod(int(duration_sec), 60) title_text = vid.get('title', 'Unknown Title') channel = vid.get('uploader', 'Unknown Artist') item = QListWidgetItem(f"{title_text}\n [{m:02}:{s:02}] - {channel}") item.setData(Qt.ItemDataRole.UserRole, vid.get('url')) self.list_widget.addItem(item) self.list_widget.itemDoubleClicked.connect(self.accept) btn_layout = QHBoxLayout() self.cancel_btn = QPushButton("CANCEL") self.cancel_btn.setStyleSheet("background-color: #333; color: #888; border-radius: 5px;") self.cancel_btn.clicked.connect(self.reject) btn_hl = QPushButton("DOWNLOAD & IMPORT") btn_hl.clicked.connect(self.accept) btn_layout.addWidget(self.cancel_btn) btn_layout.addWidget(btn_hl) layout.addLayout(btn_layout) def get_selected_url(self): i = self.list_widget.currentItem() return i.data(Qt.ItemDataRole.UserRole) if i else None class RecordingWorker(QProcess): """Records this app's audio output (isolated) using FFmpeg.""" recording_started = pyqtSignal() recording_error = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) self.output_file = "" self.readyReadStandardError.connect(self.handle_error) def start_recording(self, output_path, fmt="wav", sample_rate=48000): """Start recording this app's audio to file. fmt -- 'wav' (default) or 'mp3' sample_rate -- sample rate in Hz """ self.output_file = output_path if not shutil.which("ffmpeg"): self.recording_error.emit("FFmpeg not found. Install with: sudo apt install ffmpeg") return False source = get_audio_capture_source() print(f"[RECORDING] Starting: {output_path} format={fmt} sr={sample_rate} source={source}") if fmt == "mp3": args = [ "-f", "pulse", "-i", source, "-ac", "2", "-ar", str(sample_rate), "-codec:a", "libmp3lame", "-q:a", "2", # VBR ~190 kbps "-y", output_path, ] else: # wav args = [ "-f", "pulse", "-i", source, "-ac", "2", "-ar", str(sample_rate), "-acodec", "pcm_s16le", "-sample_fmt", "s16", "-y", output_path, ] self.start("ffmpeg", args) self.recording_started.emit() return True def stop_recording(self): """Stop the recording.""" if self.state() == QProcess.ProcessState.Running: print("[RECORDING] Stopping...") self.write(b"q") self.waitForFinished(3000) if self.state() == QProcess.ProcessState.Running: self.kill() def handle_error(self): """Handle FFmpeg stderr (which includes progress info)""" err = self.readAllStandardError().data().decode('utf-8', errors='ignore').strip() if err and "error" in err.lower(): print(f"[RECORDING ERROR] {err}") class StreamingWorker(QThread): """Streams the currently-playing deck's audio file to the server. Instead of capturing from a PulseAudio monitor (which is unreliable when Qt6 uses the PipeWire-native or ALSA audio backend), this worker reads the file being played directly through ffmpeg and sends the resulting MP3 bytes to the server as audio_chunk events. The server distributes them to all connected listener browsers via /stream.mp3. Pacing strategy --------------- We do NOT use ffmpeg's ``-re`` flag because it paces from byte-zero of the file which causes a startup surge of pre-seek data right after a ``-ss`` seek — this surge is what listeners hear as glitchy/stuttery audio at the beginning of every track. Instead we use an instantaneous fast-seek (``-ss`` before ``-i``), let ffmpeg encode as fast as possible without ``-re``, and manually throttle the read loop on our side using a token-bucket controlled by wall-clock time. This gives perfectly smooth, stutter-free output with no startup burst, and still handles seeks mid-track cleanly. """ streaming_started = pyqtSignal() streaming_error = pyqtSignal(str) listener_count = pyqtSignal(int) # Throttle pacing: emit CHUNK_BYTES bytes every CHUNK_INTERVAL seconds. # At 128 kbps that is 16 000 bytes/s = 16 bytes/ms. # 2048 bytes / 0.128 s ≈ 125 ms cadence — smooth without latency spikes. _STREAM_BITRATE_BPS = 128_000 # must match -b:a below _CHUNK_BYTES = 2048 _CHUNK_INTERVAL = _CHUNK_BYTES / (_STREAM_BITRATE_BPS / 8) # seconds def __init__(self, parent=None): super().__init__(parent) self.sio = None self.stream_url = "" self.dj_password = "" self.glow_intensity = 30 # mirrors the UI slider; sent to listeners on connect self.is_running = False self.ffmpeg_proc = None self._broadcast_started = False # Thread-safe command queue: ('play', file_path, position_ms) or ('stop',) self._file_cmd_queue = queue.Queue(maxsize=20) def on_connect(self): print("[SOCKET] Connected to DJ server") if not self._broadcast_started: self._broadcast_started = True self.sio.emit('start_broadcast', {'format': 'mp3', 'bitrate': '128k'}) # Push current glow intensity to all listeners as soon as we connect self.sio.emit('listener_glow', {'intensity': self.glow_intensity}) self.streaming_started.emit() else: # Reconnected mid-stream — server handles resume gracefully print("[SOCKET] Reconnected - resuming existing broadcast") self.sio.emit('start_broadcast', {'format': 'mp3', 'bitrate': '128k'}) self.sio.emit('listener_glow', {'intensity': self.glow_intensity}) def on_disconnect(self): print("[SOCKET] Disconnected from DJ server") def on_connect_error(self, data): self.streaming_error.emit(f"Connection error: {data}") def on_listener_count(self, data): self.listener_count.emit(data.get('count', 0)) @staticmethod def _get_auth_cookie(base_url, password): """POST the DJ panel password to /login and return the session cookie string. The server sets a Flask session cookie on success. We forward that cookie as an HTTP header on the Socket.IO upgrade request so the socket handler sees an authenticated session. """ try: resp = requests.post( f"{base_url}/login", data={"password": password}, allow_redirects=False, timeout=5, ) # Server responds with 302 + Set-Cookie on success if resp.cookies: return "; ".join(f"{k}={v}" for k, v in resp.cookies.items()) print(f"[AUTH] Login response {resp.status_code} — no session cookie returned") return None except Exception as e: print(f"[AUTH] Login request failed: {e}") return None @staticmethod def _drain_stderr(proc): """Read ffmpeg's stderr in a daemon thread so the OS pipe buffer never fills up and deadlocks the stdout read loop.""" try: for raw_line in iter(proc.stderr.readline, b''): line = raw_line.decode('utf-8', errors='ignore').strip() if line: print(f"[STREAM FFMPEG] {line}") except Exception: pass def run(self): try: # Create a fresh Socket.IO client for each session self.sio = socketio.Client() self.sio.on('connect', self.on_connect) self.sio.on('disconnect', self.on_disconnect) self.sio.on('listener_count', self.on_listener_count) self.sio.on('connect_error', self.on_connect_error) # If the DJ panel has a password set, authenticate via HTTP first to # obtain a Flask session cookie, then forward it on the WS upgrade # request so the socket handler sees an authenticated session. connect_headers = {} if self.dj_password: print("[AUTH] DJ panel password configured — authenticating...") cookie = self._get_auth_cookie(self.stream_url, self.dj_password) if cookie: connect_headers['Cookie'] = cookie print("[AUTH] Authenticated — session cookie obtained") else: self.streaming_error.emit( "Authentication failed.\n" "Check the DJ panel password in Settings → Audio." ) return # wait_timeout: how long to wait for the server to respond during connect self.sio.connect(self.stream_url, wait_timeout=10, headers=connect_headers) print("[STREAM] Connected — waiting for deck to start playing...") # File-based streaming loop. # Waits for ('play', path, pos_ms) commands from the main thread, # then pipes the file through ffmpeg at real-time rate to the server. # This is reliable regardless of Qt's audio backend (PulseAudio / # PipeWire-native / ALSA), since we read the file directly. current_proc = None while self.is_running: # Block briefly waiting for a command; loop allows is_running re-check try: cmd = self._file_cmd_queue.get(timeout=0.25) except queue.Empty: # If the current ffmpeg exited on its own (track ended), clean up if current_proc is not None and current_proc.poll() is not None: current_proc = None self.ffmpeg_proc = None continue if cmd[0] == 'play': # Kill previous ffmpeg before starting a new one if current_proc and current_proc.poll() is None: current_proc.terminate() try: current_proc.wait(timeout=1.0) except Exception: current_proc.kill() _, file_path, position_ms, active_fx = cmd position_secs = max(0.0, position_ms / 1000.0) af_filter = StreamingWorker._build_af_filter(active_fx) print(f"[STREAM] Streaming file: {file_path} from {position_secs:.1f}s fx={active_fx or 'none'}") # -ss before -i = fast (keyframe) seek; no -re so ffmpeg # encodes ahead of real-time and we pace the reads ourselves. ffmpeg_cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-ss", f"{position_secs:.3f}", "-i", file_path, "-vn", "-ac", "2", "-ar", "44100", ] if af_filter: ffmpeg_cmd.extend(["-af", af_filter]) ffmpeg_cmd.extend([ "-b:a", "128k", "-write_xing", "0", # no Xing/LAME header — cleaner mid-stream "-flush_packets", "1", "-f", "mp3", "pipe:1", ]) current_proc = subprocess.Popen( ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0, ) self.ffmpeg_proc = current_proc threading.Thread( target=self._drain_stderr, args=(current_proc,), daemon=True ).start() # Inner read loop — token-bucket pacing so we send at exactly # bitrate speed regardless of how fast ffmpeg encodes. _interval = StreamingWorker._CHUNK_INTERVAL _sz = StreamingWorker._CHUNK_BYTES _next_send = time.monotonic() while self.is_running: # Non-blocking check for new command (switch track / stop) try: next_cmd = self._file_cmd_queue.get_nowait() # Re-queue so the outer loop handles it try: self._file_cmd_queue.put_nowait(next_cmd) except queue.Full: pass # Kill current proc so read() below returns immediately if current_proc.poll() is None: current_proc.terminate() break except queue.Empty: pass if current_proc.poll() is not None: current_proc = None self.ffmpeg_proc = None break chunk = current_proc.stdout.read(_sz) if not chunk: current_proc = None self.ffmpeg_proc = None break sio = self.sio if sio and sio.connected: # Token-bucket: sleep until the next scheduled send slot. now = time.monotonic() wait = _next_send - now if wait > 0: time.sleep(wait) _next_send = max(time.monotonic(), _next_send) + _interval sio.emit('audio_chunk', chunk) elif cmd[0] == 'stop': if current_proc and current_proc.poll() is None: current_proc.terminate() try: current_proc.wait(timeout=1.0) except Exception: current_proc.kill() current_proc = None self.ffmpeg_proc = None except Exception as e: self.streaming_error.emit(f"Streaming error: {e}") finally: self.stop_streaming() def start_streaming(self, base_url, bitrate=128, password=""): self.stream_url = base_url self.dj_password = password self.is_running = True self.start() return True def emit_if_connected(self, event, data=None): """Thread-safe emit from any thread — no-op if socket is not connected.""" sio = self.sio if sio and sio.connected: try: sio.emit(event, data) except Exception as e: print(f"[SOCKET] emit_if_connected error: {e}") @staticmethod def _build_af_filter(effects): """Return an ffmpeg -af filter string from the active effects set, or None.""" if not effects: return None segments = [] for fx_id in ('reverb', 'echo', 'comb'): # only one delay-type at a time if fx_id in effects: segments.extend(FX_DEFS[fx_id]) break for fx_id in ('flange', 'phase', 'lpf', 'hpf', 'crush', 'bass', 'treble'): if fx_id in effects: segments.extend(FX_DEFS[fx_id]) return ','.join(segments) if segments else None def switch_file(self, file_path, position_ms=0, effects=None): """Called from the main thread when a deck starts playing. Signals the streaming loop to kill the current ffmpeg (if any) and start a new one reading *file_path* from *position_ms* with the given effects filter chain. """ if not file_path: return # Drop any stale pending commands so only the latest file matters while not self._file_cmd_queue.empty(): try: self._file_cmd_queue.get_nowait() except queue.Empty: break try: self._file_cmd_queue.put_nowait(( 'play', file_path, int(position_ms), frozenset(effects or []) )) except queue.Full: pass def stop_file(self): """Called from the main thread when a deck pauses or stops.""" while not self._file_cmd_queue.empty(): try: self._file_cmd_queue.get_nowait() except queue.Empty: break try: self._file_cmd_queue.put_nowait(('stop',)) except queue.Full: pass def stop_streaming(self): """Thread-safe stop: capture refs locally before clearing to avoid TOCTOU.""" self.is_running = False self._broadcast_started = False proc = self.ffmpeg_proc self.ffmpeg_proc = None sio = self.sio self.sio = None if proc: try: proc.terminate() except Exception: pass if sio: try: if sio.connected: sio.emit('stop_broadcast') sio.disconnect() except Exception: pass # --- WIDGETS --- class GlowFrame(QWidget): """Custom widget that paints a neon glow effect around the edges""" def __init__(self, parent=None): super().__init__(parent) self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) # Don't block mouse events self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.glow_color = QColor("#0ff") self.glow_enabled = False def set_glow(self, enabled, color="#0ff"): self.glow_enabled = enabled self.glow_color = QColor(color) self.update() def paintEvent(self, event): if not self.glow_enabled: return painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) rect = self.rect() glow_width = 80 # Wider glow for more intensity # Draw glow from each edge using linear gradients # Top edge glow top_gradient = QLinearGradient(0, 0, 0, glow_width) for i in range(6): pos = i / 5.0 alpha = int(255 * (1 - pos)) # Full opacity at edge color = QColor(self.glow_color) color.setAlpha(alpha) top_gradient.setColorAt(pos, color) painter.fillRect(0, 0, rect.width(), glow_width, top_gradient) # Bottom edge glow bottom_gradient = QLinearGradient(0, rect.height() - glow_width, 0, rect.height()) for i in range(6): pos = i / 5.0 alpha = int(255 * pos) color = QColor(self.glow_color) color.setAlpha(alpha) bottom_gradient.setColorAt(pos, color) painter.fillRect(0, rect.height() - glow_width, rect.width(), glow_width, bottom_gradient) # Left edge glow left_gradient = QLinearGradient(0, 0, glow_width, 0) for i in range(6): pos = i / 5.0 alpha = int(255 * (1 - pos)) color = QColor(self.glow_color) color.setAlpha(alpha) left_gradient.setColorAt(pos, color) painter.fillRect(0, 0, glow_width, rect.height(), left_gradient) # Right edge glow right_gradient = QLinearGradient(rect.width() - glow_width, 0, rect.width(), 0) for i in range(6): pos = i / 5.0 alpha = int(255 * pos) color = QColor(self.glow_color) color.setAlpha(alpha) right_gradient.setColorAt(pos, color) painter.fillRect(rect.width() - glow_width, 0, glow_width, rect.height(), right_gradient) class VinylWidget(QWidget): def __init__(self, color_hex, parent=None): super().__init__(parent) self.setMinimumSize(140, 140) self.angle = 0 self.speed = 1.0 self.is_spinning = False self.color = QColor(color_hex) self.center = QPointF(0, 0) self.radius = 0 self.timer = QTimer(self) self.timer.timeout.connect(self.rotate) def resizeEvent(self, event): w, h = self.width(), self.height() self.center = QPointF(w / 2, h / 2) self.radius = min(w, h) / 2 - 4 super().resizeEvent(event) def start_spin(self): if not self.is_spinning: self.is_spinning = True self.timer.start(ANIMATION_INTERVAL) def stop_spin(self): self.is_spinning = False self.timer.stop() def set_speed(self, rate): self.speed = rate def rotate(self): self.angle = (self.angle + 3.0 * self.speed) % 360 self.update() def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) cx, cy = self.center.x(), self.center.y() r = self.radius # --- outer tyre rim (metallic silver ring) --- p.setBrush(QBrush(QColor("#1a2030"))) p.setPen(QPen(QColor("#334455"), 1)) p.drawEllipse(self.center, r, r) # --- main vinyl disk (rotates) --- p.save() p.translate(self.center) p.rotate(self.angle) # Base vinyl – deep black with subtle radial gradient vinyl_grad = QRadialGradient(QPointF(0, 0), r * 0.97) vinyl_grad.setColorAt(0.0, QColor("#1c1c1c")) vinyl_grad.setColorAt(0.45, QColor("#0d0d0d")) vinyl_grad.setColorAt(0.75, QColor("#141414")) vinyl_grad.setColorAt(1.0, QColor("#0a0a0a")) p.setBrush(QBrush(vinyl_grad)) p.setPen(Qt.PenStyle.NoPen) p.drawEllipse(QPointF(0, 0), r * 0.97, r * 0.97) # Groove rings – concentric thin circles groove_pen = QPen(QColor("#2a2a2a"), 1) groove_pen.setCosmetic(True) p.setBrush(Qt.BrushStyle.NoBrush) for frac in [0.93, 0.88, 0.82, 0.76, 0.70, 0.65, 0.60, 0.53, 0.47, 0.42]: groove_pen.setColor(QColor("#222222") if frac > 0.6 else QColor("#1e1e1e")) p.setPen(groove_pen) p.drawEllipse(QPointF(0, 0), r * frac, r * frac) # Shiny reflection arc (static highlight on top-left of disk) p.restore() p.save() p.translate(self.center) p.rotate(self.angle) # thin bright arc shine_pen = QPen(QColor(255, 255, 255, 28), 2) shine_pen.setCosmetic(True) p.setPen(shine_pen) p.setBrush(Qt.BrushStyle.NoBrush) p.drawArc(QRectF(-r*0.85, -r*0.85, r*1.7, r*1.7), 120*16, 60*16) p.restore() # --- center label (rotates with disk) --- p.save() p.translate(self.center) p.rotate(self.angle) # Label background gradient label_r = r * 0.34 label_grad = QRadialGradient(QPointF(-label_r*0.25, -label_r*0.2), label_r) lc = self.color darker = lc.darker(200) label_grad.setColorAt(0.0, lc.lighter(130)) label_grad.setColorAt(0.5, lc) label_grad.setColorAt(1.0, darker) p.setBrush(QBrush(label_grad)) p.setPen(Qt.PenStyle.NoPen) p.drawEllipse(QPointF(0, 0), label_r, label_r) # Neon ring around label ring_col = QColor(self.color) ring_col.setAlpha(200) p.setPen(QPen(ring_col, 2)) p.setBrush(Qt.BrushStyle.NoBrush) p.drawEllipse(QPointF(0, 0), label_r + 3, label_r + 3) # Spindle hole p.setBrush(QBrush(QColor("#000"))) p.setPen(QPen(QColor("#1a1a1a"), 1)) p.drawEllipse(QPointF(0, 0), label_r * 0.12, label_r * 0.12) # Marker stripe on label p.setBrush(QBrush(QColor(255, 255, 255, 180))) p.setPen(Qt.PenStyle.NoPen) p.drawRect(QRectF(-2, -label_r * 0.9, 4, label_r * 0.45)) p.restore() # --------------------------------------------------------------------------- # Background workers for waveform loading and BPM detection # --------------------------------------------------------------------------- class WaveformLoaderThread(QThread): """Loads RMS bars from an audio file in a background thread. Safe to emit signals back to the main thread via Qt's queued connection. """ wave_ready = pyqtSignal(list) def __init__(self, file_path, num_bars=300): super().__init__() self.file_path = str(file_path) self.num_bars = num_bars def run(self): try: data, _ = sf.read(self.file_path, dtype='float32', always_2d=True) mono = data.mean(axis=1) chunk = max(1, len(mono) // self.num_bars) bars = [] for i in range(self.num_bars): seg = mono[i * chunk:(i + 1) * chunk] rms = float(np.sqrt(np.mean(seg ** 2))) if len(seg) else 0.02 bars.append(max(0.02, rms)) peak = max(bars) or 1.0 self.wave_ready.emit([max(0.05, v / peak) for v in bars]) except Exception as e: print(f"[WAVE] Fallback ({Path(self.file_path).name}): {e}") random.seed(self.file_path) base = [max(0.05, random.random() ** 1.5) for _ in range(self.num_bars)] smoothed = [] for i, v in enumerate(base): nb = [base[max(0, i - 1)], v, base[min(len(base) - 1, i + 1)]] smoothed.append(sum(nb) / len(nb)) self.wave_ready.emit(smoothed) class BPMDetectorThread(QThread): """Detects BPM using librosa in a background thread.""" bpm_ready = pyqtSignal(float) def __init__(self, file_path_str): super().__init__() self.file_path = file_path_str def run(self): if not HAS_LIBROSA: return try: y, sr = librosa.load(self.file_path, sr=None, mono=True, duration=60) tempo, _ = librosa.beat.beat_track(y=y, sr=sr) bpm_val = float(np.atleast_1d(tempo)[0]) if 60 <= bpm_val <= 200: self.bpm_ready.emit(round(bpm_val, 1)) print(f"[BPM] {bpm_val:.1f} BPM — {Path(self.file_path).name}") else: print(f"[BPM] Out-of-range ({bpm_val:.1f}), ignored") except Exception as e: print(f"[BPM] Detection failed: {e}") # --------------------------------------------------------------------------- # Real-time spectrum level meter # --------------------------------------------------------------------------- class LevelMeterWidget(QWidget): """Animated spectrum bar meter driven by pre-analysed waveform data.""" NUM_BARS = 32 DECAY = 0.055 PEAK_HOLD_MAX = 18 # frames to hold peak marker before decay def __init__(self, color_hex, parent=None): super().__init__(parent) self.base_color = QColor(color_hex) self.setFixedHeight(52) self._levels = [0.0] * self.NUM_BARS self._peaks = [0.0] * self.NUM_BARS self._peak_hold = [0] * self.NUM_BARS self._wave_data = [] self._pos = 0 self._dur = 1 self._playing = False self._phase = 0 # pseudo-random animation phase self._timer = QTimer(self) self._timer.setInterval(33) # ~30 fps self._timer.timeout.connect(self._tick) self._timer.start() def set_wave_data(self, data): self._wave_data = data def update_playback(self, pos, dur, playing): self._pos = pos self._dur = max(1, dur) self._playing = playing def _tick(self): self._phase = (self._phase + 1) % 97 # Skip expensive repaint when nothing is animating if not self._playing and max(self._levels) < 0.005 and max(self._peaks) < 0.005: return if self._playing and self._wave_data: ratio = self._pos / self._dur n = len(self._wave_data) ci = int(ratio * n) # How many wave-data bars to spread across the NUM_BARS display window = max(4, n // 20) for i in range(self.NUM_BARS): spread = int((i / self.NUM_BARS - 0.5) * window * 0.5) idx = max(0, min(n - 1, ci + spread)) # Per-bar pseudo-random variation so bars look like separate bands variation = 0.65 + 0.70 * ((i * 7919 + self._phase * 31) % 100) / 100.0 target = min(1.0, self._wave_data[idx] * variation) if target > self._levels[i]: self._levels[i] = target decay = self.DECAY * (1.0 if self._playing else 2.5) for i in range(self.NUM_BARS): self._levels[i] = max(0.0, self._levels[i] - decay) if self._levels[i] >= self._peaks[i]: self._peaks[i] = self._levels[i] self._peak_hold[i] = self.PEAK_HOLD_MAX elif self._peak_hold[i] > 0: self._peak_hold[i] -= 1 else: self._peaks[i] = max(0.0, self._peaks[i] - self.DECAY * 0.4) self.update() def paintEvent(self, event): painter = QPainter(self) w, h = self.width(), self.height() painter.fillRect(0, 0, w, h, QColor(4, 7, 14)) bar_w = w / self.NUM_BARS gap = max(1, int(bar_w * 0.20)) bw = max(2, int(bar_w) - gap) c_bot = self.base_color c_top = QColor(255, 50, 50) t_top = int(h * 0.18) # top 18% = red zone for i in range(self.NUM_BARS): lvl = self._levels[i] bx = int(i * bar_w) bah = max(2, int(lvl * (h - 2))) if bah < 2: continue by = h - bah if by < t_top: painter.fillRect(bx, by, bw, t_top - by, c_top) painter.fillRect(bx, t_top, bw, h - t_top, c_bot) else: painter.fillRect(bx, by, bw, h - by, c_bot) # Peak hold marker pk = self._peaks[i] if pk > 0.05: py = h - int(pk * (h - 2)) - 2 painter.fillRect(bx, py, bw, 2, QColor(255, 255, 255, 190)) painter.end() class WaveformWidget(QWidget): seekRequested = pyqtSignal(int) bpm_detected = pyqtSignal(float) # kept for back-compat; see BPMDetectorThread wave_ready = pyqtSignal(list) # emitted when waveform bars are loaded def __init__(self, color_hex, parent=None): super().__init__(parent) self.color = QColor(color_hex) self.setMinimumHeight(70) self.setCursor(Qt.CursorShape.PointingHandCursor) self.duration = 1 self.position = 0 self.wave_data = [] self.loop_active = False self.loop_start = 0 self.loop_end = 0 self.last_seek_time = 0 self._wave_loader = None # keep reference so thread isn't GC'd def detect_bpm(self, file_path_str): """Start BPM detection in a QThread; result forwarded via bpm_detected signal.""" if not HAS_LIBROSA: return # Disconnect any previous thread so a stale BPM result from a slow-finishing # old track does not overwrite the BPM of the newly loaded track. if hasattr(self, '_bpm_thread') and self._bpm_thread is not None: try: self._bpm_thread.bpm_ready.disconnect() except RuntimeError: pass self._bpm_thread = BPMDetectorThread(file_path_str) self._bpm_thread.bpm_ready.connect(self.bpm_detected) self._bpm_thread.start() def generate_wave(self, file_path): """Kick off background waveform loading; shows blank until ready.""" self.wave_data = [] self.update() if self._wave_loader is not None: # Disconnect signal before replacing so a slow-finishing old thread # cannot overwrite the new waveform with stale data. try: self._wave_loader.wave_ready.disconnect(self._on_wave_ready) except RuntimeError: pass if self._wave_loader.isRunning(): self._wave_loader.quit() self._wave_loader = WaveformLoaderThread(file_path) self._wave_loader.wave_ready.connect(self._on_wave_ready) self._wave_loader.start() def _on_wave_ready(self, bars): self.wave_data = bars self.wave_ready.emit(bars) self.update() def set_duration(self, d): self.duration = max(1, d) self.update() def set_position(self, p): self.position = p self.update() def set_loop_region(self, active, start, end): self.loop_active = active self.loop_start = start self.loop_end = end self.update() def mousePressEvent(self, e): if time.time() - self.last_seek_time > 0.1: seek_pos = int((e.position().x() / self.width()) * self.duration) self.seekRequested.emit(seek_pos) self.last_seek_time = time.time() def mouseMoveEvent(self, e): if e.buttons() and time.time() - self.last_seek_time > 0.1: seek_pos = int((e.position().x() / self.width()) * self.duration) self.seekRequested.emit(seek_pos) self.last_seek_time = time.time() def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) w, h = self.width(), self.height() mid = h / 2 # Background with scanline effect p.fillRect(0, 0, w, h, QColor("#05080f")) scan_pen = QPen(QColor(0, 0, 0, 30), 1) p.setPen(scan_pen) for y in range(0, h, 3): p.drawLine(0, y, w, y) if not self.wave_data: p.setPen(QPen(QColor(self.color), 1)) p.drawLine(0, int(mid), w, int(mid)) return bar_w = w / len(self.wave_data) play_x = (self.position / self.duration) * w # Played-through color (dimmed version) vs upcoming (bright) col_played = self.color.darker(250) col_upcoming = self.color # Loop region shading if self.loop_active: lx = int((self.loop_start / self.duration) * w) lw_ = int(((self.loop_end - self.loop_start) / self.duration) * w) loop_bg = QColor(255, 165, 0, 35) p.fillRect(lx, 0, lw_, h, loop_bg) p.setPen(QPen(QColor("#ff9900"), 1)) p.drawLine(lx, 0, lx, h) p.drawLine(lx + lw_, 0, lx + lw_, h) p.setPen(Qt.PenStyle.NoPen) for i, val in enumerate(self.wave_data): bx = i * bar_w # Dual-sided: bars grow up and down from center bar_half = val * mid * 0.88 color = col_played if bx < play_x else col_upcoming p.setBrush(QBrush(color)) # upper half p.drawRect(QRectF(bx, mid - bar_half, max(1, bar_w - 0.5), bar_half)) # lower half (mirror) dimmed = QColor(color) dimmed.setAlpha(120) p.setBrush(QBrush(dimmed)) p.drawRect(QRectF(bx, mid, max(1, bar_w - 0.5), bar_half)) # Center line p.setPen(QPen(QColor(80, 100, 120, 140), 1)) p.drawLine(0, int(mid), w, int(mid)) # Glowing playhead px = int(play_x) for offset, alpha_ in [(3, 25), (2, 55), (1, 120), (0, 255)]: glow_color = QColor(self.color) glow_color.setAlpha(alpha_) p.setPen(QPen(glow_color, 1 + offset)) p.drawLine(px, 0, px, h) # Time marker triangle at bottom tri_size = 5 tri_col = QColor(self.color) tri_col.setAlpha(200) p.setBrush(QBrush(tri_col)) p.setPen(Qt.PenStyle.NoPen) tri = QPolygonF([ QPointF(px - tri_size, h), QPointF(px + tri_size, h), QPointF(px, h - tri_size * 1.5), ]) p.drawPolygon(tri) class DeckWidget(QGroupBox): def __init__(self, name, color_code, deck_id, parent=None): super().__init__(name, parent) self.setObjectName(name.replace(" ", "_")) self.color_code = color_code self.deck_id = deck_id self.playback_mode = 0 self.loop_active = False self.loop_start = 0 self.loop_end = 0 self.loop_btns = [] self._loop_in_pending = None # IN marker set, waiting for OUT self._roll_ref_pos = None # position when ROLL was pressed self._roll_start_time = None # wall-clock time when ROLL was pressed (for accurate elapsed) self.detected_bpm = None # librosa-detected BPM; None = use DEFAULT_BPM self.xf_vol = 100 self.current_title = "" self.current_file_path = None self.loop_timer = QTimer(self) self.loop_timer.setInterval(LOOP_CHECK_INTERVAL) self.loop_timer.timeout.connect(self.check_loop) self.audio_output = QAudioOutput() self.player = QMediaPlayer() self.player.setAudioOutput(self.audio_output) self.player.positionChanged.connect(self.on_position_changed) self.player.durationChanged.connect(self.on_duration_changed) self.player.mediaStatusChanged.connect(self.check_queue) self.real_duration = 0 self.setup_ui() def setup_ui(self): layout = QVBoxLayout() layout.setSpacing(4) # ── Top row: Vinyl + track info ────────────────────────────────── r1 = QHBoxLayout() r1.setSpacing(8) self.vinyl = VinylWidget(self.color_code) self.vinyl.setFixedSize(150, 150) r1.addWidget(self.vinyl) c1 = QVBoxLayout() c1.setSpacing(3) # Track title display self.lbl_tr = QLabel("NO MEDIA") self.lbl_tr.setAlignment(Qt.AlignmentFlag.AlignCenter) self.lbl_tr.setStyleSheet( f"color: {self.color_code}; border: 1px solid {self.color_code}44; " f"background: #060a10; padding: 5px 6px; font-family: 'Courier New'; " f"font-size: 12px; font-weight: bold; letter-spacing: 1px;" ) self.lbl_tr.setWordWrap(True) self.lbl_tr.setFixedHeight(46) c1.addWidget(self.lbl_tr) # Time row rt = QHBoxLayout() self.lbl_cur = QLabel("00:00") self.lbl_cur.setStyleSheet( f"color: {self.color_code}; font-family: 'Courier New'; font-size: 20px; font-weight: bold;" ) self.lbl_tot = QLabel("/ 00:00") self.lbl_tot.setStyleSheet("color:#445566; font-family: 'Courier New'; font-size: 13px;") rt.addWidget(self.lbl_cur) rt.addStretch() rt.addWidget(self.lbl_tot) c1.addLayout(rt) # BPM + pitch readout row bpm_row = QHBoxLayout() bpm_label = QLabel("BPM") bpm_label.setStyleSheet("color:#334455; font-size:9px; font-family:'Courier New';") self.lbl_bpm = QLabel(f"{DEFAULT_BPM}") self.lbl_bpm.setStyleSheet( "color:#ff9900; font-family:'Courier New'; font-size:16px; font-weight:bold;" ) self.lbl_rate = QLabel("1.00x") self.lbl_rate.setStyleSheet( f"color:{self.color_code}; font-family:'Courier New'; font-size:13px; font-weight:bold;" ) bpm_row.addWidget(bpm_label) bpm_row.addWidget(self.lbl_bpm) bpm_row.addStretch() bpm_row.addWidget(self.lbl_rate) c1.addLayout(bpm_row) # Hot cue buttons row hc_row = QHBoxLayout() hc_row.setSpacing(3) self.hot_cues = {} # idx -> ms position (-1 = unset) hc_names = ["CUE1", "CUE2", "CUE3", "CUE4"] hc_ids = ["btn_hotcue1", "btn_hotcue2", "btn_hotcue3", "btn_hotcue4"] hc_colors = ["#9933ff", "#00ccbb", "#ff7700", "#ff3388"] for i in range(4): btn = QPushButton(hc_names[i]) btn.setObjectName(hc_ids[i]) btn.setCheckable(True) btn.setToolTip(f"Hot cue {i+1}: unset — click to set") btn.clicked.connect(lambda checked, idx=i: self._hotcue_action(idx)) self.hot_cues[i] = -1 hc_row.addWidget(btn) setattr(self, f"btn_hc{i+1}", btn) c1.addLayout(hc_row) r1.addLayout(c1, 1) layout.addLayout(r1) # ── Waveform ────────────────────────────────────────────────────── self.wave = WaveformWidget(self.color_code) self.wave.seekRequested.connect(self.player.setPosition) self.wave.bpm_detected.connect(self._on_bpm_detected) layout.addWidget(self.wave) # ── Level meter (spectrum bars) ─────────────────────────────────── self.level_meter = LevelMeterWidget(self.color_code) self.wave.wave_ready.connect(self.level_meter.set_wave_data) layout.addWidget(self.level_meter) # ── Loop beat buttons ───────────────────────────────────────────── loop_frame = QWidget() loop_frame.setStyleSheet("background:#030608; border-radius:3px;") g = QGridLayout(loop_frame) g.setSpacing(2) g.setContentsMargins(4, 3, 4, 3) loop_lbl = QLabel("LOOP") loop_lbl.setStyleSheet("color:#334455; font-size:9px; font-family:'Courier New'; letter-spacing:2px;") g.addWidget(loop_lbl, 0, 0) loops = [("8", 8), ("4", 4), ("2", 2), ("1", 1), ("1/2", 0.5), ("1/4", 0.25), ("1/8", 0.125)] for i, (label, beats) in enumerate(loops): btn = QPushButton(label) btn.setObjectName("btn_loop") btn.setCheckable(True) btn.setFixedHeight(22) btn.setToolTip(f"Set loop to {beats} beat(s)") btn.clicked.connect(lambda c, b=beats, o=btn: self.set_loop(b, o)) g.addWidget(btn, 0, i + 1) self.loop_btns.append(btn) exit_btn = QPushButton("EXIT") exit_btn.setObjectName("btn_loop_exit") exit_btn.setFixedHeight(22) exit_btn.setToolTip("Clear active loop") exit_btn.clicked.connect(self.clear_loop) g.addWidget(exit_btn, 0, len(loops) + 1) # ── Row 2: manual IN/OUT + resize + ROLL ────────────────────────── self._alo_base_ss = ( "QPushButton{background:#080d14;color:#446655;border:1px solid #1a2a1a;" "font-family:'Courier New';font-size:9px;font-weight:bold;letter-spacing:1px;}" "QPushButton:hover{color:#00ffaa;border-color:#00ffaa66;background:#0a1410;}" "QPushButton:checked{background:#00ffaa18;color:#00ffaa;border:1px solid #00ffaa;}" ) _alo_ss = self._alo_base_ss _alo_lbl = QLabel("AUTO") _alo_lbl.setStyleSheet("color:#334455;font-size:9px;font-family:'Courier New';letter-spacing:2px;") g.addWidget(_alo_lbl, 1, 0) self.btn_loop_in = QPushButton("IN") self.btn_loop_in.setFixedHeight(22) self.btn_loop_in.setStyleSheet(_alo_ss) self.btn_loop_in.setToolTip("Set loop IN point at current position") self.btn_loop_in.clicked.connect(self.loop_set_in) g.addWidget(self.btn_loop_in, 1, 1) self.btn_loop_out = QPushButton("OUT") self.btn_loop_out.setCheckable(True) self.btn_loop_out.setFixedHeight(22) self.btn_loop_out.setStyleSheet(_alo_ss) self.btn_loop_out.setToolTip("Set loop OUT point at current position and activate loop") self.btn_loop_out.clicked.connect(self.loop_set_out) g.addWidget(self.btn_loop_out, 1, 2) self.btn_loop_x2 = QPushButton("2X") self.btn_loop_x2.setFixedHeight(22) self.btn_loop_x2.setStyleSheet(_alo_ss) self.btn_loop_x2.setToolTip("Double loop length") self.btn_loop_x2.clicked.connect(self.loop_double) g.addWidget(self.btn_loop_x2, 1, 3) self.btn_loop_d2 = QPushButton("/2") self.btn_loop_d2.setFixedHeight(22) self.btn_loop_d2.setStyleSheet(_alo_ss) self.btn_loop_d2.setToolTip("Halve loop length") self.btn_loop_d2.clicked.connect(self.loop_halve) g.addWidget(self.btn_loop_d2, 1, 4) # ROLL: hold to loop, release resumes from where track would have been _roll_ss = ( "QPushButton{background:#0a0514;color:#9933ff;border:1px solid #2a0066;" "font-family:'Courier New';font-size:9px;font-weight:bold;letter-spacing:1px;}" "QPushButton:hover{color:#cc66ff;border-color:#aa33ff;}" "QPushButton:pressed{background:#aa33ff;color:#000;}" ) self.btn_loop_roll = QPushButton("ROLL") self.btn_loop_roll.setFixedHeight(22) self.btn_loop_roll.setStyleSheet(_roll_ss) self.btn_loop_roll.setToolTip("Loop roll: hold loops, release continues from real position") self.btn_loop_roll.pressed.connect(self._roll_start) self.btn_loop_roll.released.connect(self._roll_end) g.addWidget(self.btn_loop_roll, 1, 5) layout.addWidget(loop_frame) # ── Transport controls ──────────────────────────────────────────── rc = QHBoxLayout() rc.setSpacing(4) bp = QPushButton("PLAY") bp.setObjectName("btn_play") bp.setToolTip("Play track (Space)") bp.clicked.connect(self.play) bpa = QPushButton("PAUSE") bpa.setObjectName("btn_pause") bpa.setToolTip("Pause playback") bpa.clicked.connect(self.pause) bs = QPushButton("STOP") bs.setObjectName("btn_stop") bs.setToolTip("Stop playback") bs.clicked.connect(self.stop) # CUE button self.btn_cue = QPushButton("CUE") self.btn_cue.setObjectName("btn_cue") self.btn_cue.setToolTip("Set/jump to cue point") self.btn_cue.setFixedWidth(52) self._cue_point = -1 self.btn_cue.clicked.connect(self._cue_action) self.b_mode = QPushButton("CONT") self.b_mode.setFixedWidth(90) self.b_mode.setProperty("mode", "0") self.b_mode.setToolTip("Cycle playback mode: Continuous / Loop 1 / Stop") self.b_mode.clicked.connect(self.cycle_mode) rc.addWidget(bp) rc.addWidget(bpa) rc.addWidget(bs) rc.addSpacing(6) rc.addWidget(self.btn_cue) rc.addStretch() rc.addWidget(self.b_mode) layout.addLayout(rc) # ── Pitch / speed control ───────────────────────────────────────── rp = QHBoxLayout() rp.setSpacing(4) pitch_lbl = QLabel("PITCH") pitch_lbl.setStyleSheet("color:#334455; font-size:9px; font-family:'Courier New'; letter-spacing:1px;") pitch_lbl.setFixedWidth(38) self.sl_rate = QSlider(Qt.Orientation.Horizontal) self.sl_rate.setRange(50, 150) self.sl_rate.setValue(100) self.sl_rate.setToolTip("Adjust playback speed / pitch (50%-150%)") self.sl_rate.valueChanged.connect(self.update_playback_rate) br = QPushButton("0") br.setToolTip("Reset pitch to 1.0x") br.setFixedWidth(24) br.setStyleSheet("font-size:10px; padding:2px;") br.clicked.connect(lambda: self.sl_rate.setValue(100)) rp.addWidget(pitch_lbl) rp.addWidget(self.sl_rate) rp.addWidget(br) rp.addWidget(self.lbl_rate) layout.addLayout(rp) # ── Bottom section: Queue + EQ faders ──────────────────────────── bottom = QHBoxLayout() bottom.setSpacing(6) # Queue widget qc = QWidget() ql = QVBoxLayout(qc) ql.setContentsMargins(0, 0, 0, 0) ql.setSpacing(2) hq = QHBoxLayout() queue_title_lbl = QLabel(f"QUEUE {self.deck_id}") queue_title_lbl.setStyleSheet( f"font-size:9px; font-family:'Courier New'; letter-spacing:2px; color:{self.color_code}88;" ) bd = QPushButton("X") bd.setObjectName("btn_remove") bd.setToolTip("Remove selected track from queue") bd.setFixedSize(22, 22) bd.clicked.connect(self.delete_selected) hq.addWidget(queue_title_lbl) hq.addStretch() hq.addWidget(bd) ql.addLayout(hq) self.q_list = QListWidget() self.q_list.setObjectName("queue_list") self.q_list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.q_list.setDefaultDropAction(Qt.DropAction.MoveAction) self.q_list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.q_list.itemDoubleClicked.connect(self._queue_double_clicked) QShortcut(QKeySequence(Qt.Key.Key_Delete), self.q_list).activated.connect(self.delete_selected) ql.addWidget(self.q_list) # EQ sliders widget sc = QWidget() sl = QVBoxLayout(sc) sl.setContentsMargins(0, 0, 0, 0) sl.setSpacing(0) # EQ label and kill buttons eq_header = QHBoxLayout() eq_lbl = QLabel("EQ / MIX") eq_lbl.setStyleSheet("font-size:9px; font-family:'Courier New'; letter-spacing:2px; color:#334455;") eq_header.addWidget(eq_lbl) sl.addLayout(eq_header) row_s = QHBoxLayout() row_s.setSpacing(4) def make_slider(prop, label, tooltip, color_override=None): v = QVBoxLayout() v.setSpacing(1) s = QSlider(Qt.Orientation.Vertical) s.setRange(0, MAX_SLIDER_VALUE) s.setValue(MAX_SLIDER_VALUE) s.setProperty("eq", prop) s.setFixedWidth(18) s.setMinimumHeight(70) s.setToolTip(tooltip) lc = color_override or "#778899" l = QLabel(label) l.setAlignment(Qt.AlignmentFlag.AlignCenter) l.setStyleSheet(f"font-size:8px; font-family:'Courier New'; color:{lc}; letter-spacing:1px;") v.addWidget(s, 1, Qt.AlignmentFlag.AlignHCenter) v.addWidget(l) row_s.addLayout(v) return s self.sl_vol = make_slider("vol", "LEV", "Volume level", "#aabbcc") self.sl_vol.valueChanged.connect(self.recalc_vol) self.sl_hi = make_slider("high", "HI", "High EQ (treble) — visual trim only", "#00e5ff") self.sl_mid = make_slider("mid", "MID", "Mid EQ — visual trim only", "#00ff88") self.sl_low = make_slider("low", "LO", "Low EQ (bass) — visual trim only", "#ff3344") # Apply initial volume so audio_output is in the correct state immediately self.recalc_vol() sl.addLayout(row_s) if self.deck_id == "A": bottom.addWidget(qc, 3) bottom.addWidget(sc, 1) else: bottom.addWidget(sc, 1) bottom.addWidget(qc, 3) layout.addLayout(bottom, 1) self.setLayout(layout) def _on_bpm_detected(self, bpm_val): """Received from waveform worker thread; update BPM display.""" self.detected_bpm = bpm_val effective = bpm_val * (self.sl_rate.value() / 100.0) self.lbl_bpm.setText(str(int(round(effective)))) def _hotcue_action(self, idx): """Set or jump-to hot cue. Shift-click to clear.""" mods = QApplication.keyboardModifiers() if mods & Qt.KeyboardModifier.ShiftModifier: # Clear self.hot_cues[idx] = -1 btn = getattr(self, f"btn_hc{idx+1}") btn.setChecked(False) btn.setToolTip(f"Hot cue {idx+1}: unset - click to set") elif self.hot_cues[idx] == -1: # Set at current position pos = self.player.position() self.hot_cues[idx] = pos btn = getattr(self, f"btn_hc{idx+1}") btn.setChecked(True) m, s = divmod(pos // 1000, 60) btn.setToolTip(f"Hot cue {idx+1}: {m:02d}:{s:02d} - Shift+click to clear") else: # Jump to stored position self.player.setPosition(self.hot_cues[idx]) if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState: self.play() def _cue_action(self): """CUE: if stopped/paused set cue point; if playing jump to cue point.""" state = self.player.playbackState() if state == QMediaPlayer.PlaybackState.PlayingState: if self._cue_point >= 0: self.player.setPosition(self._cue_point) else: self._cue_point = self.player.position() pos = self._cue_point m, s = divmod(pos // 1000, 60) self.btn_cue.setToolTip(f"CUE point: {m:02d}:{s:02d}") # ── Queue / list helpers ─────────────────────────────────────────── def _queue_double_clicked(self, item): """Double-click on a queue item: load it into the deck immediately and remove it.""" path = item.data(Qt.ItemDataRole.UserRole) row = self.q_list.row(item) self.q_list.takeItem(row) if path: self.load_track(path) self.play() def delete_selected(self): for item in self.q_list.selectedItems(): self.q_list.takeItem(self.q_list.row(item)) def cycle_mode(self): self.playback_mode = (self.playback_mode + 1) % 3 modes = {0: "CONT", 1: "LOOP1", 2: "STOP"} self.b_mode.setText(modes[self.playback_mode]) self.b_mode.setProperty("mode", str(self.playback_mode)) self.b_mode.style().unpolish(self.b_mode) self.b_mode.style().polish(self.b_mode) def _effective_bpm(self): """Return the current effective BPM (detected or default) adjusted for pitch.""" base = self.detected_bpm if self.detected_bpm else DEFAULT_BPM rate = self.sl_rate.value() / 100.0 return base * rate if rate > 0 else base def set_loop(self, beats, btn): for x in self.loop_btns: x.setChecked(x == btn) if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState: # Uncheck all — loop can't be set while stopped/paused for x in self.loop_btns: x.setChecked(False) return effective_bpm = self._effective_bpm() ms_per_beat = MS_PER_MINUTE / (effective_bpm if effective_bpm > 0 else DEFAULT_BPM) self.loop_start = self.player.position() self.loop_end = self.loop_start + int(ms_per_beat * beats) self.loop_active = True self.loop_timer.start() self.wave.set_loop_region(True, self.loop_start, self.loop_end) def clear_loop(self): self.loop_active = False self.loop_timer.stop() self.wave.set_loop_region(False, 0, 0) for btn in self.loop_btns: btn.setChecked(False) if hasattr(self, 'btn_loop_out'): self.btn_loop_out.setChecked(False) if hasattr(self, 'btn_loop_in') and hasattr(self, '_alo_base_ss'): self.btn_loop_in.setStyleSheet(self._alo_base_ss) self._loop_in_pending = None # ── Auto-loop controls ─────────────────────────────────────────────── def loop_set_in(self): """Mark current playhead position as loop IN point.""" self._loop_in_pending = self.player.position() # Always apply from the base style so repeated IN clicks work correctly self.btn_loop_in.setStyleSheet( self._alo_base_ss.replace('color:#446655', 'color:#00ffaa') ) def loop_set_out(self, checked): """Mark current position as loop OUT and activate (or deactivate) the loop.""" if not checked: self.clear_loop() # also resets _loop_in_pending and btn_loop_in style return pos = self.player.position() start = self._loop_in_pending if self._loop_in_pending is not None else max(0, pos - 2000) if pos <= start: self.btn_loop_out.setChecked(False) return self.loop_start = start self.loop_end = pos self.loop_active = True self.loop_timer.start() self.wave.set_loop_region(True, self.loop_start, self.loop_end) self._loop_in_pending = None def loop_double(self): """Double the current loop length.""" if not self.loop_active: return self.loop_end = self.loop_start + (self.loop_end - self.loop_start) * 2 self.wave.set_loop_region(True, self.loop_start, self.loop_end) def loop_halve(self): """Halve the current loop length (minimum 10 ms).""" if not self.loop_active: return half = (self.loop_end - self.loop_start) // 2 if half < 10: return self.loop_end = self.loop_start + half # If playhead is already past the new end, snap it back if self.player.position() > self.loop_end: self.player.setPosition(int(self.loop_start)) self.wave.set_loop_region(True, self.loop_start, self.loop_end) def _roll_start(self): """Loop roll start: lock loop at current 1-beat length, save real position.""" ms_per_beat = MS_PER_MINUTE / (self._effective_bpm() or DEFAULT_BPM) self._roll_ref_pos = self.player.position() self._roll_start_time = time.time() self.loop_start = self._roll_ref_pos self.loop_end = self._roll_ref_pos + int(ms_per_beat) self.loop_active = True self.loop_timer.start() self.wave.set_loop_region(True, self.loop_start, self.loop_end) def _roll_end(self): """Loop roll end: clear loop, resume from where track would have been.""" if self._roll_ref_pos is None: self.clear_loop() return # The player position loops during the roll, so we use wall-clock elapsed # time to calculate where the track should actually be elapsed_ms = int((time.time() - self._roll_start_time) * 1000) if self._roll_start_time else 0 raw_pos = self._roll_ref_pos + elapsed_ms # Clamp to track duration so we never seek past the end and trigger an # unexpected EndOfMedia / auto-advance. duration = self.real_duration if self.real_duration > 0 else (self.player.duration() or 0) real_pos = min(raw_pos, max(0, duration - 100)) if duration > 0 else raw_pos self._roll_ref_pos = None self._roll_start_time = None self.clear_loop() if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: self.player.setPosition(int(real_pos)) def check_loop(self): if self.loop_active and self.player.position() >= self.loop_end: self.player.setPosition(int(self.loop_start)) def load_track(self, path): if not path: return p = Path(path) if not p.exists(): print(f"[ERROR] Track path does not exist: {p}") return try: self.player.setSource(QUrl.fromLocalFile(str(p.absolute()))) self.current_title = p.stem self.current_file_path = str(p.absolute()) self.lbl_tr.setText(p.stem.upper()) self.vinyl.set_speed(0) self.vinyl.angle = 0 self.vinyl.update() # Clear hot cues from the previous track for idx in range(4): self.hot_cues[idx] = -1 btn = getattr(self, f"btn_hc{idx+1}") btn.setChecked(False) btn.setToolTip(f"Hot cue {idx+1}: unset - click to set") # Reset CUE point self._cue_point = -1 self.btn_cue.setToolTip("Set/jump to cue point") self.wave.generate_wave(p) # Kick off BPM detection in background (updates lbl_bpm via signal) self.detected_bpm = None self.lbl_bpm.setText(str(DEFAULT_BPM)) self.wave.detect_bpm(str(p.absolute())) # Find parent DJApp to show status parent = self.window() if hasattr(parent, 'status_label'): parent.status_label.setText(f"Loaded: {p.name}") # Use soundfile to get accurate duration (GStreamer/Qt6 can be wrong) try: info = sf.info(str(p.absolute())) self.real_duration = int(info.duration * 1000) print(f"[DEBUG] {self.deck_id} Real Duration: {self.real_duration}ms") # Update UI immediately if possible, or wait for player durationChanged self.wave.set_duration(self.real_duration) except Exception as se: print(f"[DEBUG] Could not get accurate duration with soundfile: {se}") self.real_duration = 0 except Exception as e: print(f"[ERROR] Failed to load track {p}: {e}") self.lbl_tr.setText("LOAD ERROR") def add_queue(self, path): p = Path(path) item = QListWidgetItem(p.name) item.setData(Qt.ItemDataRole.UserRole, p) self.q_list.addItem(item) def check_queue(self, status): if status == QMediaPlayer.MediaStatus.EndOfMedia: # Premature EndOfMedia can occur with VBR MP3s (GStreamer) or after a # setSource() on some Qt6 builds. If we know the real duration and the # player is nowhere near the end, ignore it entirely instead of stopping. if self.real_duration > 0 and self.player.position() < self.real_duration - 1000: print(f"[DEBUG] {self.deck_id} Premature EndOfMedia at {self.player.position()}ms " f"(expected {self.real_duration}ms) — ignoring") return if self.playback_mode == 1: # Loop 1 mode self.player.setPosition(0) self.play() elif self.playback_mode == 0 and self.q_list.count() > 0: # Continuous mode - load next from queue next_item = self.q_list.takeItem(0) self.load_track(next_item.data(Qt.ItemDataRole.UserRole)) self.play() else: # Stop mode or no queue items self.stop() def _emit_playing_state(self, is_playing): """Emit deck_glow and now_playing events to the listener page.""" mw = self.window() if not hasattr(mw, 'streaming_worker') or not hasattr(mw, 'deck_a') or not hasattr(mw, 'deck_b'): return other = mw.deck_b if self.deck_id == 'A' else mw.deck_a other_playing = (other.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState) a_playing = is_playing if self.deck_id == 'A' else other_playing b_playing = is_playing if self.deck_id == 'B' else other_playing mw.streaming_worker.emit_if_connected('deck_glow', {'A': a_playing, 'B': b_playing}) if is_playing and self.current_title: mw.streaming_worker.emit_if_connected('now_playing', { 'title': self.current_title, 'deck': self.deck_id, }) def play(self): self.player.play() # Ensure Qt's sink-input is routed to the virtual sink immediately. # Qt6's PipeWire-native backend ignores PULSE_SINK; pactl move-sink-input # works regardless of which backend is active. QTimer.singleShot(400, _route_qt_audio_to_virtual_sink) self.vinyl.start_spin() self._emit_playing_state(True) self.level_meter.update_playback( self.player.position(), self.wave.duration, True ) # Stream the file directly to listeners (reliable regardless of audio backend) mw = self.window() if hasattr(mw, 'streaming_worker') and self.current_file_path: mw.streaming_worker.switch_file( self.current_file_path, self.player.position(), frozenset(), ) def pause(self): self.player.pause() self.vinyl.stop_spin() self._emit_playing_state(False) self.level_meter.update_playback( self.player.position(), self.wave.duration, False ) mw = self.window() if hasattr(mw, 'streaming_worker'): mw.streaming_worker.stop_file() def stop(self): self.player.stop() self.vinyl.stop_spin() self.vinyl.angle = 0 self.vinyl.update() self.clear_loop() self._emit_playing_state(False) self.level_meter.update_playback(0, 1, False) mw = self.window() if hasattr(mw, 'streaming_worker'): mw.streaming_worker.stop_file() def on_position_changed(self, pos): self.wave.set_position(pos) is_playing = ( self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState ) self.level_meter.update_playback(pos, self.wave.duration, is_playing) minutes = int(pos // MS_PER_MINUTE) seconds = int((pos // 1000) % 60) self.lbl_cur.setText(f"{minutes:02}:{seconds:02}") def on_duration_changed(self, duration): # Use our accurate duration if available, otherwise fallback to player's reported duration final_duration = self.real_duration if self.real_duration > 0 else duration self.wave.set_duration(final_duration) minutes = int(final_duration // MS_PER_MINUTE) seconds = int((final_duration // 1000) % 60) self.lbl_tot.setText(f"{minutes:02}:{seconds:02}") def update_playback_rate(self, value): rate = value / 100.0 self.player.setPlaybackRate(rate) self.lbl_rate.setText(f"{rate:.2f}x") self.vinyl.set_speed(rate) # Update displayed BPM based on detected BPM (or DEFAULT_BPM) scaled by pitch base = self.detected_bpm if self.detected_bpm else DEFAULT_BPM self.lbl_bpm.setText(str(int(round(base * rate)))) def set_xf_vol(self, volume): self.xf_vol = volume self.recalc_vol() def recalc_vol(self): # Volume is controlled only by the LEV fader and the crossfader. # The HI/MID/LO sliders are visual EQ trims; they do not affect # QMediaPlayer output volume (QMediaPlayer has no EQ API). final = (self.xf_vol / 100.0) * (self.sl_vol.value() / MAX_SLIDER_VALUE) self.audio_output.setVolume(final) class DJApp(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("TechDJ Pro - Neon Edition") self.resize(1280, 980) self.setStyleSheet(STYLESHEET) # Set window icon icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dj_icon.png") if os.path.exists(icon_path): self.setWindowIcon(QIcon(icon_path)) self.neon_state = 0 # --- LOCAL FOLDER SETUP --- # Creates a folder named 'dj_tracks' inside your project directory self.lib_path = Path(os.getcwd()) / "music" if not self.lib_path.exists(): try: self.lib_path.mkdir(exist_ok=True) print(f"[INIT] Created library folder: {self.lib_path}") except Exception as e: print(f"[ERROR] Could not create library folder: {e}") # Create recordings folder self.recordings_path = Path(os.getcwd()) / "recordings" if not self.recordings_path.exists(): try: self.recordings_path.mkdir(exist_ok=True) print(f"[INIT] Created recordings folder: {self.recordings_path}") except Exception as e: print(f"[ERROR] Could not create recordings folder: {e}") self.search_worker = YTSearchWorker() self.search_worker.results_ready.connect(self.on_search_results) self.search_worker.error_occurred.connect(self.on_error) self.download_worker = YTDownloadWorker() self.download_worker.download_finished.connect(self.on_download_complete) self.download_worker.error_occurred.connect(self.on_error) self.download_worker.download_progress.connect(self.update_download_progress) # Recording setup self.recording_worker = RecordingWorker() self.recording_worker.recording_error.connect(self.on_recording_error) self.is_recording = False self.recording_start_time = 0 self.recording_timer = QTimer() self.recording_timer.timeout.connect(self.update_recording_time) # Streaming setup self.streaming_worker = StreamingWorker() self.streaming_worker.streaming_error.connect(self.on_streaming_error) self.streaming_worker.listener_count.connect(self.update_listener_count) self.is_streaming = False # Periodically ensure Qt's audio sink-inputs are routed to the virtual # sink. Qt6 may use the PipeWire-native audio backend which ignores # PULSE_SINK, causing the streaming ffmpeg to capture silence instead # of the DJ's music. pactl move-sink-input fixes this regardless of # which Qt audio backend is active. self._audio_route_timer = QTimer() self._audio_route_timer.timeout.connect(_route_qt_audio_to_virtual_sink) self._audio_route_timer.start(12000) # run every 12 s — reduces subprocess overhead # Also fire once shortly after startup so Qt's first audio stream is routed # before the 12-second interval elapses (relevant for PipeWire-native backend). QTimer.singleShot(1500, _route_qt_audio_to_virtual_sink) # Server library state self.server_url = "http://localhost:5000" self.library_mode = "local" # "local" or "server" self.server_library = [] self.local_library = [] self.cache_dir = Path.home() / ".techdj_cache" self.cache_dir.mkdir(exist_ok=True) self.download_threads = {} self.init_ui() self.setup_keyboard_shortcuts() self.apply_ui_settings() # Apply saved UI settings # Filtering debounce timer self.filter_timer = QTimer() self.filter_timer.setSingleShot(True) self.filter_timer.timeout.connect(self.perform_filter) self.load_library() def init_ui(self): main = QWidget() main.setObjectName("Central") self.setCentralWidget(main) layout = QVBoxLayout(main) layout.setContentsMargins(12, 10, 12, 10) layout.setSpacing(6) # Create glow overlay self.glow_frame = GlowFrame(main) self.glow_frame.setGeometry(main.rect()) self.glow_frame.raise_() # ── Top bar: app title + YouTube search ────────────────────────── h = QHBoxLayout() h.setSpacing(6) title_lbl = QLabel("[ TECHDJ PRO ]") title_lbl.setStyleSheet( "color:#00e5ff; font-family:'Courier New'; font-size:14px; " "font-weight:bold; letter-spacing:3px;" ) self.neon_button = QPushButton("NEON: OFF") self.neon_button.setObjectName("btn_neon") self.neon_button.setFixedWidth(110) self.neon_button.setToolTip("Toggle neon border glow effect") self.neon_button.clicked.connect(self.toggle_neon) self.yt_input = QLineEdit() self.yt_input.setPlaceholderText("Search YouTube or paste URL...") self.yt_input.setToolTip("Search YouTube with keywords or paste a YouTube/YT Music URL to download directly") self.yt_input.returnPressed.connect(self.search_youtube) self.format_selector = QComboBox() self.format_selector.addItem("MP3", "mp3") self.format_selector.addItem("Best Quality", "best") self.format_selector.setCurrentIndex(0) self.format_selector.setToolTip("Download format: MP3 (converted) or Best (faster, original)") self.format_selector.setFixedWidth(110) self.search_button = QPushButton("GO") self.search_button.setObjectName("btn_yt_go") self.search_button.setFixedWidth(54) self.search_button.setToolTip("Search / download from YouTube") self.search_button.clicked.connect(self.search_youtube) self.settings_btn = QPushButton("MAP") self.settings_btn.setFixedWidth(60) self.settings_btn.setToolTip("Keyboard shortcuts & settings") self.settings_btn.clicked.connect(self.open_settings) h.addWidget(title_lbl) h.addSpacing(6) h.addWidget(self.neon_button) h.addSpacing(6) h.addWidget(self.yt_input, 1) h.addWidget(self.format_selector) h.addWidget(self.search_button) h.addWidget(self.settings_btn) layout.addLayout(h) # Download progress bar self.download_progress_bar = QProgressBar() self.download_progress_bar.setRange(0, 100) self.download_progress_bar.setValue(0) self.download_progress_bar.setTextVisible(True) self.download_progress_bar.setFormat("DOWNLOADING %p%") self.download_progress_bar.setFixedHeight(14) self.download_progress_bar.setVisible(False) layout.addWidget(self.download_progress_bar) # Decks decks_layout = QHBoxLayout() self.deck_a = DeckWidget("Deck A", "#00ffff", "A") decks_layout.addWidget(self.deck_a) self.deck_b = DeckWidget("Deck B", "#ff00ff", "B") decks_layout.addWidget(self.deck_b) layout.addLayout(decks_layout, 70) # Crossfader section xf_outer = QWidget() xf_outer.setStyleSheet( "background: #04080f; border-top: 1px solid #0d1a26; border-bottom: 1px solid #0d1a26;" ) xf_vbox = QVBoxLayout(xf_outer) xf_vbox.setContentsMargins(40, 6, 40, 6) xf_vbox.setSpacing(2) xf_labels = QHBoxLayout() xf_a = QLabel("<< DECK A") xf_a.setStyleSheet("color:#00e5ff; font-family:'Courier New'; font-size:10px; letter-spacing:2px; font-weight:bold;") xf_b = QLabel("DECK B >>") xf_b.setStyleSheet("color:#ea00ff; font-family:'Courier New'; font-size:10px; letter-spacing:2px; font-weight:bold;") xf_b.setAlignment(Qt.AlignmentFlag.AlignRight) xf_center_lbl = QLabel("-- CROSSFADER --") xf_center_lbl.setStyleSheet( "color:#556677; font-family:'Courier New'; font-size:9px; letter-spacing:3px;" ) xf_center_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) xf_labels.addWidget(xf_a) xf_labels.addWidget(xf_center_lbl, 1) xf_labels.addWidget(xf_b) xf_vbox.addLayout(xf_labels) self.crossfader = QSlider(Qt.Orientation.Horizontal) self.crossfader.setObjectName("crossfader") self.crossfader.setRange(0, 100) self.crossfader.setValue(50) self.crossfader.setFixedHeight(46) self.crossfader.setToolTip("Crossfade between decks (Left = Deck A, Right = Deck B)") self.crossfader.valueChanged.connect(self.update_crossfade) xf_vbox.addWidget(self.crossfader) layout.addWidget(xf_outer) # ── Recording / Streaming / Glow bar ───────────────────────────── rec_layout = QHBoxLayout() rec_layout.setContentsMargins(30, 6, 30, 6) rec_layout.setSpacing(10) # REC button self.record_button = QPushButton("REC") self.record_button.setFixedSize(90, 44) self.record_button.setToolTip("Start/Stop recording your mix") self.record_button.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #1a0000, stop:1 #0d0000); color: #cc2222; border: 2px solid #440000; font-weight: bold; font-size: 13px; font-family: 'Courier New'; letter-spacing: 2px; } QPushButton:hover { background: #220000; border-color: #ff0000; color: #ff3333; } """) self.record_button.clicked.connect(self.toggle_recording) self.recording_timer_label = QLabel("00:00") self.recording_timer_label.setStyleSheet( "color: #334455; font-size: 22px; font-weight: bold; font-family: 'Courier New';" ) self.recording_timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.recording_timer_label.setFixedWidth(70) self.recording_status_label = QLabel("STANDBY") self.recording_status_label.setStyleSheet( "color: #223344; font-size: 10px; font-family: 'Courier New'; letter-spacing: 1px;" ) self.recording_status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) rec_info = QVBoxLayout() rec_info.setSpacing(1) rec_info.addWidget(self.recording_timer_label) rec_info.addWidget(self.recording_status_label) # Separator def _vline(): f = QFrame() f.setFrameShape(QFrame.Shape.VLine) f.setStyleSheet("color: #1a2233;") return f # LIVE button self.stream_button = QPushButton("LIVE") self.stream_button.setFixedSize(90, 44) self.stream_button.setToolTip("Start/Stop live streaming") self.stream_button.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #001528, stop:1 #000d18); color: #1a5588; border: 2px solid #0a2244; font-weight: bold; font-size: 13px; font-family: 'Courier New'; letter-spacing: 2px; } QPushButton:hover { background: #001a33; border-color: #0066cc; color: #3399ff; } """) self.stream_button.clicked.connect(self.toggle_streaming) self.stream_status_label = QLabel("OFFLINE") self.stream_status_label.setStyleSheet( "color: #223344; font-size: 10px; font-family: 'Courier New'; letter-spacing: 2px;" ) self.stream_status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.listener_count_label = QLabel("0 listeners") self.listener_count_label.setStyleSheet( "color: #1a3a55; font-size: 10px; font-weight: bold; font-family: 'Courier New';" ) self.listener_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) stream_info = QVBoxLayout() stream_info.setSpacing(1) stream_info.addWidget(self.stream_status_label) stream_info.addWidget(self.listener_count_label) # Listener Glow glow_title = QLabel("LISTENER GLOW") glow_title.setStyleSheet( "color: #7a22bb; font-size: 9px; font-weight: bold; " "font-family: 'Courier New'; letter-spacing: 2px;" ) glow_title.setAlignment(Qt.AlignmentFlag.AlignCenter) self.glow_slider = QSlider(Qt.Orientation.Horizontal) self.glow_slider.setRange(0, 100) self.glow_slider.setValue(30) self.glow_slider.setFixedWidth(120) self.glow_slider.setToolTip( "Listener page glow intensity\n0 = off | 100 = max\nSends to listeners in real-time" ) self.glow_slider.setStyleSheet(""" QSlider::groove:horizontal { border: 1px solid #330055; height: 6px; background: qlineargradient(x1:0,y1:0,x2:1,y2:0, stop:0 #0d0018, stop:1 #aa00ff); border-radius: 3px; } QSlider::handle:horizontal { background: #bb22ff; border: 2px solid #cc44ff; width: 14px; height: 14px; margin: -5px 0; border-radius: 7px; } QSlider::handle:horizontal:hover { background: #dd66ff; border-color: #fff; } """) self.glow_slider.valueChanged.connect(self.on_glow_slider_changed) self.glow_value_label = QLabel("30") self.glow_value_label.setStyleSheet("color: #7a22bb; font-size: 10px; font-family: 'Courier New';") self.glow_value_label.setAlignment(Qt.AlignmentFlag.AlignCenter) glow_vbox = QVBoxLayout() glow_vbox.setSpacing(2) glow_vbox.addWidget(glow_title) glow_vbox.addWidget(self.glow_slider, 0, Qt.AlignmentFlag.AlignHCenter) glow_vbox.addWidget(self.glow_value_label) # Status label (search/download feedback) self.status_label = QLabel("") self.status_label.setStyleSheet( "color:#00ff88; font-weight:bold; font-family:'Courier New'; font-size:11px;" ) self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) rec_layout.addStretch() rec_layout.addWidget(self.record_button) rec_layout.addSpacing(4) rec_layout.addLayout(rec_info) rec_layout.addWidget(_vline()) rec_layout.addWidget(self.stream_button) rec_layout.addSpacing(4) rec_layout.addLayout(stream_info) rec_layout.addWidget(_vline()) rec_layout.addLayout(glow_vbox) rec_layout.addWidget(_vline()) rec_layout.addWidget(self.status_label) rec_layout.addStretch() layout.addLayout(rec_layout) # Library section library_group = QGroupBox("TRACK LIBRARY") library_group.setStyleSheet( "QGroupBox { border: 1px solid #0d1a26; border-radius:4px; margin-top:10px; }" "QGroupBox::title { color:#445566; font-size:10px; letter-spacing:3px; subcontrol-origin:margin; left:10px; }" ) lib_layout = QVBoxLayout(library_group) lib_layout.setSpacing(4) lib_layout.setContentsMargins(6, 10, 6, 6) button_row = QHBoxLayout() button_row.setSpacing(4) self.local_mode_btn = QPushButton("LOCAL") self.local_mode_btn.setObjectName("btn_lib_local") self.local_mode_btn.setCheckable(True) self.local_mode_btn.setChecked(True) self.local_mode_btn.clicked.connect(lambda: self.set_library_mode("local")) self.server_mode_btn = QPushButton("SERVER") self.server_mode_btn.setObjectName("btn_lib_server") self.server_mode_btn.setCheckable(True) self.server_mode_btn.clicked.connect(lambda: self.set_library_mode("server")) refresh_btn = QPushButton("REFRESH") refresh_btn.setToolTip("Rescan library folder for audio files") refresh_btn.clicked.connect(self.load_library) upload_btn = QPushButton("UPLOAD") upload_btn.setToolTip("Upload track to library") upload_btn.clicked.connect(self.upload_track) load_a_btn = QPushButton("LOAD A") load_a_btn.setToolTip("Load selected track to Deck A (Ctrl+L)") load_a_btn.clicked.connect(lambda: self.load_to_deck(self.deck_a)) queue_a_btn = QPushButton("Q A+") queue_a_btn.setToolTip("Add selected track to Deck A queue (Ctrl+Shift+L)") queue_a_btn.clicked.connect(lambda: self.queue_to_deck(self.deck_a)) load_b_btn = QPushButton("LOAD B") load_b_btn.setToolTip("Load selected track to Deck B (Ctrl+R)") load_b_btn.clicked.connect(lambda: self.load_to_deck(self.deck_b)) queue_b_btn = QPushButton("Q B+") queue_b_btn.setToolTip("Add selected track to Deck B queue (Ctrl+Shift+R)") queue_b_btn.clicked.connect(lambda: self.queue_to_deck(self.deck_b)) for btn in [self.local_mode_btn, self.server_mode_btn, refresh_btn, upload_btn, load_a_btn, queue_a_btn, load_b_btn, queue_b_btn]: button_row.addWidget(btn) lib_layout.addLayout(button_row) self.search_filter = QLineEdit() self.search_filter.setPlaceholderText("Filter...") self.search_filter.setToolTip("Filter library by filename") self.search_filter.textChanged.connect(self.filter_library) lib_layout.addWidget(self.search_filter) self.library_list = QListWidget() self.library_list.setObjectName("main_lib") lib_layout.addWidget(self.library_list) layout.addWidget(library_group, 25) # Initialize crossfade self.update_crossfade() def setup_keyboard_shortcuts(self): # Clear existing shortcuts if any if hasattr(self, '_shortcuts'): for s in self._shortcuts: s.setParent(None) self._shortcuts = [] # Mapping actions to methods mapping = { "Deck A: Load": lambda: self.load_to_deck(self.deck_a), "Deck A: Queue": lambda: self.queue_to_deck(self.deck_a), "Deck A: Play/Pause": lambda: self.deck_a.play() if self.deck_a.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState else self.deck_a.pause(), "Deck B: Load": lambda: self.load_to_deck(self.deck_b), "Deck B: Queue": lambda: self.queue_to_deck(self.deck_b), "Deck B: Play/Pause": lambda: self.deck_b.play() if self.deck_b.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState else self.deck_b.pause(), "Global: Focus Search": lambda: self.search_filter.setFocus(), "Global: Toggle Library": lambda: self.set_library_mode("server" if self.library_mode == "local" else "local"), } # Default shortcuts self.default_shortcuts = { "Deck A: Load": "Ctrl+L", "Deck A: Queue": "Ctrl+Shift+L", "Deck A: Play/Pause": "Space", "Deck B: Load": "Ctrl+R", "Deck B: Queue": "Ctrl+Shift+R", "Deck B: Play/Pause": "Ctrl+Space", "Global: Focus Search": "Ctrl+F", "Global: Toggle Library": "Ctrl+Tab", } # Load all settings from file self.settings_file = Path(os.getcwd()) / "settings.json" self.all_settings = self.load_all_settings() self.current_shortcuts = self.all_settings.get("shortcuts", self.default_shortcuts) # Create shortcuts for action, key in self.current_shortcuts.items(): if action in mapping: sc = QShortcut(QKeySequence(key), self) sc.activated.connect(mapping[action]) self._shortcuts.append(sc) def load_all_settings(self): """Load all settings from settings.json""" default_settings = { "shortcuts": self.default_shortcuts if hasattr(self, 'default_shortcuts') else {}, "audio": { "recording_sample_rate": 48000, "recording_format": "wav", }, "ui": { "neon_mode": 0, }, "library": { "auto_scan": True, "yt_default_format": "mp3", } } if self.settings_file.exists(): try: with open(self.settings_file, "r") as f: loaded = json.load(f) # Merge with defaults to ensure all keys exist for key in default_settings: if key not in loaded: loaded[key] = default_settings[key] return loaded except Exception as e: print(f"[SETTINGS] Error loading: {e}") return default_settings return default_settings def apply_ui_settings(self): """Apply UI settings from loaded settings""" ui_settings = self.all_settings.get("ui", {}) neon_mode = ui_settings.get("neon_mode", 0) # Apply neon mode if neon_mode != self.neon_state: for _ in range(neon_mode): self.toggle_neon() # Apply library settings library_settings = self.all_settings.get("library", {}) yt_default = library_settings.get("yt_default_format", "mp3") self.format_selector.setCurrentIndex(0 if yt_default == "mp3" else 1) def open_settings(self): dialog = SettingsDialog(self.all_settings, self) if dialog.exec(): # Get all updated settings self.all_settings = dialog.get_all_settings() self.current_shortcuts = self.all_settings["shortcuts"] # Save all settings try: with open(self.settings_file, "w") as f: json.dump(self.all_settings, f, indent=4) except Exception as e: QMessageBox.warning(self, "Settings Warning", f"Settings applied but could not be saved to disk:\n{e}") # Still apply in-memory settings even if file write failed self.setup_keyboard_shortcuts() self.apply_ui_settings() return # Re-setup shortcuts self.setup_keyboard_shortcuts() # Apply UI settings self.apply_ui_settings() QMessageBox.information(self, "Settings Saved", "All settings have been updated!") def search_youtube(self): query = self.yt_input.text().strip() if not query: return # Check if it's a direct URL if "youtube.com/" in query or "youtu.be/" in query or "music.youtube.com/" in query: # Direct Download mode selected_format = self.format_selector.currentData() self.status_label.setText("Downloading...") # Show and reset progress bar self.download_progress_bar.setValue(0) self.download_progress_bar.setVisible(True) self.download_worker.download(query, str(self.lib_path), selected_format) self.yt_input.clear() else: # Keyword Search mode self.status_label.setText("Searching...") self.search_button.setEnabled(False) self.search_worker.search(query) def on_search_results(self, results): self.status_label.setText("") self.search_button.setEnabled(True) dialog = YTResultDialog(results, self) if dialog.exec(): url = dialog.get_selected_url() if url: # Get selected format from dropdown selected_format = self.format_selector.currentData() self.status_label.setText("Downloading...") # Show and reset progress bar self.download_progress_bar.setValue(0) self.download_progress_bar.setVisible(True) self.download_worker.download(url, str(self.lib_path), selected_format) def update_download_progress(self, percentage): """Update download progress bar""" self.download_progress_bar.setValue(int(percentage)) def on_download_complete(self, filepath): self.status_label.setText("Done!") self.download_progress_bar.setVisible(False) # Hide progress bar self.load_library() QMessageBox.information(self, "Download Complete", f"Saved: {os.path.basename(filepath)}") self.status_label.setText("") def on_error(self, error_msg): self.status_label.setText("Error") self.search_button.setEnabled(True) self.download_progress_bar.setVisible(False) # Hide progress bar on error QMessageBox.critical(self, "Error", error_msg) def toggle_neon(self): self.neon_state = (self.neon_state + 1) % 3 colors = {0: "#334455", 1: "#00e5ff", 2: "#ea00ff"} color = colors[self.neon_state] labels = ["OFF", "CYAN", "PURPLE"] self.neon_button.setText(f"NEON: {labels[self.neon_state]}") self.neon_button.setStyleSheet(f"color: {color}; border: 1px solid {color}44;") if self.neon_state == 0: self.glow_frame.set_glow(False) else: self.glow_frame.set_glow(True, color) self.centralWidget().setStyleSheet("QWidget#Central { border: none; }") def set_library_mode(self, mode): self.library_mode = mode self.local_mode_btn.setChecked(mode == "local") self.server_mode_btn.setChecked(mode == "server") self.load_library() def load_library(self): if self.library_mode == "local": self.library_list.clear() self.library_list.addItem(f"Reading: {self.lib_path.name}...") self.library_scanner = LibraryScannerThread(self.lib_path) self.library_scanner.files_found.connect(self.populate_library) self.library_scanner.start() else: self.fetch_server_library() def fetch_server_library(self): self.library_list.clear() self.library_list.addItem("Fetching server library...") # Use the listener port (no auth required) for library / track data. # The DJ port requires a password session which the Qt client doesn't hold. listener_url = self.get_listener_base_url() self.server_url = self.get_server_base_url() # kept for socket/streaming self.fetcher = ServerLibraryFetcher(f"{listener_url}/library.json") self.fetcher.finished.connect( lambda tracks, err, success: self.on_server_library_fetched(tracks, listener_url, err, success) ) self.fetcher.start() def on_server_library_fetched(self, tracks, base_url, err, success): self.library_list.clear() if success: self.server_library = tracks self.populate_server_library(tracks, base_url) else: self.library_list.addItem(f"Error: {err}") def populate_server_library(self, tracks, base_url): self.library_list.clear() for track in tracks: item = QListWidgetItem(track['title']) # Store URL and title track_url = f"{base_url}/{track['file']}" item.setData(Qt.ItemDataRole.UserRole, {"url": track_url, "title": track['title'], "is_server": True}) self.library_list.addItem(item) def populate_library(self, files): self.library_list.clear() self.local_library = [] for file_path in files: item = QListWidgetItem(file_path.name) data = {"path": file_path, "title": file_path.stem, "is_server": False} item.setData(Qt.ItemDataRole.UserRole, data) self.library_list.addItem(item) self.local_library.append(data) def filter_library(self, filter_text): # Debounce the search to prevent UI freezing while typing self.filter_timer.start(250) def perform_filter(self): filter_text = self.search_filter.text().lower().strip() self.library_list.setUpdatesEnabled(False) for i in range(self.library_list.count()): item = self.library_list.item(i) is_match = not filter_text or filter_text in item.text().lower() item.setHidden(not is_match) self.library_list.setUpdatesEnabled(True) def load_to_deck(self, deck): item = self.library_list.currentItem() if item: data = item.data(Qt.ItemDataRole.UserRole) if data: if data.get("is_server"): self.load_server_track(deck, data) else: path = data.get("path") if path: deck.load_track(path) def load_server_track(self, deck, data): url = data.get("url") title = data.get("title") filename = os.path.basename(url) cache_path = self.cache_dir / filename if cache_path.exists(): deck.load_track(cache_path) else: self.status_label.setText(f"Downloading: {title}...") thread = DownloadThread(url, str(cache_path)) thread.finished.connect(lambda path, success: self.on_server_download_complete(deck, path, success)) thread.start() self.download_threads[filename] = thread def on_server_download_complete(self, deck, path, success): self.status_label.setText("Download complete") if success: deck.load_track(Path(path)) else: QMessageBox.warning(self, "Download Error", "Failed to download track from server") def queue_to_deck(self, deck): item = self.library_list.currentItem() if item: data = item.data(Qt.ItemDataRole.UserRole) if data: if data.get("is_server"): # For server queueing, we download first then queue url = data.get("url") filename = os.path.basename(url) cache_path = self.cache_dir / filename if cache_path.exists(): deck.add_queue(cache_path) else: thread = DownloadThread(url, str(cache_path)) thread.finished.connect(lambda path, success: deck.add_queue(Path(path)) if success else None) thread.start() self.download_threads[os.path.basename(url)] = thread else: path = data.get("path") if path: deck.add_queue(path) def upload_track(self): file_path, _ = QFileDialog.getOpenFileName(self, "Select Track to Upload", "", "Audio Files (*.mp3 *.wav *.m4a *.flac *.ogg)") if not file_path: return if self.library_mode == "local": filename = os.path.basename(file_path) # Check for duplicates in local_library if any(Path(track['path']).name.lower() == filename.lower() for track in self.local_library): QMessageBox.information(self, "Import Skipped", f"'{filename}' is already in your local library.") return # Copy to local music folder dest = self.lib_path / filename try: shutil.copy2(file_path, dest) self.status_label.setText(f"Imported: {filename}") self.load_library() except Exception as e: QMessageBox.warning(self, "Import Error", f"Failed to import file: {e}") else: # Upload to server filename = os.path.basename(file_path) # Check for duplicates in server_library if hasattr(self, 'server_library'): if any(track['file'].split('/')[-1].lower() == filename.lower() for track in self.server_library): QMessageBox.information(self, "Upload Skipped", f"'{filename}' already exists on the server.") return try: self.status_label.setText("Uploading to server...") base_url = self.get_server_base_url() password = self.all_settings.get("audio", {}).get("dj_panel_password", "") # Authenticate if a password is configured (same approach as StreamingWorker) session_cookie = None if password: session_cookie = StreamingWorker._get_auth_cookie(base_url, password) if not session_cookie: QMessageBox.warning(self, "Upload Error", "DJ panel authentication failed.\nCheck the password in Settings → Audio.") self.status_label.setText("Upload failed") return headers = {} if session_cookie: headers['Cookie'] = session_cookie with open(file_path, 'rb') as f: files = {'file': f} response = requests.post(f"{base_url}/upload", files=files, headers=headers, timeout=60) if response.status_code == 200: self.status_label.setText("Upload successful!") self.load_library() else: try: err = response.json().get('error', 'Unknown error') except: err = f"Server returned {response.status_code}" QMessageBox.warning(self, "Upload Error", f"Server error: {err}") self.status_label.setText("Upload failed") except Exception as e: QMessageBox.warning(self, "Upload Error", f"Failed to upload: {e}") self.status_label.setText("Upload error") def update_crossfade(self): value = self.crossfader.value() ratio = value / 100.0 # Cosine crossfade curve for smooth transition deck_a_vol = int(math.cos(ratio * 0.5 * math.pi) * 100) deck_b_vol = int(math.cos((1 - ratio) * 0.5 * math.pi) * 100) self.deck_a.set_xf_vol(deck_a_vol) self.deck_b.set_xf_vol(deck_b_vol) def toggle_recording(self): """Start or stop recording""" if not self.is_recording: from datetime import datetime audio_settings = self.all_settings.get("audio", {}) fmt = audio_settings.get("recording_format", "wav") sample_rate = audio_settings.get("recording_sample_rate", 48000) timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") filename = f"mix_{timestamp}.{fmt}" output_path = str(self.recordings_path / filename) if self.recording_worker.start_recording(output_path, fmt=fmt, sample_rate=sample_rate): self.is_recording = True self.recording_start_time = time.time() self.recording_timer.start(1000) self.record_button.setText("STOP REC") self.record_button.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #330000, stop:1 #1a0000); color: #ff2222; border: 2px solid #ff0000; font-weight: bold; font-size: 13px; font-family: 'Courier New'; letter-spacing: 2px; } QPushButton:hover { background: #440000; border-color: #ff4444; } """) self.recording_status_label.setText("[REC]") self.recording_status_label.setStyleSheet( "color: #ff2222; font-size: 10px; font-family: 'Courier New'; letter-spacing: 2px;" ) else: self.recording_worker.stop_recording() self.is_recording = False self.recording_timer.stop() self.record_button.setText("REC") self.record_button.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #1a0000, stop:1 #0d0000); color: #cc2222; border: 2px solid #440000; font-weight: bold; font-size: 13px; font-family: 'Courier New'; letter-spacing: 2px; } QPushButton:hover { background: #220000; border-color: #ff0000; color: #ff3333; } """) self.recording_timer_label.setText("00:00") self.recording_timer_label.setStyleSheet( "color: #334455; font-size: 22px; font-weight: bold; font-family: 'Courier New';" ) self.recording_status_label.setText("SAVED") self.recording_status_label.setStyleSheet( "color: #00ff88; font-size: 10px; font-family: 'Courier New'; letter-spacing: 1px;" ) def _reset_rec_status(): self.recording_status_label.setText("STANDBY") self.recording_status_label.setStyleSheet( "color: #223344; font-size: 10px; font-family: 'Courier New'; letter-spacing: 1px;" ) QTimer.singleShot(3000, _reset_rec_status) def update_recording_time(self): if self.is_recording: elapsed = int(time.time() - self.recording_start_time) minutes = elapsed // 60 seconds = elapsed % 60 self.recording_timer_label.setText(f"{minutes:02d}:{seconds:02d}") self.recording_timer_label.setStyleSheet( "color: #ff2222; font-size: 22px; font-weight: bold; font-family: 'Courier New';" ) def on_recording_error(self, error_msg): QMessageBox.critical(self, "Recording Error", error_msg) if self.is_recording: self.is_recording = False self.recording_timer.stop() self.record_button.setText("REC") self.recording_status_label.setText("ERROR") self.recording_status_label.setStyleSheet( "color: #ff0000; font-size: 10px; font-family: 'Courier New';" ) def toggle_streaming(self): if not self.is_streaming: base_url = self.get_server_base_url() bitrate = self.all_settings.get("audio", {}).get("bitrate", 128) password = self.all_settings.get("audio", {}).get("dj_panel_password", "") if self.streaming_worker.start_streaming(base_url, bitrate, password=password): self.is_streaming = True self.stream_button.setText("STOP") self.stream_button.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #002244, stop:1 #001122); color: #00ff88; border: 2px solid #0066cc; font-weight: bold; font-size: 13px; font-family: 'Courier New'; letter-spacing: 2px; } QPushButton:hover { background: #003366; border-color: #00ff88; } """) self.stream_status_label.setText("LIVE") self.stream_status_label.setStyleSheet( "color: #ff2222; font-size: 10px; font-family: 'Courier New'; letter-spacing: 2px; font-weight: bold;" ) else: self.streaming_worker.stop_streaming() self.is_streaming = False self.stream_button.setText("LIVE") self.stream_button.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #001528, stop:1 #000d18); color: #1a5588; border: 2px solid #0a2244; font-weight: bold; font-size: 13px; font-family: 'Courier New'; letter-spacing: 2px; } QPushButton:hover { background: #001a33; border-color: #0066cc; color: #3399ff; } """) self.stream_status_label.setText("OFFLINE") self.stream_status_label.setStyleSheet( "color: #223344; font-size: 10px; font-family: 'Courier New'; letter-spacing: 2px;" ) def on_streaming_error(self, error_msg): QMessageBox.critical(self, "Streaming Error", error_msg) if self.is_streaming: self.is_streaming = False self.stream_button.setText("LIVE") self.stream_status_label.setText("ERROR") self.stream_status_label.setStyleSheet( "color: #ff2222; font-size: 10px; font-family: 'Courier New';" ) def update_listener_count(self, count): self.listener_count_label.setText(f"{count} listener{'s' if count != 1 else ''}") self.listener_count_label.setStyleSheet( "color: #3399ff; font-size: 10px; font-weight: bold; font-family: 'Courier New';" ) def on_glow_slider_changed(self, val): """Send listener glow intensity to all connected listeners in real-time.""" self.glow_value_label.setText(str(val)) # Keep the worker's glow_intensity in sync so it's sent on reconnect too self.streaming_worker.glow_intensity = val self.streaming_worker.emit_if_connected('listener_glow', {'intensity': val}) def get_listener_base_url(self): """Return the listener server base URL (no auth required). The listener server runs on DJ_port + 1 by convention (default 5001). Library JSON and music_proxy routes are available there without any password, so we use this for all library / track-download requests. """ audio_settings = self.all_settings.get("audio", {}) raw = audio_settings.get("stream_server_url", "http://localhost:5000").strip() if not raw.startswith(("http://", "https://", "ws://", "wss://")): raw = "http://" + raw parsed = urlparse(raw) host = parsed.hostname or "localhost" dj_port = parsed.port or 5000 # Normalise: if the configured port IS the listener port, keep it; # otherwise derive listener port as DJ port + 1. if dj_port == 5001: listener_port = 5001 else: # Remap any reverse-proxy or listener port back to DJ port first, # then add 1 to get the listener port. if dj_port == 8080: dj_port = 5000 listener_port = dj_port + 1 return f"http://{host}:{listener_port}" def get_server_base_url(self): audio_settings = self.all_settings.get("audio", {}) raw = audio_settings.get("stream_server_url", "http://localhost:5000").strip() # Ensure scheme is present so urlparse parses host/port correctly if not raw.startswith(("http://", "https://", "ws://", "wss://")): raw = "http://" + raw parsed = urlparse(raw) host = parsed.hostname or "localhost" port = parsed.port or 5000 # Remap listener port and common reverse-proxy port to the DJ server port if port in (5001, 8080): port = 5000 # Always use plain HTTP — the server never terminates TLS directly. # Any path component (e.g. /stream.mp3, /api/stream) is intentionally stripped. return f"http://{host}:{port}" def resizeEvent(self, event): """Update glow frame size when window is resized""" super().resizeEvent(event) if hasattr(self, 'glow_frame'): self.glow_frame.setGeometry(self.centralWidget().rect()) def closeEvent(self, event): # Stop recording if active if self.is_recording: self.recording_worker.stop_recording() # Stop streaming if active if self.is_streaming: self.streaming_worker.stop_streaming() self.streaming_worker.wait(3000) # allow thread to finish cleanly self.deck_a.stop() self.deck_b.stop() self.search_worker.kill() self.download_worker.kill() event.accept() if __name__ == "__main__": # Create virtual sink BEFORE QApplication so Qt's audio output # is automatically routed to it via PULSE_SINK env var. _setup_audio_isolation() app = QApplication(sys.argv) app.setApplicationName("TechDJ Pro") app.setDesktopFileName("techdj-pro") window = DJApp() window.show() sys.exit(app.exec())