From 69c078071d53c29da5bb0427572773ad819b3d47 Mon Sep 17 00:00:00 2001 From: ComputerTech Date: Sun, 12 Apr 2026 13:01:44 +0100 Subject: [PATCH] Fix stream glitching, suppress ffmpeg noise, green progress bar, bug fixes - StreamingWorker: replace -re with token-bucket pacing; -ss before -i for fast seek; add -write_xing 0; halve chunk size to 2048 bytes. Eliminates startup burst and glitchy audio on listener side. - Suppress Qt6 internal ffmpeg AV_LOG_WARNING noise via ctypes av_log_set_level - Progress bar colour changed from blue to green - server.py: drain ffmpeg stderr pipe in transcoder to prevent deadlock - Fix waveform/BPM thread signal race (disconnect before replacing thread) - Fix _roll_end: clamp real_pos to track duration - Fix open_settings: wrap file write in try/except - Fix hot cue initial tooltip text - Remove src-tauri (Tauri desktop wrapper removed) --- requirements.txt | 1 + server.py | 14 + src-tauri/Cargo.toml | 21 - src-tauri/build.rs | 3 - src-tauri/capabilities/default.json | 11 - src-tauri/src/lib.rs | 147 -- src-tauri/src/main.rs | 6 - src-tauri/tauri.conf.json | 35 - techdj_qt.py | 2066 ++++++++++++++++++++------- 9 files changed, 1594 insertions(+), 710 deletions(-) delete mode 100644 src-tauri/Cargo.toml delete mode 100644 src-tauri/build.rs delete mode 100644 src-tauri/capabilities/default.json delete mode 100644 src-tauri/src/lib.rs delete mode 100644 src-tauri/src/main.rs delete mode 100644 src-tauri/tauri.conf.json diff --git a/requirements.txt b/requirements.txt index 8456aca..e440d45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ PyQt6 sounddevice soundfile numpy +librosa requests python-socketio[client] yt-dlp diff --git a/server.py b/server.py index 8dc178f..ddac0c6 100644 --- a/server.py +++ b/server.py @@ -193,6 +193,20 @@ def _start_transcoder_if_needed(is_mp3_input=False): # Define greenlets INSIDE so they close over THIS specific 'proc'. # Blocking subprocess pipe I/O is delegated to eventlet.tpool so it runs # in a real OS thread, preventing it from stalling the eventlet hub. + + def _stderr_drain(proc): + """Drain ffmpeg's stderr pipe so it never fills the OS buffer (64 KB on + Linux) and deadlocks the ffmpeg process. Errors are printed and logged.""" + try: + for raw in iter(proc.stderr.readline, b''): + line = raw.decode('utf-8', errors='replace').strip() + if line: + print(f'[FFMPEG] {line}') + except Exception: + pass + + eventlet.spawn(_stderr_drain, _ffmpeg_proc) + def _writer(proc): global _transcoder_last_error print(f"[THREAD] Transcoder writer started (PID: {proc.pid})") diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml deleted file mode 100644 index 4a0bc09..0000000 --- a/src-tauri/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "techdj" -version = "0.1.0" -edition = "2021" - -[build-dependencies] -tauri-build = { version = "2", features = [] } - -[dependencies] -tauri = { version = "2", features = [] } -tauri-plugin-fs = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" - -# Release optimisations — keep the binary small on the 4 GB machine -[profile.release] -panic = "abort" -codegen-units = 1 -lto = true -opt-level = "s" -strip = true diff --git a/src-tauri/build.rs b/src-tauri/build.rs deleted file mode 100644 index d860e1e..0000000 --- a/src-tauri/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - tauri_build::build() -} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json deleted file mode 100644 index 8a5b0f5..0000000 --- a/src-tauri/capabilities/default.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://schema.tauri.app/config/2/acl/capability.json", - "identifier": "default", - "description": "TechDJ default permissions — read-only access to home directory for local audio", - "windows": ["main"], - "permissions": [ - "core:default", - "fs:read-all", - "fs:scope-home-recursive" - ] -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs deleted file mode 100644 index 0490d1e..0000000 --- a/src-tauri/src/lib.rs +++ /dev/null @@ -1,147 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::fs; -use serde_json::{json, Value}; -use tauri::Manager; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn home_dir() -> PathBuf { - std::env::var("HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/home")) -} - -fn settings_file(app: &tauri::AppHandle) -> PathBuf { - app.path() - .app_local_data_dir() - .unwrap_or_else(|_| home_dir().join(".local/share/techdj")) - .join("settings.json") -} - -fn read_settings(app: &tauri::AppHandle) -> Value { - fs::read_to_string(settings_file(app)) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .unwrap_or_else(|| json!({})) -} - -// --------------------------------------------------------------------------- -// Tauri commands -// --------------------------------------------------------------------------- - -/// Returns the current music folder path stored in app settings. -/// Falls back to ~/Music if nothing is saved. -#[tauri::command] -fn get_music_folder(app: tauri::AppHandle) -> String { - let settings = read_settings(&app); - if let Some(s) = settings["music_folder"].as_str() { - if !s.is_empty() { - return s.to_string(); - } - } - home_dir().join("Music").to_string_lossy().into_owned() -} - -/// Persists the chosen music folder to the Tauri app-local settings file. -#[tauri::command] -fn save_music_folder(app: tauri::AppHandle, path: String) -> Value { - let mut settings = read_settings(&app); - settings["music_folder"] = json!(path); - let sf = settings_file(&app); - if let Some(parent) = sf.parent() { - let _ = fs::create_dir_all(parent); - } - match fs::write(&sf, serde_json::to_string_pretty(&settings).unwrap_or_default()) { - Ok(_) => json!({ "success": true }), - Err(e) => json!({ "success": false, "error": e.to_string() }), - } -} - -/// Recursively scans `music_dir` for supported audio files and returns an -/// array of `{ title, file, absolutePath }` objects — same shape as the -/// Flask `/library.json` endpoint so the front-end works without changes. -#[tauri::command] -fn scan_library(music_dir: String) -> Vec { - let mut tracks = Vec::new(); - let p = Path::new(&music_dir); - if p.is_dir() { - scan_dir(p, &mut tracks); - } - tracks -} - -fn scan_dir(dir: &Path, tracks: &mut Vec) { - let Ok(rd) = fs::read_dir(dir) else { return }; - let mut entries: Vec<_> = rd.flatten().collect(); - entries.sort_by_key(|e| e.file_name()); - for entry in entries { - let p = entry.path(); - if p.is_dir() { - scan_dir(&p, tracks); - } else if let Some(ext) = p.extension() { - let ext_lc = ext.to_string_lossy().to_lowercase(); - if matches!(ext_lc.as_str(), "mp3" | "m4a" | "wav" | "flac" | "ogg" | "aac") { - let title = p - .file_stem() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(); - let file_name = p - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(); - let abs = p.to_string_lossy().into_owned(); - tracks.push(json!({ - "title": title, - "file": format!("music_proxy/{}", file_name), - "absolutePath": abs, - })); - } - } - } -} - -/// Lists subdirectories at `path` — replaces the Flask `/browse_directories` -/// endpoint for the in-app folder picker. -#[tauri::command] -fn list_dirs(path: String) -> Value { - let dir = Path::new(&path); - let mut entries: Vec = Vec::new(); - - if let Some(parent) = dir.parent() { - entries.push(json!({ "name": "..", "path": parent.to_string_lossy(), "isDir": true })); - } - - if let Ok(rd) = fs::read_dir(dir) { - let mut dirs: Vec<_> = rd.flatten().filter(|e| e.path().is_dir()).collect(); - dirs.sort_by_key(|e| e.file_name()); - for d in dirs { - entries.push(json!({ - "name": d.file_name().to_string_lossy(), - "path": d.path().to_string_lossy(), - "isDir": true, - })); - } - } - - json!({ "success": true, "path": path, "entries": entries }) -} - -// --------------------------------------------------------------------------- -// App entry point -// --------------------------------------------------------------------------- - -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - tauri::Builder::default() - .plugin(tauri_plugin_fs::init()) - .invoke_handler(tauri::generate_handler![ - get_music_folder, - save_music_folder, - scan_library, - list_dirs, - ]) - .run(tauri::generate_context!()) - .expect("error while running TechDJ"); -} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs deleted file mode 100644 index e0fd7d0..0000000 --- a/src-tauri/src/main.rs +++ /dev/null @@ -1,6 +0,0 @@ -// Hides the console window on Windows release builds; harmless on Linux. -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -fn main() { - techdj_lib::run() -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json deleted file mode 100644 index b167baf..0000000 --- a/src-tauri/tauri.conf.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "TechDJ", - "version": "0.1.0", - "identifier": "dev.computertech.techdj", - "build": { - "frontendDist": "../" - }, - "app": { - "withGlobalTauri": true, - "windows": [ - { - "title": "TechDJ", - "width": 1280, - "height": 800, - "minWidth": 1024, - "minHeight": 600, - "decorations": true, - "fullscreen": false, - "resizable": true - } - ], - "security": { - "assetProtocol": { - "enable": true, - "scope": ["$HOME/**"] - } - } - }, - "bundle": { - "active": true, - "targets": ["deb"], - "icon": ["../icon.png"] - } -} diff --git a/techdj_qt.py b/techdj_qt.py index a915314..81eab5e 100644 --- a/techdj_qt.py +++ b/techdj_qt.py @@ -16,12 +16,36 @@ 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 @@ -30,16 +54,18 @@ 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, +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) + 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 +from PyQt6.QtGui import (QPainter, QColor, QPen, QBrush, QKeySequence, QIcon, + QRadialGradient, QPainterPath, QShortcut, + QLinearGradient, QPolygonF) # --- CONFIGURATION --- DEFAULT_BPM = 124 @@ -50,66 +76,359 @@ MS_PER_MINUTE = 60000 NUM_EQ_BANDS = 3 MAX_SLIDER_VALUE = 100 -STYLESHEET = """ -QMainWindow { background-color: #050505; } -QGroupBox { background-color: #0a0a0a; border-radius: 6px; margin-top: 10px; font-family: "Courier New"; } -QGroupBox#Deck_A { border: 2px solid #00ffff; } -QGroupBox#Deck_A::title { color: #00ffff; font-weight: bold; subcontrol-origin: margin; left: 10px; } -QGroupBox#Deck_B { border: 2px solid #ff00ff; } -QGroupBox#Deck_B::title { color: #ff00ff; font-weight: bold; subcontrol-origin: margin; left: 10px; } -QPushButton { background-color: #000; color: #fff; border: 1px solid #444; padding: 6px; font-weight: bold; border-radius: 4px; } -QPushButton:hover { background-color: #222; border: 1px solid #fff; } -QPushButton:pressed { background-color: #444; } -QPushButton#btn_neon { font-family: "Courier New"; margin-bottom: 5px; font-size: 12px; } -QPushButton#btn_yt_go { background-color: #cc0000; border: 1px solid #ff0000; color: white; font-weight: bold; } -QPushButton#btn_yt_go:hover { background-color: #ff0000; } -QPushButton#btn_remove { background-color: #330000; color: #ff0000; border: 1px solid #550000; padding: 0px; font-size: 10px; min-width: 20px; min-height: 20px; } -QPushButton#btn_remove:hover { background-color: #ff0000; color: #fff; border-color: #ff5555; } -QPushButton#btn_loop { background-color: #1a1a1a; color: #888; border: 1px solid #333; font-size: 11px; } -QPushButton#btn_loop:hover { border-color: #ffa500; color: #ffa500; } -QPushButton#btn_loop:checked { background-color: #ffa500; color: #000; border: 1px solid #ffcc00; } -QPushButton#btn_loop_exit { color: #ff3333; border: 1px solid #550000; font-size: 11px; } -QPushButton#btn_loop_exit:hover { background-color: #330000; border-color: #ff0000; } -QPushButton[mode="0"] { color: #00ff00; border-color: #005500; } -QPushButton[mode="1"] { color: #ffa500; border-color: #553300; } -QPushButton#btn_lib_local { color: #00ffff; border-color: #008888; } -QPushButton#btn_lib_local:checked { background-color: #00ffff; color: #000; font-weight: bold; } -QPushButton#btn_lib_server { color: #ff00ff; border-color: #880088; } -QPushButton#btn_lib_server:checked { background-color: #ff00ff; color: #000; font-weight: bold; } -QPushButton[mode="2"] { color: #ff0000; border-color: #550000; } -QLineEdit { background-color: #111; color: #fff; border: 1px solid #555; padding: 6px; font-family: "Courier New"; } -QLineEdit:focus { border: 1px solid #00ff00; } -QListWidget { background-color: #000; border: 1px solid #333; color: #888; font-family: "Courier New"; } -QListWidget::item:selected { background-color: #222; color: #fff; border: 1px solid #00ff00; } -QListWidget#queue_list::item:selected { background-color: #331111; color: #ffaaaa; border: 1px solid #550000; } -QSlider::groove:horizontal { border: 1px solid #333; height: 4px; background: #222; } -QSlider::handle:horizontal { background: #fff; border: 2px solid #fff; width: 14px; height: 14px; margin: -6px 0; border-radius: 8px; } -QSlider::groove:vertical { border: 1px solid #333; width: 6px; background: #111; border-radius: 3px; } -QSlider::handle:vertical { background: #ccc; border: 1px solid #fff; height: 14px; width: 14px; margin: 0 -5px; border-radius: 4px; } -QSlider::sub-page:vertical { background: #444; border-radius: 3px; } -QSlider::add-page:vertical { background: #222; border-radius: 3px; } -QSlider[eq="vol"]::handle:vertical { background: #fff; border: 1px solid #fff; } -QSlider[eq="high"]::handle:vertical { background: #00ffff; border: 1px solid #00ffff; } -QSlider[eq="mid"]::handle:vertical { background: #00ff00; border: 1px solid #00ff00; } -QSlider[eq="low"]::handle:vertical { background: #ff0000; border: 1px solid #ff0000; } -QSlider#crossfader::groove:horizontal { - border: 1px solid #777; - height: 16px; - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #00ffff, stop:0.5 #111, stop:1 #ff00ff); - border-radius: 8px; +# --- 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'], } -QSlider#crossfader::handle:horizontal { - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #eee, stop:1 #888); - border: 2px solid #fff; - width: 32px; - height: 36px; - margin: -11px 0; - border-radius: 6px; + +# 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 #aaa); - border-color: #00ff00; + 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 --- @@ -146,6 +465,33 @@ def _cleanup_stale_sinks(): 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. @@ -173,13 +519,16 @@ def _setup_audio_isolation(): return _audio_sink_module = r.stdout.strip() - # 2. Loopback → real speakers so the DJ hears the mix - r = subprocess.run( - ['pactl', 'load-module', 'module-loopback', - f'source={_AUDIO_MONITOR}', - 'latency_msec=50'], - capture_output=True, text=True, timeout=5, - ) + # 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: @@ -735,7 +1084,6 @@ class SettingsDialog(QDialog): action = self.shortcuts_table.item(row, 0).text() - from PyQt6.QtWidgets import QInputDialog 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 @@ -821,28 +1169,44 @@ class RecordingWorker(QProcess): self.output_file = "" self.readyReadStandardError.connect(self.handle_error) - def start_recording(self, output_path): - """Start recording this app's audio to file.""" + 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} (source={source})") - - args = [ - "-f", "pulse", - "-i", source, - "-ac", "2", - "-ar", "48000", - "-acodec", "pcm_s16le", - "-sample_fmt", "s16", - "-y", - output_path - ] - + 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 @@ -867,14 +1231,34 @@ class StreamingWorker(QThread): 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 with '-re' (real-time rate) 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. + 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 @@ -1004,25 +1388,32 @@ class StreamingWorker(QThread): except Exception: current_proc.kill() - _, file_path, position_ms = cmd + _, file_path, position_ms, active_fx = cmd position_secs = max(0.0, position_ms / 1000.0) - print(f"[STREAM] Streaming file: {file_path} from {position_secs:.1f}s") + 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", - "-re", # real-time output rate — prevents flooding the socket - "-ss", f"{position_secs:.3f}", # seek to playback position + "-ss", f"{position_secs:.3f}", "-i", file_path, - "-vn", # discard video (cover art etc.) + "-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, @@ -1034,7 +1425,12 @@ class StreamingWorker(QThread): target=self._drain_stderr, args=(current_proc,), daemon=True ).start() - # Inner read loop for this file + # 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: @@ -1056,7 +1452,7 @@ class StreamingWorker(QThread): self.ffmpeg_proc = None break - chunk = current_proc.stdout.read(4096) + chunk = current_proc.stdout.read(_sz) if not chunk: current_proc = None self.ffmpeg_proc = None @@ -1064,6 +1460,12 @@ class StreamingWorker(QThread): 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': @@ -1097,11 +1499,27 @@ class StreamingWorker(QThread): except Exception as e: print(f"[SOCKET] emit_if_connected error: {e}") - def switch_file(self, file_path, position_ms=0): + @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*. + start a new one reading *file_path* from *position_ms* with the given + effects filter chain. """ if not file_path: return @@ -1112,7 +1530,9 @@ class StreamingWorker(QThread): except queue.Empty: break try: - self._file_cmd_queue.put_nowait(('play', file_path, int(position_ms))) + self._file_cmd_queue.put_nowait(( + 'play', file_path, int(position_ms), frozenset(effects or []) + )) except queue.Full: pass @@ -1179,8 +1599,6 @@ class GlowFrame(QWidget): glow_width = 80 # Wider glow for more intensity # Draw glow from each edge using linear gradients - from PyQt6.QtGui import QLinearGradient - # Top edge glow top_gradient = QLinearGradient(0, 0, 0, glow_width) for i in range(6): @@ -1224,80 +1642,305 @@ class GlowFrame(QWidget): class VinylWidget(QWidget): def __init__(self, color_hex, parent=None): super().__init__(parent) - self.setMinimumSize(120, 120) + self.setMinimumSize(140, 140) self.angle = 0 self.speed = 1.0 self.is_spinning = False self.color = QColor(color_hex) - - # Initialize drawing resources - self.brush_disk = QBrush(QColor("#111")) - self.pen_disk = QPen(QColor("#000"), 2) - self.brush_label = QBrush(self.color) - self.brush_white = QBrush(Qt.GlobalColor.white) 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 - 5 + 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) - - # Draw vinyl disk - p.setBrush(self.brush_disk) - p.setPen(self.pen_disk) - p.drawEllipse(QPointF(0, 0), self.radius, self.radius) - - # Draw grooves - p.setBrush(Qt.BrushStyle.NoBrush) - p.setPen(QPen(QColor("#222"), 1)) - p.drawEllipse(QPointF(0, 0), self.radius * 0.8, self.radius * 0.8) - p.drawEllipse(QPointF(0, 0), self.radius * 0.6, self.radius * 0.6) - - # Draw center label - p.setBrush(self.brush_label) + + # 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), self.radius * 0.35, self.radius * 0.35) - - # Draw position marker - p.setBrush(self.brush_white) - p.drawRect(QRectF(-2, -self.radius * 0.35, 4, 12)) + 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(60) + self.setMinimumHeight(70) self.setCursor(Qt.CursorShape.PointingHandCursor) - + self.duration = 1 self.position = 0 self.wave_data = [] @@ -1305,80 +1948,145 @@ class WaveformWidget(QWidget): self.loop_start = 0 self.loop_end = 0 self.last_seek_time = 0 - - # Initialize drawing resources - self.brush_active = QBrush(self.color) - self.brush_inactive = QBrush(QColor("#444")) - self.pen_white = QPen(QColor("#fff"), 2) - self.loop_brush = QBrush(QColor(255, 165, 0, 100)) - self.loop_pen = QPen(QColor("#ffa500"), 2) - + 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): - random.seed(str(file_path)) - self.wave_data = [max(0.1, random.random()**2) for _ in range(250)] + """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 time.time() - self.last_seek_time > 0.1: + 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() - p.fillRect(0, 0, w, h, QColor("#111")) - + 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(self.pen_white) - p.drawLine(0, int(h / 2), w, int(h / 2)) + p.setPen(QPen(QColor(self.color), 1)) + p.drawLine(0, int(mid), w, int(mid)) return - - bar_width = w / len(self.wave_data) + + bar_w = w / len(self.wave_data) play_x = (self.position / self.duration) * w - p.setPen(Qt.PenStyle.NoPen) - - # Draw waveform bars - for i, val in enumerate(self.wave_data): - brush = self.brush_active if i * bar_width < play_x else self.brush_inactive - p.setBrush(brush) - bar_height = val * h * 0.9 - p.drawRect(QRectF(i * bar_width, (h - bar_height) / 2, bar_width, bar_height)) - - # Draw loop region + + # 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: - loop_x = (self.loop_start / self.duration) * w - loop_width = ((self.loop_end - self.loop_start) / self.duration) * w - p.setBrush(self.loop_brush) - p.drawRect(QRectF(loop_x, 0, loop_width, h)) - p.setPen(self.loop_pen) - p.drawLine(int(loop_x), 0, int(loop_x), h) - p.drawLine(int(loop_x + loop_width), 0, int(loop_x + loop_width), h) - - # Draw playhead - p.setPen(self.pen_white) - p.drawLine(int(play_x), 0, int(play_x), h) + 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): @@ -1391,6 +2099,10 @@ class DeckWidget(QGroupBox): 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 @@ -1411,196 +2123,420 @@ class DeckWidget(QGroupBox): def setup_ui(self): layout = QVBoxLayout() - layout.setSpacing(5) - - # Top row: Vinyl and track info + 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}; " - f"background: #000; padding: 4px;" + 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("color:#fff") - self.lbl_tot = QLabel("00:00") - self.lbl_tot.setStyleSheet("color:#fff") + 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) - r1.addLayout(c1) + + # 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 + + # ── 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) - - # Loop buttons - g = QGridLayout() + + # ── 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) + 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)) - layout.addLayout(g) - - # Playback controls + 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.setToolTip("Play track") + 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) - - self.b_mode = QPushButton("MODE: CONT") - self.b_mode.setFixedWidth(100) + + # 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(10) + rc.addSpacing(6) + rc.addWidget(self.btn_cue) + rc.addStretch() rc.addWidget(self.b_mode) layout.addLayout(rc) - - # Pitch control + + # ── 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") + self.sl_rate.setToolTip("Adjust playback speed / pitch (50%-150%)") self.sl_rate.valueChanged.connect(self.update_playback_rate) - - br = QPushButton("R") + + 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)) - - self.lbl_rate = QLabel("1.0x") - self.lbl_rate.setStyleSheet("color:#fff; font-weight:bold;") - - rp.addWidget(QLabel("PITCH", styleSheet="color:#666")) + + rp.addWidget(pitch_lbl) rp.addWidget(self.sl_rate) rp.addWidget(br) rp.addWidget(self.lbl_rate) layout.addLayout(rp) - - # Bottom section: Queue and EQ + + # ── 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() - hq.addWidget(QLabel(f"QUEUE {self.deck_id}", styleSheet="font-size:10px; color:#666")) + 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( - lambda i: self.q_list.takeItem(self.q_list.row(i)) - ) + 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() - - def make_slider(prop, label, tooltip): + 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) - s.valueChanged.connect(self.recalc_vol) + lc = color_override or "#778899" l = QLabel(label) l.setAlignment(Qt.AlignmentFlag.AlignCenter) - l.setStyleSheet("font-size:8px; color:#aaa;") + 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") - self.sl_hi = make_slider("high", "HI", "High frequencies (treble)") - self.sl_mid = make_slider("mid", "MID", "Mid frequencies") - self.sl_low = make_slider("low", "LO", "Low frequencies (bass)") - + + 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: "LOOP 1", 2: "STOP"} - self.b_mode.setText(f"MODE: {modes[self.playback_mode]}") + 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 - - ms_per_beat = MS_PER_MINUTE / DEFAULT_BPM + + 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 @@ -1613,6 +2549,88 @@ class DeckWidget(QGroupBox): 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: @@ -1635,7 +2653,22 @@ class DeckWidget(QGroupBox): 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'): @@ -1663,10 +2696,14 @@ class DeckWidget(QGroupBox): def check_queue(self, status): if status == QMediaPlayer.MediaStatus.EndOfMedia: - # Premature EndOfMedia is common with GStreamer + VBR MP3s + # 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 (expected {self.real_duration}ms)") - + 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) @@ -1698,17 +2735,31 @@ class DeckWidget(QGroupBox): 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()) + 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() @@ -1720,12 +2771,17 @@ class DeckWidget(QGroupBox): 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}") @@ -1741,27 +2797,28 @@ class DeckWidget(QGroupBox): def update_playback_rate(self, value): rate = value / 100.0 self.player.setPlaybackRate(rate) - self.lbl_rate.setText(f"{rate:.1f}x") + 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): - eq_hi = self.sl_hi.value() / MAX_SLIDER_VALUE - eq_mid = self.sl_mid.value() / MAX_SLIDER_VALUE - eq_low = self.sl_low.value() / MAX_SLIDER_VALUE - eq_gain = eq_hi * eq_mid * eq_low - - final = (self.xf_vol / 100.0) * (self.sl_vol.value() / MAX_SLIDER_VALUE) * eq_gain + # 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(1200, 950) + self.resize(1280, 980) self.setStyleSheet(STYLESHEET) # Set window icon @@ -1820,7 +2877,10 @@ class DJApp(QMainWindow): # 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(2000) # run every 2 s + 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" @@ -1844,81 +2904,74 @@ class DJApp(QMainWindow): def init_ui(self): main = QWidget() - main.setObjectName("Central") # Set objectName for neon border styling + main.setObjectName("Central") self.setCentralWidget(main) layout = QVBoxLayout(main) - layout.setContentsMargins(15, 15, 15, 15) - + 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_() # Bring to front - - # Top bar: Neon toggle and YouTube search + self.glow_frame.raise_() + + # ── Top bar: app title + YouTube search ────────────────────────── h = QHBoxLayout() - self.neon_button = QPushButton("NEON EDGE: OFF") + 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(150) - self.neon_button.setToolTip("Toggle neon border effect") + 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.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) - - # Format selector dropdown + self.format_selector = QComboBox() - self.format_selector.addItem("MP3 (slower, universal)", "mp3") - self.format_selector.addItem("Best Quality (faster)", "best") - self.format_selector.setCurrentIndex(0) # Default to MP3 - self.format_selector.setToolTip("Choose download format:\nMP3 = Converted, slower\nBest = Original quality, faster") - self.format_selector.setFixedWidth(160) - + 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(40) - self.search_button.setToolTip("Start YouTube search") + 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(40) - self.settings_btn.setToolTip("Open Keyboard Mapping Settings") + self.settings_btn.setFixedWidth(60) + self.settings_btn.setToolTip("Keyboard shortcuts & settings") self.settings_btn.clicked.connect(self.open_settings) - - self.status_label = QLabel("") - self.status_label.setStyleSheet("color:#0f0; font-weight:bold") - + + h.addWidget(title_lbl) + h.addSpacing(6) h.addWidget(self.neon_button) - h.addSpacing(10) - h.addWidget(self.yt_input) + h.addSpacing(6) + h.addWidget(self.yt_input, 1) h.addWidget(self.format_selector) h.addWidget(self.search_button) h.addWidget(self.settings_btn) - h.addWidget(self.status_label) 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("%p% - Downloading...") - self.download_progress_bar.setStyleSheet(""" - QProgressBar { - border: 1px solid #555; - border-radius: 3px; - text-align: center; - background-color: #111; - color: #fff; - } - QProgressBar::chunk { - background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0, - stop:0 #00ff00, stop:1 #00aa00); - border-radius: 2px; - } - """) - self.download_progress_bar.setVisible(False) # Hidden by default + 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 @@ -1931,167 +2984,208 @@ class DJApp(QMainWindow): layout.addLayout(decks_layout, 70) - # Crossfader + # 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_layout = QVBoxLayout() - xf_layout.setContentsMargins(50, 5, 50, 5) - xf_layout.addWidget(self.crossfader) - layout.addLayout(xf_layout, 5) - - # Recording controls + xf_vbox.addWidget(self.crossfader) + + layout.addWidget(xf_outer) + + # ── Recording / Streaming / Glow bar ───────────────────────────── rec_layout = QHBoxLayout() - rec_layout.setContentsMargins(50, 10, 50, 10) - + rec_layout.setContentsMargins(30, 6, 30, 6) + rec_layout.setSpacing(10) + + # REC button self.record_button = QPushButton("REC") - self.record_button.setFixedWidth(100) + self.record_button.setFixedSize(90, 44) self.record_button.setToolTip("Start/Stop recording your mix") self.record_button.setStyleSheet(""" QPushButton { - background-color: #330000; - color: #ff3333; - border: 2px solid #550000; + 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: 14px; - padding: 8px; - } - QPushButton:hover { - background-color: #550000; - border-color: #ff0000; + 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: #888; - font-size: 24px; - font-weight: bold; - font-family: 'Courier New'; - """) + 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_status_label = QLabel("Ready to record") - self.recording_status_label.setStyleSheet("color: #666; font-size: 12px;") + 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_left = QVBoxLayout() - rec_left.addWidget(self.recording_timer_label) - rec_left.addWidget(self.recording_status_label) - - # Streaming button + + 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.setFixedWidth(100) + self.stream_button.setFixedSize(90, 44) self.stream_button.setToolTip("Start/Stop live streaming") self.stream_button.setStyleSheet(""" QPushButton { - background-color: #001a33; - color: #3399ff; - border: 2px solid #003366; + 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: 14px; - padding: 8px; - } - QPushButton:hover { - background-color: #003366; - border-color: #0066cc; + 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: #666; font-size: 12px;") + + 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: #3399ff; font-size: 10px; font-weight: bold;") + 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 Controls --- + # Listener Glow glow_title = QLabel("LISTENER GLOW") glow_title.setStyleSheet( - "color: #bc13fe; font-size: 10px; font-weight: bold; " - "font-family: 'Courier New'; letter-spacing: 1px;" + "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(130) + self.glow_slider.setFixedWidth(120) self.glow_slider.setToolTip( - "Listener page glow intensity\n" - "0 = off | 100 = max\n" - "Sends to all listeners in real-time while streaming" + "Listener page glow intensity\n0 = off | 100 = max\nSends to listeners in real-time" ) self.glow_slider.setStyleSheet(""" QSlider::groove:horizontal { - border: 1px solid #550088; + border: 1px solid #330055; height: 6px; - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, - stop:0 #1a0026, stop:1 #bc13fe); + background: qlineargradient(x1:0,y1:0,x2:1,y2:0, stop:0 #0d0018, stop:1 #aa00ff); border-radius: 3px; } QSlider::handle:horizontal { - background: #bc13fe; - border: 2px solid #e040fb; - width: 14px; - height: 14px; - margin: -5px 0; - border-radius: 7px; - } - QSlider::handle:horizontal:hover { - background: #e040fb; - border-color: #fff; + 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: #bc13fe; font-size: 10px;") + 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) + 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(20) - rec_layout.addLayout(rec_left) - rec_layout.addSpacing(40) + rec_layout.addSpacing(4) + rec_layout.addLayout(rec_info) + rec_layout.addWidget(_vline()) rec_layout.addWidget(self.stream_button) - rec_layout.addSpacing(20) + rec_layout.addSpacing(4) rec_layout.addLayout(stream_info) - rec_layout.addSpacing(40) + 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, 3) + + layout.addLayout(rec_layout) # Library section - library_group = QGroupBox("LIBRARY") + 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) @@ -2237,9 +3331,17 @@ class DJApp(QMainWindow): self.current_shortcuts = self.all_settings["shortcuts"] # Save all settings - with open(self.settings_file, "w") as f: - json.dump(self.all_settings, f, indent=4) - + 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() @@ -2307,21 +3409,18 @@ class DJApp(QMainWindow): def toggle_neon(self): self.neon_state = (self.neon_state + 1) % 3 - colors = {0: "#555", 1: "#0ff", 2: "#f0f"} + colors = {0: "#334455", 1: "#00e5ff", 2: "#ea00ff"} color = colors[self.neon_state] - labels = ["OFF", "BLUE", "PURPLE"] - - self.neon_button.setText(f"NEON EDGE: {labels[self.neon_state]}") - self.neon_button.setStyleSheet(f"color: {color}; border: 1px solid {color};") - + 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: - # Disable glow self.glow_frame.set_glow(False) - self.centralWidget().setStyleSheet("QWidget#Central { border: none; }") else: - # Enable glow with selected color self.glow_frame.set_glow(True, color) - self.centralWidget().setStyleSheet("QWidget#Central { border: none; }") + self.centralWidget().setStyleSheet("QWidget#Central { border: none; }") def set_library_mode(self, mode): self.library_mode = mode @@ -2529,152 +3628,144 @@ class DJApp(QMainWindow): def toggle_recording(self): """Start or stop recording""" if not self.is_recording: - # Start 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}.wav" + filename = f"mix_{timestamp}.{fmt}" output_path = str(self.recordings_path / filename) - - if self.recording_worker.start_recording(output_path): + + 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) # Update every second - - # Update UI - self.record_button.setText("STOP") + self.recording_timer.start(1000) + + self.record_button.setText("STOP REC") self.record_button.setStyleSheet(""" QPushButton { - background-color: #550000; - color: #ff0000; + 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: 14px; - padding: 8px; - } - QPushButton:hover { - background-color: #770000; - border-color: #ff3333; + 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(f"Recording to: {filename}") - self.recording_status_label.setStyleSheet("color: #ff0000; font-size: 12px;") - print(f"[RECORDING] Started: {output_path}") + self.recording_status_label.setText("[REC]") + self.recording_status_label.setStyleSheet( + "color: #ff2222; font-size: 10px; font-family: 'Courier New'; letter-spacing: 2px;" + ) else: - # Stop recording self.recording_worker.stop_recording() self.is_recording = False self.recording_timer.stop() - - # Update UI + self.record_button.setText("REC") self.record_button.setStyleSheet(""" QPushButton { - background-color: #330000; - color: #ff3333; - border: 2px solid #550000; - font-weight: bold; - font-size: 14px; - padding: 8px; - } - QPushButton:hover { - background-color: #550000; - border-color: #ff0000; + 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: #888; font-size: 24px; font-weight: bold; font-family: 'Courier New';") - self.recording_status_label.setText("Recording saved!") - self.recording_status_label.setStyleSheet("color: #00ff00; font-size: 12px;") - print("[RECORDING] Stopped") - - # Reset status after 3 seconds + 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("Ready to record") - self.recording_status_label.setStyleSheet("color: #666; font-size: 12px;") + 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): - """Update the recording timer display""" 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: #ff0000; font-size: 24px; font-weight: bold; font-family: 'Courier New';") + self.recording_timer_label.setStyleSheet( + "color: #ff2222; font-size: 22px; font-weight: bold; font-family: 'Courier New';" + ) def on_recording_error(self, error_msg): - """Handle recording errors""" 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("Recording failed") - self.recording_status_label.setStyleSheet("color: #ff0000; font-size: 12px;") + self.recording_status_label.setText("ERROR") + self.recording_status_label.setStyleSheet( + "color: #ff0000; font-size: 10px; font-family: 'Courier New';" + ) def toggle_streaming(self): - """Toggle live streaming on/off""" if not self.is_streaming: - # Get base URL and credentials from settings 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", "") - # Start streaming (password handled inside StreamingWorker) 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-color: #003366; - color: #00ff00; + 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: 14px; - padding: 8px; - } - QPushButton:hover { - background-color: #0066cc; - border-color: #00ff00; + 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: #ff0000; font-size: 12px; font-weight: bold;") - print("[STREAMING] Started") + self.stream_status_label.setStyleSheet( + "color: #ff2222; font-size: 10px; font-family: 'Courier New'; letter-spacing: 2px; font-weight: bold;" + ) else: - # Stop streaming self.streaming_worker.stop_streaming() self.is_streaming = False self.stream_button.setText("LIVE") self.stream_button.setStyleSheet(""" QPushButton { - background-color: #001a33; - color: #3399ff; - border: 2px solid #003366; - font-weight: bold; - font-size: 14px; - padding: 8px; - } - QPushButton:hover { - background-color: #003366; - border-color: #0066cc; + 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: #666; font-size: 12px;") - print("[STREAMING] Stopped") + 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): - """Handle streaming errors""" 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: #ff0000; font-size: 12px;") + 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} listeners") + 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.""" @@ -2747,7 +3838,8 @@ class DJApp(QMainWindow): # 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()