techdj/techdj_qt.py

3860 lines
156 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())