Initial commit - Radio Panel v0.1
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+306
@@ -0,0 +1,306 @@
|
|||||||
|
# audio_engine.py
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
import jack
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# FORMAT LOADER
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'}
|
||||||
|
FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'}
|
||||||
|
|
||||||
|
|
||||||
|
def load_audio(filepath: str, target_sr: int) -> np.ndarray:
|
||||||
|
ext = os.path.splitext(filepath)[1].lower()
|
||||||
|
|
||||||
|
if ext in SOUNDFILE_FORMATS:
|
||||||
|
data, sr = sf.read(filepath, always_2d=True, dtype='float32')
|
||||||
|
data = data.T
|
||||||
|
|
||||||
|
elif ext in FFMPEG_FORMATS:
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg', '-i', filepath, '-f', 'f32le', '-acodec', 'pcm_f32le',
|
||||||
|
'-ar', str(target_sr), '-ac', '2', '-', '-loglevel', 'quiet'
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ValueError(f"FFmpeg failed: {result.stderr.decode()}")
|
||||||
|
raw = np.frombuffer(result.stdout, dtype=np.float32)
|
||||||
|
data = raw.reshape(-1, 2).T
|
||||||
|
sr = target_sr
|
||||||
|
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data, sr = sf.read(filepath, always_2d=True, dtype='float32')
|
||||||
|
data = data.T
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Unsupported format: {ext} — {e}")
|
||||||
|
|
||||||
|
if sr != target_sr:
|
||||||
|
import librosa
|
||||||
|
data = np.array([
|
||||||
|
librosa.resample(data[ch], orig_sr=sr, target_sr=target_sr)
|
||||||
|
for ch in range(data.shape[0])
|
||||||
|
], dtype=np.float32)
|
||||||
|
|
||||||
|
if data.shape[0] == 1:
|
||||||
|
data = np.vstack([data, data])
|
||||||
|
|
||||||
|
data = np.clip(data, -1.0, 1.0)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# TRACK
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class Track:
|
||||||
|
def __init__(self, filepath: str, audio: np.ndarray, sr: int):
|
||||||
|
self.filepath = filepath
|
||||||
|
self.filename = os.path.basename(filepath)
|
||||||
|
self.audio = audio
|
||||||
|
self.sr = sr
|
||||||
|
self.num_samples = audio.shape[1]
|
||||||
|
self.duration = self.num_samples / sr
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# DECK STATE
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class DeckState:
|
||||||
|
def __init__(self):
|
||||||
|
self.track = None
|
||||||
|
self.queued = None
|
||||||
|
self.position = 0
|
||||||
|
self.playing = False
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
def load(self, track: Track):
|
||||||
|
with self.lock:
|
||||||
|
self.track = track
|
||||||
|
self.position = 0
|
||||||
|
self.playing = False
|
||||||
|
|
||||||
|
def load_queue(self, track: Track):
|
||||||
|
with self.lock:
|
||||||
|
self.queued = track
|
||||||
|
|
||||||
|
def play(self):
|
||||||
|
with self.lock:
|
||||||
|
if self.track:
|
||||||
|
self.playing = True
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
with self.lock:
|
||||||
|
self.playing = False
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
with self.lock:
|
||||||
|
self.playing = False
|
||||||
|
self.track = None
|
||||||
|
self.position = 0
|
||||||
|
|
||||||
|
def skip_seconds(self, seconds: int):
|
||||||
|
with self.lock:
|
||||||
|
if self.track:
|
||||||
|
new = self.position + int(seconds * self.track.sr)
|
||||||
|
self.position = max(0, min(new, self.track.num_samples - 1))
|
||||||
|
|
||||||
|
def go_to_start(self):
|
||||||
|
with self.lock:
|
||||||
|
self.position = 0
|
||||||
|
|
||||||
|
def go_to_end(self):
|
||||||
|
with self.lock:
|
||||||
|
if self.track:
|
||||||
|
self.position = self.track.num_samples - 1
|
||||||
|
|
||||||
|
def get_elapsed(self) -> str:
|
||||||
|
if not self.track:
|
||||||
|
return "00:00"
|
||||||
|
s = self.position / self.track.sr
|
||||||
|
return f"{int(s//60):02d}:{int(s%60):02d}"
|
||||||
|
|
||||||
|
def get_remaining(self) -> str:
|
||||||
|
if not self.track:
|
||||||
|
return "-00:00"
|
||||||
|
remaining = max(0, self.track.duration - (self.position / self.track.sr))
|
||||||
|
return f"-{int(remaining//60):02d}:{int(remaining%60):02d}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# AUDIO ENGINE
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class AudioEngine:
|
||||||
|
def __init__(self):
|
||||||
|
self.client = jack.Client("RadioPanel")
|
||||||
|
self.sr = self.client.samplerate
|
||||||
|
|
||||||
|
# Output ports
|
||||||
|
self.ports = {
|
||||||
|
'deck1': (
|
||||||
|
self.client.outports.register("deck1_L"),
|
||||||
|
self.client.outports.register("deck1_R"),
|
||||||
|
),
|
||||||
|
'deck2': (
|
||||||
|
self.client.outports.register("deck2_L"),
|
||||||
|
self.client.outports.register("deck2_R"),
|
||||||
|
),
|
||||||
|
'cart': (
|
||||||
|
self.client.outports.register("cart_L"),
|
||||||
|
self.client.outports.register("cart_R"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Input ports for fingerprinting
|
||||||
|
self.inports = {
|
||||||
|
'fingerprint': (
|
||||||
|
self.client.inports.register("fingerprint_L"),
|
||||||
|
self.client.inports.register("fingerprint_R"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deck states
|
||||||
|
self.decks = {
|
||||||
|
'deck1': DeckState(),
|
||||||
|
'deck2': DeckState(),
|
||||||
|
'cart1': DeckState(),
|
||||||
|
'cart2': DeckState(),
|
||||||
|
'cart3': DeckState(),
|
||||||
|
'cart4': DeckState(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Recording state
|
||||||
|
self.recording = False
|
||||||
|
self.record_buffer = None
|
||||||
|
self.record_position = 0
|
||||||
|
self.record_target_frames = 0
|
||||||
|
self.record_callback = None
|
||||||
|
|
||||||
|
# VU levels (L, R) 0.0-1.0
|
||||||
|
self.fingerprint_levels = [0.0, 0.0]
|
||||||
|
|
||||||
|
self.client.set_process_callback(self._process)
|
||||||
|
self.client.activate()
|
||||||
|
|
||||||
|
def _fill_port(self, port_L, port_R, deck: DeckState, frames: int):
|
||||||
|
buf_L = port_L.get_array()
|
||||||
|
buf_R = port_R.get_array()
|
||||||
|
buf_L.fill(0.0)
|
||||||
|
buf_R.fill(0.0)
|
||||||
|
|
||||||
|
if not deck.playing or deck.track is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
pos = deck.position
|
||||||
|
track = deck.track
|
||||||
|
end = min(pos + frames, track.num_samples)
|
||||||
|
n = end - pos
|
||||||
|
|
||||||
|
if n <= 0:
|
||||||
|
deck.playing = False
|
||||||
|
return
|
||||||
|
|
||||||
|
buf_L[:n] = track.audio[0, pos:end]
|
||||||
|
buf_R[:n] = track.audio[1, pos:end]
|
||||||
|
deck.position += n
|
||||||
|
|
||||||
|
if deck.position >= track.num_samples:
|
||||||
|
deck.playing = False
|
||||||
|
deck.position = track.num_samples - 1
|
||||||
|
|
||||||
|
def _process(self, frames: int):
|
||||||
|
# Decks
|
||||||
|
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
|
||||||
|
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
|
||||||
|
|
||||||
|
# Mix carts
|
||||||
|
buf_L = self.ports['cart'][0].get_array()
|
||||||
|
buf_R = self.ports['cart'][1].get_array()
|
||||||
|
buf_L.fill(0.0)
|
||||||
|
buf_R.fill(0.0)
|
||||||
|
|
||||||
|
for cart_key in ('cart1', 'cart2', 'cart3', 'cart4'):
|
||||||
|
deck = self.decks[cart_key]
|
||||||
|
if not deck.playing or deck.track is None:
|
||||||
|
continue
|
||||||
|
pos = deck.position
|
||||||
|
track = deck.track
|
||||||
|
end = min(pos + frames, track.num_samples)
|
||||||
|
n = end - pos
|
||||||
|
if n <= 0:
|
||||||
|
deck.playing = False
|
||||||
|
continue
|
||||||
|
buf_L[:n] += track.audio[0, pos:end]
|
||||||
|
buf_R[:n] += track.audio[1, pos:end]
|
||||||
|
deck.position += n
|
||||||
|
if deck.position >= track.num_samples:
|
||||||
|
deck.playing = False
|
||||||
|
deck.position = track.num_samples - 1
|
||||||
|
|
||||||
|
# Fingerprint recording + VU
|
||||||
|
in_L = self.inports['fingerprint'][0].get_array()
|
||||||
|
in_R = self.inports['fingerprint'][1].get_array()
|
||||||
|
|
||||||
|
rms_l = np.sqrt(np.mean(in_L**2)) if len(in_L) > 0 else 0.0
|
||||||
|
rms_r = np.sqrt(np.mean(in_R**2)) if len(in_R) > 0 else 0.0
|
||||||
|
self.fingerprint_levels[0] = min(1.0, rms_l * 8)
|
||||||
|
self.fingerprint_levels[1] = min(1.0, rms_r * 8)
|
||||||
|
|
||||||
|
if self.recording and self.record_buffer is not None:
|
||||||
|
remaining = self.record_target_frames - self.record_position
|
||||||
|
to_record = min(frames, remaining)
|
||||||
|
|
||||||
|
if to_record > 0:
|
||||||
|
self.record_buffer[0, self.record_position:self.record_position + to_record] = in_L[:to_record]
|
||||||
|
self.record_buffer[1, self.record_position:self.record_position + to_record] = in_R[:to_record]
|
||||||
|
self.record_position += to_record
|
||||||
|
|
||||||
|
if self.record_position >= self.record_target_frames:
|
||||||
|
self.recording = False
|
||||||
|
if self.record_callback:
|
||||||
|
audio_copy = self.record_buffer.copy()
|
||||||
|
cb = self.record_callback
|
||||||
|
sr = self.sr
|
||||||
|
self.record_buffer = None
|
||||||
|
threading.Thread(target=cb, args=(audio_copy, sr), daemon=True).start()
|
||||||
|
|
||||||
|
def start_recording(self, duration_seconds: int, callback=None):
|
||||||
|
frames = int(duration_seconds * self.sr)
|
||||||
|
self.record_buffer = np.zeros((2, frames), dtype=np.float32)
|
||||||
|
self.record_position = 0
|
||||||
|
self.record_target_frames = frames
|
||||||
|
self.record_callback = callback
|
||||||
|
self.recording = True
|
||||||
|
|
||||||
|
def stop_recording(self):
|
||||||
|
self.recording = False
|
||||||
|
actual = self.record_buffer[:, :self.record_position].copy()
|
||||||
|
self.record_buffer = None
|
||||||
|
return actual, self.sr
|
||||||
|
|
||||||
|
def load_track(self, deck_key: str, filepath: str):
|
||||||
|
def _load():
|
||||||
|
audio = load_audio(filepath, self.sr)
|
||||||
|
track = Track(filepath, audio, self.sr)
|
||||||
|
self.decks[deck_key].load(track)
|
||||||
|
threading.Thread(target=_load, daemon=True).start()
|
||||||
|
|
||||||
|
def load_queue(self, deck_key: str, filepath: str):
|
||||||
|
def _load():
|
||||||
|
audio = load_audio(filepath, self.sr)
|
||||||
|
track = Track(filepath, audio, self.sr)
|
||||||
|
self.decks[deck_key].load_queue(track)
|
||||||
|
threading.Thread(target=_load, daemon=True).start()
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self.client.deactivate()
|
||||||
|
self.client.close()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# audio_test.py
|
||||||
|
import soundfile as sf
|
||||||
|
import numpy as np
|
||||||
|
import jack
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def test_audio(filepath):
|
||||||
|
client = jack.Client("AudioTest")
|
||||||
|
sr = client.samplerate
|
||||||
|
|
||||||
|
out_L = client.outports.register("test_L")
|
||||||
|
out_R = client.outports.register("test_R")
|
||||||
|
|
||||||
|
# Load file
|
||||||
|
try:
|
||||||
|
data, file_sr = sf.read(filepath, always_2d=True)
|
||||||
|
data = data.T.astype(np.float32)
|
||||||
|
print(f"✓ Loaded: {data.shape} at {file_sr}Hz")
|
||||||
|
if file_sr != sr:
|
||||||
|
print(f"⚠ Sample rate mismatch: file={file_sr} JACK={sr}")
|
||||||
|
print(f" Install librosa for auto-resampling")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Load failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
position = [0]
|
||||||
|
playing = [True]
|
||||||
|
|
||||||
|
def callback(frames):
|
||||||
|
out_L.get_array().fill(0.0)
|
||||||
|
out_R.get_array().fill(0.0)
|
||||||
|
|
||||||
|
if playing[0]:
|
||||||
|
pos = position[0]
|
||||||
|
end = min(pos + frames, data.shape[1])
|
||||||
|
chunk = data[:, pos:end]
|
||||||
|
n = chunk.shape[1]
|
||||||
|
|
||||||
|
out_L.get_array()[:n] = chunk[0]
|
||||||
|
out_R.get_array()[:n] = chunk[1] if data.shape[0] > 1 else chunk[0]
|
||||||
|
|
||||||
|
position[0] += n
|
||||||
|
if position[0] >= data.shape[1]:
|
||||||
|
playing[0] = False
|
||||||
|
print("✓ Playback complete")
|
||||||
|
|
||||||
|
client.set_process_callback(callback)
|
||||||
|
client.activate()
|
||||||
|
|
||||||
|
print(f"Playing {filepath}")
|
||||||
|
print("Wire 'AudioTest:test_L/R' in qpwgraph to hear audio")
|
||||||
|
print("Press Ctrl+C to stop")
|
||||||
|
|
||||||
|
import time
|
||||||
|
try:
|
||||||
|
while playing[0]:
|
||||||
|
time.sleep(0.1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
client.deactivate()
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python audio_test.py /path/to/file.flac")
|
||||||
|
sys.exit(1)
|
||||||
|
test_audio(sys.argv[1])
|
||||||
+295
@@ -0,0 +1,295 @@
|
|||||||
|
# cart_widget.py
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QFrame, QSizePolicy, QGridLayout
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt, QTimer
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# SINGLE CART SLOT
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class CartSlot(QFrame):
|
||||||
|
def __init__(self, cart_key: str, slot_number: int, engine, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.cart_key = cart_key
|
||||||
|
self.slot_number = slot_number
|
||||||
|
self.engine = engine
|
||||||
|
|
||||||
|
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||||
|
self.setLineWidth(1)
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
|
self.setFixedHeight(110)
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self._connect_signals()
|
||||||
|
|
||||||
|
self.timer = QTimer(self)
|
||||||
|
self.timer.setInterval(100)
|
||||||
|
self.timer.timeout.connect(self._refresh_display)
|
||||||
|
self.timer.start()
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
root = QHBoxLayout(self)
|
||||||
|
root.setContentsMargins(8, 6, 8, 6)
|
||||||
|
root.setSpacing(8)
|
||||||
|
|
||||||
|
badge = QLabel(str(self.slot_number))
|
||||||
|
badge.setFixedSize(28, 28)
|
||||||
|
badge.setAlignment(Qt.AlignCenter)
|
||||||
|
badge.setFont(QFont("Arial", 12, QFont.Bold))
|
||||||
|
badge.setStyleSheet("""
|
||||||
|
background-color: #cc3300;
|
||||||
|
color: white;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: none;
|
||||||
|
""")
|
||||||
|
root.addWidget(badge, alignment=Qt.AlignVCenter)
|
||||||
|
|
||||||
|
lcd_frame = QFrame()
|
||||||
|
lcd_frame.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #0a1a0a;
|
||||||
|
border: 1px solid #1a2a1a;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
lcd_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||||
|
|
||||||
|
lcd_layout = QVBoxLayout(lcd_frame)
|
||||||
|
lcd_layout.setContentsMargins(6, 4, 6, 4)
|
||||||
|
lcd_layout.setSpacing(2)
|
||||||
|
|
||||||
|
self.track_label = QLabel("EMPTY")
|
||||||
|
self.track_label.setFont(QFont("Courier New", 9, QFont.Bold))
|
||||||
|
self.track_label.setStyleSheet(
|
||||||
|
"color: #00ff41; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||||
|
|
||||||
|
time_row = QHBoxLayout()
|
||||||
|
|
||||||
|
self.elapsed_label = QLabel("00:00")
|
||||||
|
self.elapsed_label.setFont(QFont("Courier New", 14, QFont.Bold))
|
||||||
|
self.elapsed_label.setStyleSheet(
|
||||||
|
"color: #00ff41; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.remaining_label = QLabel("-00:00")
|
||||||
|
self.remaining_label.setFont(QFont("Courier New", 10))
|
||||||
|
self.remaining_label.setStyleSheet(
|
||||||
|
"color: #00cc33; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
|
|
||||||
|
time_row.addWidget(self.elapsed_label)
|
||||||
|
time_row.addStretch()
|
||||||
|
time_row.addWidget(self.remaining_label)
|
||||||
|
|
||||||
|
self.status_label = QLabel("STOPPED")
|
||||||
|
self.status_label.setFont(QFont("Courier New", 8))
|
||||||
|
self.status_label.setStyleSheet(
|
||||||
|
"color: #008822; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
|
||||||
|
lcd_layout.addWidget(self.track_label)
|
||||||
|
lcd_layout.addLayout(time_row)
|
||||||
|
lcd_layout.addWidget(self.status_label)
|
||||||
|
|
||||||
|
root.addWidget(lcd_frame)
|
||||||
|
|
||||||
|
btn_col = QVBoxLayout()
|
||||||
|
btn_col.setSpacing(4)
|
||||||
|
|
||||||
|
self.btn_play = QPushButton("▶")
|
||||||
|
self.btn_play.setFixedSize(60, 44)
|
||||||
|
self.btn_play.setFont(QFont("Arial", 13, QFont.Bold))
|
||||||
|
self.btn_play.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #1a6b3a;
|
||||||
|
color: #00ff41;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #2a7b4a; }
|
||||||
|
QPushButton:pressed { background-color: #0a5a2a; }
|
||||||
|
""")
|
||||||
|
|
||||||
|
self.btn_eject = QPushButton("⏏")
|
||||||
|
self.btn_eject.setFixedSize(60, 30)
|
||||||
|
self.btn_eject.setFont(QFont("Arial", 11, QFont.Bold))
|
||||||
|
self.btn_eject.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #8a1a1a;
|
||||||
|
color: #ff4444;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #9a2a2a; }
|
||||||
|
QPushButton:pressed { background-color: #6a0a0a; }
|
||||||
|
""")
|
||||||
|
|
||||||
|
btn_col.addWidget(self.btn_play)
|
||||||
|
btn_col.addWidget(self.btn_eject)
|
||||||
|
|
||||||
|
root.addLayout(btn_col)
|
||||||
|
|
||||||
|
def _connect_signals(self):
|
||||||
|
self.btn_play.clicked.connect(self._toggle_play)
|
||||||
|
self.btn_eject.clicked.connect(self._eject)
|
||||||
|
|
||||||
|
def load_track(self, filepath: str):
|
||||||
|
self.engine.load_track(self.cart_key, filepath)
|
||||||
|
name = os.path.basename(filepath)
|
||||||
|
short = name[:28] + "..." if len(name) > 28 else name
|
||||||
|
self.track_label.setText(short)
|
||||||
|
self.status_label.setText("LOADED")
|
||||||
|
self.elapsed_label.setText("00:00")
|
||||||
|
self.remaining_label.setText("-00:00")
|
||||||
|
|
||||||
|
# ── PUBLIC MIDI METHOD ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def toggle(self):
|
||||||
|
"""
|
||||||
|
MIDI toggle - playing → stop and reset to start.
|
||||||
|
Stopped/paused → play.
|
||||||
|
"""
|
||||||
|
deck = self.engine.decks[self.cart_key]
|
||||||
|
if deck.track is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if deck.playing:
|
||||||
|
deck.stop()
|
||||||
|
deck.go_to_start()
|
||||||
|
self._set_ui_stopped()
|
||||||
|
self.status_label.setText("READY")
|
||||||
|
else:
|
||||||
|
deck.play()
|
||||||
|
self._set_ui_playing()
|
||||||
|
|
||||||
|
# ── SHARED UI STATE HELPERS ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _set_ui_playing(self):
|
||||||
|
self.btn_play.setText("⏸")
|
||||||
|
self.btn_play.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #00aa33;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #00ff41;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #00cc44; }
|
||||||
|
""")
|
||||||
|
self.status_label.setText("PLAYING")
|
||||||
|
self.elapsed_label.setStyleSheet(
|
||||||
|
"color: #00ff41; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_ui_stopped(self):
|
||||||
|
self.btn_play.setText("▶")
|
||||||
|
self.btn_play.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #1a6b3a;
|
||||||
|
color: #00ff41;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #2a7b4a; }
|
||||||
|
""")
|
||||||
|
self.elapsed_label.setText("00:00")
|
||||||
|
self.remaining_label.setText("-00:00")
|
||||||
|
self.elapsed_label.setStyleSheet(
|
||||||
|
"color: #00aa2a; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── ACTIONS ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _toggle_play(self):
|
||||||
|
deck = self.engine.decks[self.cart_key]
|
||||||
|
if deck.track is None:
|
||||||
|
return
|
||||||
|
if deck.playing:
|
||||||
|
deck.pause()
|
||||||
|
self._set_ui_stopped()
|
||||||
|
self.status_label.setText("PAUSED")
|
||||||
|
else:
|
||||||
|
deck.play()
|
||||||
|
self._set_ui_playing()
|
||||||
|
|
||||||
|
def _eject(self):
|
||||||
|
deck = self.engine.decks[self.cart_key]
|
||||||
|
deck.stop()
|
||||||
|
self._set_ui_stopped()
|
||||||
|
self.track_label.setText("EMPTY")
|
||||||
|
self.status_label.setText("STOPPED")
|
||||||
|
|
||||||
|
def _refresh_display(self):
|
||||||
|
deck = self.engine.decks[self.cart_key]
|
||||||
|
|
||||||
|
if not deck.playing and self.btn_play.text() == "⏸":
|
||||||
|
self._set_ui_stopped()
|
||||||
|
self.status_label.setText("STOPPED")
|
||||||
|
|
||||||
|
if deck.track:
|
||||||
|
self.elapsed_label.setText(deck.get_elapsed())
|
||||||
|
self.remaining_label.setText(deck.get_remaining())
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# CART BANK
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class CartBankWidget(QWidget):
|
||||||
|
def __init__(self, engine, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.engine = engine
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QWidget {
|
||||||
|
background-color: #111;
|
||||||
|
border: 2px solid #cc3300;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(8, 6, 8, 6)
|
||||||
|
root.setSpacing(6)
|
||||||
|
|
||||||
|
header = QLabel("CARTS")
|
||||||
|
header.setFont(QFont("Arial", 11, QFont.Bold))
|
||||||
|
header.setStyleSheet("""
|
||||||
|
color: #cc3300;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 2px;
|
||||||
|
""")
|
||||||
|
header.setAlignment(Qt.AlignCenter)
|
||||||
|
root.addWidget(header)
|
||||||
|
|
||||||
|
grid = QGridLayout()
|
||||||
|
grid.setSpacing(6)
|
||||||
|
|
||||||
|
self.slots = []
|
||||||
|
for i in range(4):
|
||||||
|
cart_key = f"cart{i + 1}"
|
||||||
|
slot = CartSlot(cart_key, i + 1, self.engine)
|
||||||
|
row = i // 2
|
||||||
|
col = i % 2
|
||||||
|
grid.addWidget(slot, row, col)
|
||||||
|
self.slots.append(slot)
|
||||||
|
|
||||||
|
root.addLayout(grid)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# debug_audio.py - save and run this
|
||||||
|
import jack
|
||||||
|
import numpy as np
|
||||||
|
import time
|
||||||
|
|
||||||
|
client = jack.Client("DebugTest")
|
||||||
|
client.activate()
|
||||||
|
|
||||||
|
in_L = client.inports.register("test_in_L")
|
||||||
|
in_R = client.inports.register("test_in_R")
|
||||||
|
|
||||||
|
@client.set_process_callback
|
||||||
|
def process(frames):
|
||||||
|
lvl_L = np.max(np.abs(in_L.get_array())) if len(in_L.get_array()) > 0 else 0
|
||||||
|
lvl_R = np.max(np.abs(in_R.get_array())) if len(in_R.get_array()) > 0 else 0
|
||||||
|
if lvl_L > 0.001 or lvl_R > 0.001:
|
||||||
|
print(f"Levels: L={lvl_L:.4f} R={lvl_R:.4f}")
|
||||||
|
|
||||||
|
print("Connect DebugTest:test_in_L/R to your audio source in qpwgraph")
|
||||||
|
print("Press Ctrl+C to stop")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
client.deactivate()
|
||||||
+347
@@ -0,0 +1,347 @@
|
|||||||
|
# deck_widget.py
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QFrame, QSizePolicy
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt, QTimer, Signal
|
||||||
|
from PySide6.QtGui import QFont, QColor
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# LCD DISPLAY WIDGET
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class LCDDisplay(QFrame):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setFrameStyle(QFrame.Box | QFrame.Sunken)
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #0a1a0a;
|
||||||
|
border: 2px solid #1a2a1a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(10, 8, 10, 8)
|
||||||
|
layout.setSpacing(4)
|
||||||
|
|
||||||
|
self.track_label = QLabel("NO TRACK LOADED")
|
||||||
|
self.track_label.setFont(QFont("Courier New", 13, QFont.Bold))
|
||||||
|
self.track_label.setStyleSheet(
|
||||||
|
"color: #00ff41; background: transparent;"
|
||||||
|
)
|
||||||
|
self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||||
|
self.track_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
|
|
||||||
|
time_row = QHBoxLayout()
|
||||||
|
time_row.setSpacing(4)
|
||||||
|
|
||||||
|
self.elapsed_label = QLabel("00:00")
|
||||||
|
self.elapsed_label.setFont(QFont("Courier New", 32, QFont.Bold))
|
||||||
|
self.elapsed_label.setStyleSheet(
|
||||||
|
"color: #00ff41; background: transparent;"
|
||||||
|
)
|
||||||
|
self.elapsed_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||||
|
|
||||||
|
self.remaining_label = QLabel("-00:00")
|
||||||
|
self.remaining_label.setFont(QFont("Courier New", 18, QFont.Bold))
|
||||||
|
self.remaining_label.setStyleSheet(
|
||||||
|
"color: #00cc33; background: transparent;"
|
||||||
|
)
|
||||||
|
self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
|
|
||||||
|
time_row.addWidget(self.elapsed_label)
|
||||||
|
time_row.addStretch()
|
||||||
|
time_row.addWidget(self.remaining_label)
|
||||||
|
|
||||||
|
status_row = QHBoxLayout()
|
||||||
|
|
||||||
|
self.status_label = QLabel("STOPPED")
|
||||||
|
self.status_label.setFont(QFont("Courier New", 11, QFont.Bold))
|
||||||
|
self.status_label.setStyleSheet(
|
||||||
|
"color: #008822; background: transparent;"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.queue_label = QLabel("QUEUE: EMPTY")
|
||||||
|
self.queue_label.setFont(QFont("Courier New", 10))
|
||||||
|
self.queue_label.setStyleSheet(
|
||||||
|
"color: #008822; background: transparent;"
|
||||||
|
)
|
||||||
|
self.queue_label.setAlignment(Qt.AlignRight)
|
||||||
|
|
||||||
|
status_row.addWidget(self.status_label)
|
||||||
|
status_row.addStretch()
|
||||||
|
status_row.addWidget(self.queue_label)
|
||||||
|
|
||||||
|
layout.addWidget(self.track_label)
|
||||||
|
layout.addLayout(time_row)
|
||||||
|
layout.addLayout(status_row)
|
||||||
|
|
||||||
|
def update_track(self, name: str):
|
||||||
|
if len(name) > 40:
|
||||||
|
name = name[:37] + "..."
|
||||||
|
self.track_label.setText(name)
|
||||||
|
|
||||||
|
def update_time(self, elapsed: str, remaining: str):
|
||||||
|
self.elapsed_label.setText(elapsed)
|
||||||
|
self.remaining_label.setText(remaining)
|
||||||
|
|
||||||
|
def update_status(self, status: str):
|
||||||
|
self.status_label.setText(status)
|
||||||
|
|
||||||
|
def update_queue(self, name: str):
|
||||||
|
if name:
|
||||||
|
short = name[:25] + "..." if len(name) > 25 else name
|
||||||
|
self.queue_label.setText(f"QUEUE: {short}")
|
||||||
|
else:
|
||||||
|
self.queue_label.setText("QUEUE: EMPTY")
|
||||||
|
|
||||||
|
def set_playing(self, playing: bool):
|
||||||
|
colour = "#00ff41" if playing else "#00aa2a"
|
||||||
|
self.elapsed_label.setStyleSheet(
|
||||||
|
f"color: {colour}; background: transparent;"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# TRANSPORT BUTTON HELPER
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_transport_button(text: str,
|
||||||
|
color: str = "#3a3a3a",
|
||||||
|
text_color: str = "white",
|
||||||
|
width: int = 52,
|
||||||
|
height: int = 48) -> QPushButton:
|
||||||
|
btn = QPushButton(text)
|
||||||
|
btn.setFixedSize(width, height)
|
||||||
|
btn.setFont(QFont("Arial", 11, QFont.Bold))
|
||||||
|
btn.setStyleSheet(f"""
|
||||||
|
QPushButton {{
|
||||||
|
background-color: {color};
|
||||||
|
color: {text_color};
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{
|
||||||
|
background-color: #555;
|
||||||
|
}}
|
||||||
|
QPushButton:pressed {{
|
||||||
|
background-color: #222;
|
||||||
|
border: 1px solid #888;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
return btn
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# DECK WIDGET
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class DeckWidget(QWidget):
|
||||||
|
track_started = Signal(str) # emits filepath when playback begins
|
||||||
|
|
||||||
|
def __init__(self, deck_key: str, label: str, accent_color: str,
|
||||||
|
engine, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.deck_key = deck_key
|
||||||
|
self.engine = engine
|
||||||
|
self.accent_color = accent_color
|
||||||
|
|
||||||
|
self._build_ui(label)
|
||||||
|
self._connect_signals()
|
||||||
|
|
||||||
|
self.timer = QTimer(self)
|
||||||
|
self.timer.setInterval(100)
|
||||||
|
self.timer.timeout.connect(self._refresh_display)
|
||||||
|
self.timer.start()
|
||||||
|
|
||||||
|
def _build_ui(self, label: str):
|
||||||
|
self.setStyleSheet(f"""
|
||||||
|
QWidget {{
|
||||||
|
background-color: #1c1c1c;
|
||||||
|
border: 2px solid {self.accent_color};
|
||||||
|
border-radius: 6px;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(10, 10, 10, 10)
|
||||||
|
root.setSpacing(8)
|
||||||
|
|
||||||
|
header = QLabel(label)
|
||||||
|
header.setFont(QFont("Arial", 13, QFont.Bold))
|
||||||
|
header.setStyleSheet(f"""
|
||||||
|
color: {self.accent_color};
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 2px;
|
||||||
|
""")
|
||||||
|
header.setAlignment(Qt.AlignCenter)
|
||||||
|
root.addWidget(header)
|
||||||
|
|
||||||
|
self.lcd = LCDDisplay()
|
||||||
|
root.addWidget(self.lcd)
|
||||||
|
|
||||||
|
transport = QHBoxLayout()
|
||||||
|
transport.setSpacing(6)
|
||||||
|
|
||||||
|
self.btn_to_start = make_transport_button("⏮", "#3a3a3a", "white", 52, 48)
|
||||||
|
self.btn_back_15 = make_transport_button("-15", "#3a3a3a", "#aaaaaa", 52, 48)
|
||||||
|
self.btn_play = make_transport_button("▶", "#1a6b3a", "#00ff41", 68, 48)
|
||||||
|
self.btn_fwd_15 = make_transport_button("+15", "#3a3a3a", "#aaaaaa", 52, 48)
|
||||||
|
self.btn_to_end = make_transport_button("⏭", "#3a3a3a", "white", 52, 48)
|
||||||
|
self.btn_stop = make_transport_button("■", "#8a1a1a", "#ff4444", 52, 48)
|
||||||
|
|
||||||
|
transport.addWidget(self.btn_to_start)
|
||||||
|
transport.addWidget(self.btn_back_15)
|
||||||
|
transport.addWidget(self.btn_play)
|
||||||
|
transport.addWidget(self.btn_fwd_15)
|
||||||
|
transport.addWidget(self.btn_to_end)
|
||||||
|
transport.addStretch()
|
||||||
|
transport.addWidget(self.btn_stop)
|
||||||
|
|
||||||
|
root.addLayout(transport)
|
||||||
|
|
||||||
|
def _connect_signals(self):
|
||||||
|
self.btn_play.clicked.connect(self._toggle_play)
|
||||||
|
self.btn_stop.clicked.connect(self._stop_eject)
|
||||||
|
self.btn_to_start.clicked.connect(self._go_to_start)
|
||||||
|
self.btn_to_end.clicked.connect(self._go_to_end)
|
||||||
|
self.btn_back_15.clicked.connect(lambda: self._scrub(-15))
|
||||||
|
self.btn_fwd_15.clicked.connect(lambda: self._scrub(15))
|
||||||
|
|
||||||
|
# ── PUBLIC — called by playlist ───────────────────────────────────────
|
||||||
|
|
||||||
|
def set_track_name(self, name: str):
|
||||||
|
self.lcd.update_track(name)
|
||||||
|
self.lcd.update_status("LOADED")
|
||||||
|
self.lcd.update_time("00:00", "-00:00")
|
||||||
|
|
||||||
|
def set_queue_name(self, name: str):
|
||||||
|
self.lcd.update_queue(name)
|
||||||
|
|
||||||
|
# ── PUBLIC MIDI METHODS ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def toggle_play(self):
|
||||||
|
self._toggle_play()
|
||||||
|
|
||||||
|
def cue(self):
|
||||||
|
deck = self.engine.decks[self.deck_key]
|
||||||
|
if deck.track is None:
|
||||||
|
return
|
||||||
|
if deck.playing:
|
||||||
|
deck.pause()
|
||||||
|
deck.go_to_start()
|
||||||
|
self.btn_play.setText("▶")
|
||||||
|
self.btn_play.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #1a6b3a;
|
||||||
|
color: #00ff41;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #2a7b4a; }
|
||||||
|
""")
|
||||||
|
self.lcd.update_status("CUE")
|
||||||
|
self.lcd.set_playing(False)
|
||||||
|
|
||||||
|
def scrub(self, delta: float):
|
||||||
|
self._scrub(delta)
|
||||||
|
|
||||||
|
# ── ACTIONS ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _toggle_play(self):
|
||||||
|
deck = self.engine.decks[self.deck_key]
|
||||||
|
if deck.track is None:
|
||||||
|
return
|
||||||
|
if deck.playing:
|
||||||
|
deck.pause()
|
||||||
|
self.btn_play.setText("▶")
|
||||||
|
self.btn_play.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #1a6b3a;
|
||||||
|
color: #00ff41;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #2a7b4a; }
|
||||||
|
""")
|
||||||
|
self.lcd.update_status("PAUSED")
|
||||||
|
self.lcd.set_playing(False)
|
||||||
|
else:
|
||||||
|
deck.play()
|
||||||
|
self.btn_play.setText("⏸")
|
||||||
|
self.btn_play.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #00aa33;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #00ff41;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #00cc44; }
|
||||||
|
""")
|
||||||
|
self.lcd.update_status("PLAYING")
|
||||||
|
self.lcd.set_playing(True)
|
||||||
|
if deck.track:
|
||||||
|
self.track_started.emit(deck.track.filepath)
|
||||||
|
|
||||||
|
def _stop_eject(self):
|
||||||
|
deck = self.engine.decks[self.deck_key]
|
||||||
|
deck.stop()
|
||||||
|
self.btn_play.setText("▶")
|
||||||
|
self.btn_play.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #1a6b3a;
|
||||||
|
color: #00ff41;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #2a7b4a; }
|
||||||
|
""")
|
||||||
|
self.lcd.update_track("NO TRACK LOADED")
|
||||||
|
self.lcd.update_time("00:00", "-00:00")
|
||||||
|
self.lcd.update_status("STOPPED")
|
||||||
|
self.lcd.set_playing(False)
|
||||||
|
|
||||||
|
def _go_to_start(self):
|
||||||
|
self.engine.decks[self.deck_key].go_to_start()
|
||||||
|
|
||||||
|
def _go_to_end(self):
|
||||||
|
self.engine.decks[self.deck_key].go_to_end()
|
||||||
|
|
||||||
|
def _scrub(self, seconds: float):
|
||||||
|
self.engine.decks[self.deck_key].skip_seconds(seconds)
|
||||||
|
|
||||||
|
# ── DISPLAY REFRESH ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _refresh_display(self):
|
||||||
|
deck = self.engine.decks[self.deck_key]
|
||||||
|
|
||||||
|
if not deck.playing and self.btn_play.text() == "⏸":
|
||||||
|
self.btn_play.setText("▶")
|
||||||
|
self.btn_play.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #1a6b3a;
|
||||||
|
color: #00ff41;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #2a7b4a; }
|
||||||
|
""")
|
||||||
|
self.lcd.update_status("STOPPED")
|
||||||
|
self.lcd.set_playing(False)
|
||||||
|
|
||||||
|
if deck.track:
|
||||||
|
self.lcd.update_time(
|
||||||
|
deck.get_elapsed(),
|
||||||
|
deck.get_remaining()
|
||||||
|
)
|
||||||
|
|
||||||
|
if deck.queued:
|
||||||
|
self.lcd.update_queue(
|
||||||
|
os.path.basename(deck.queued.filepath)
|
||||||
|
)
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
# fingerprint_widget.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import requests
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QLabel,
|
||||||
|
QPushButton, QProgressBar, QFrame
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt, QTimer, Signal
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
|
||||||
|
|
||||||
|
class FingerprintWidget(QFrame):
|
||||||
|
result_ready = Signal(object)
|
||||||
|
error_ready = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, engine, api_key: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.engine = engine
|
||||||
|
self.acoustid_key = api_key.strip()
|
||||||
|
self.recording_seconds = 25
|
||||||
|
self.last_signal_check = False
|
||||||
|
|
||||||
|
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #0d0d1a;
|
||||||
|
border: 2px solid #4444aa;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self.result_ready.connect(self._show_result)
|
||||||
|
self.error_ready.connect(self._show_error)
|
||||||
|
|
||||||
|
self.signal_timer = QTimer(self)
|
||||||
|
self.signal_timer.setInterval(500)
|
||||||
|
self.signal_timer.timeout.connect(self._check_signal)
|
||||||
|
self.signal_timer.start()
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(10, 8, 10, 8)
|
||||||
|
root.setSpacing(6)
|
||||||
|
|
||||||
|
header = QLabel("AUDIO FINGERPRINT")
|
||||||
|
header.setFont(QFont("Arial", 11, QFont.Bold))
|
||||||
|
header.setStyleSheet("color: #6666cc; background: transparent; border: none;")
|
||||||
|
header.setAlignment(Qt.AlignCenter)
|
||||||
|
root.addWidget(header)
|
||||||
|
|
||||||
|
port_info = QLabel("JACK: RadioPanel:fingerprint_L / R")
|
||||||
|
port_info.setFont(QFont("Courier New", 8))
|
||||||
|
port_info.setStyleSheet("color: #444488; background: transparent; border: none;")
|
||||||
|
port_info.setAlignment(Qt.AlignCenter)
|
||||||
|
root.addWidget(port_info)
|
||||||
|
|
||||||
|
self.signal_label = QLabel("● NO SIGNAL")
|
||||||
|
self.signal_label.setFont(QFont("Courier New", 14, QFont.Bold))
|
||||||
|
self.signal_label.setStyleSheet("color: #330000; background: transparent; border: none;")
|
||||||
|
self.signal_label.setAlignment(Qt.AlignCenter)
|
||||||
|
root.addWidget(self.signal_label)
|
||||||
|
|
||||||
|
self.progress = QProgressBar()
|
||||||
|
self.progress.setRange(0, self.recording_seconds * 10)
|
||||||
|
self.progress.setValue(0)
|
||||||
|
self.progress.setTextVisible(False)
|
||||||
|
self.progress.setFixedHeight(8)
|
||||||
|
self.progress.setStyleSheet("""
|
||||||
|
QProgressBar {
|
||||||
|
border: 1px solid #4444aa;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #0a0a1a;
|
||||||
|
}
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background-color: #6666cc;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
self.progress.hide()
|
||||||
|
root.addWidget(self.progress)
|
||||||
|
|
||||||
|
self.status_label = QLabel("READY")
|
||||||
|
self.status_label.setFont(QFont("Courier New", 10, QFont.Bold))
|
||||||
|
self.status_label.setStyleSheet("color: #444488; background: transparent; border: none;")
|
||||||
|
self.status_label.setAlignment(Qt.AlignCenter)
|
||||||
|
root.addWidget(self.status_label)
|
||||||
|
|
||||||
|
result_frame = QFrame()
|
||||||
|
result_frame.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #0a0a1a;
|
||||||
|
border: 1px solid #333366;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
result_layout = QVBoxLayout(result_frame)
|
||||||
|
result_layout.setContentsMargins(6, 6, 6, 6)
|
||||||
|
|
||||||
|
self.result_artist = QLabel("Artist: -")
|
||||||
|
self.result_artist.setFont(QFont("Courier New", 10, QFont.Bold))
|
||||||
|
self.result_artist.setStyleSheet("color: #555588;")
|
||||||
|
|
||||||
|
self.result_title = QLabel("Title: -")
|
||||||
|
self.result_title.setFont(QFont("Courier New", 11, QFont.Bold))
|
||||||
|
self.result_title.setStyleSheet("color: #555588;")
|
||||||
|
|
||||||
|
result_layout.addWidget(self.result_artist)
|
||||||
|
result_layout.addWidget(self.result_title)
|
||||||
|
root.addWidget(result_frame)
|
||||||
|
|
||||||
|
self.btn_fingerprint = QPushButton("FINGERPRINT")
|
||||||
|
self.btn_fingerprint.setFixedHeight(44)
|
||||||
|
self.btn_fingerprint.setFont(QFont("Arial", 12, QFont.Bold))
|
||||||
|
self.btn_fingerprint.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #4444aa;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #6666cc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #5555bb; }
|
||||||
|
QPushButton:pressed { background-color: #333399; }
|
||||||
|
QPushButton:disabled {
|
||||||
|
background-color: #222244;
|
||||||
|
color: #666688;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
self.btn_fingerprint.clicked.connect(self._start_fingerprint)
|
||||||
|
root.addWidget(self.btn_fingerprint)
|
||||||
|
|
||||||
|
root.addStretch()
|
||||||
|
|
||||||
|
def _check_signal(self):
|
||||||
|
if self.engine.recording:
|
||||||
|
return
|
||||||
|
levels = self.engine.fingerprint_levels
|
||||||
|
has_signal = (levels[0] > 0.01 or levels[1] > 0.01)
|
||||||
|
if has_signal != self.last_signal_check:
|
||||||
|
self.last_signal_check = has_signal
|
||||||
|
if has_signal:
|
||||||
|
self.signal_label.setText("● SIGNAL PRESENT")
|
||||||
|
self.signal_label.setStyleSheet("color: #00ff41; background: transparent;")
|
||||||
|
else:
|
||||||
|
self.signal_label.setText("● NO SIGNAL")
|
||||||
|
self.signal_label.setStyleSheet("color: #330000; background: transparent;")
|
||||||
|
|
||||||
|
def _start_fingerprint(self):
|
||||||
|
if not self.acoustid_key:
|
||||||
|
self.error_ready.emit("Set your AcoustID key in main.py first!")
|
||||||
|
return
|
||||||
|
if self.engine.recording:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.btn_fingerprint.setEnabled(False)
|
||||||
|
self.progress.show()
|
||||||
|
self.progress.setValue(0)
|
||||||
|
self.status_label.setText("LISTENING...")
|
||||||
|
self.status_label.setStyleSheet("color: #ccaa00;")
|
||||||
|
self.signal_label.setText("● RECORDING...")
|
||||||
|
self.signal_label.setStyleSheet("color: #ccaa00;")
|
||||||
|
self.result_artist.setText("Artist: -")
|
||||||
|
self.result_title.setText("Title: -")
|
||||||
|
|
||||||
|
self.recording_timer = QTimer(self)
|
||||||
|
self.recording_timer.setInterval(100)
|
||||||
|
self.recording_timer.timeout.connect(self._update_progress)
|
||||||
|
self.recording_timer.start()
|
||||||
|
|
||||||
|
self.engine.start_recording(self.recording_seconds, self._on_recording_finished)
|
||||||
|
|
||||||
|
def _update_progress(self):
|
||||||
|
if not self.engine.recording:
|
||||||
|
self.recording_timer.stop()
|
||||||
|
self.progress.setValue(self.progress.maximum())
|
||||||
|
return
|
||||||
|
if self.engine.record_buffer is not None:
|
||||||
|
progress = self.engine.record_position / self.engine.record_target_frames
|
||||||
|
self.progress.setValue(int(progress * self.progress.maximum()))
|
||||||
|
|
||||||
|
def _on_recording_finished(self, audio_data: np.ndarray, sr: int):
|
||||||
|
self.recording_timer.stop()
|
||||||
|
self.status_label.setText("ANALYZING...")
|
||||||
|
self.status_label.setStyleSheet("color: #4488ff;")
|
||||||
|
self.signal_label.setText("● PROCESSING")
|
||||||
|
self.signal_label.setStyleSheet("color: #4488ff;")
|
||||||
|
threading.Thread(
|
||||||
|
target=self._fingerprint_thread,
|
||||||
|
args=(audio_data, sr),
|
||||||
|
daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _fingerprint_thread(self, audio_data: np.ndarray, sr: int):
|
||||||
|
try:
|
||||||
|
result = self._identify_acoustid(audio_data, sr)
|
||||||
|
self.result_ready.emit(result)
|
||||||
|
except Exception as e:
|
||||||
|
err_str = str(e)
|
||||||
|
if "Invalid API key" in err_str:
|
||||||
|
err_str = "Invalid API Key"
|
||||||
|
elif "limit" in err_str.lower():
|
||||||
|
err_str = "Daily limit reached"
|
||||||
|
self.error_ready.emit(err_str)
|
||||||
|
|
||||||
|
def _identify_acoustid(self, audio_data: np.ndarray, sr: int) -> dict:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
||||||
|
temp_path = f.name
|
||||||
|
try:
|
||||||
|
sf.write(temp_path, audio_data.T, sr, subtype='PCM_16')
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
['fpcalc', '-json', temp_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"fpcalc failed: {result.stderr}")
|
||||||
|
|
||||||
|
fp_data = json.loads(result.stdout)
|
||||||
|
fingerprint = fp_data['fingerprint']
|
||||||
|
duration = fp_data.get('duration', 0)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
|
||||||
|
url = 'https://api.acoustid.org/v2/lookup'
|
||||||
|
params = {
|
||||||
|
'client': self.acoustid_key,
|
||||||
|
'fingerprint': fingerprint,
|
||||||
|
'duration': int(duration),
|
||||||
|
'meta': 'recordings'
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(url, params=params, timeout=15)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get('status') != 'ok':
|
||||||
|
err = data.get('error', {}).get('message', 'Unknown')
|
||||||
|
raise RuntimeError(f"AcoustID: {err}")
|
||||||
|
|
||||||
|
results = data.get('results', [])
|
||||||
|
if not results:
|
||||||
|
return None
|
||||||
|
|
||||||
|
best = results[0]
|
||||||
|
recordings = best.get('recordings', [])
|
||||||
|
if not recordings:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rec = recordings[0]
|
||||||
|
title = rec.get('title', 'Unknown')
|
||||||
|
artists = rec.get('artists', [])
|
||||||
|
artist = artists[0].get('name', 'Unknown') if artists else 'Unknown'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'artist': artist,
|
||||||
|
'title': title,
|
||||||
|
'score': best.get('score', 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _show_result(self, result):
|
||||||
|
self.btn_fingerprint.setEnabled(True)
|
||||||
|
self.progress.hide()
|
||||||
|
self.signal_label.setText("● DONE")
|
||||||
|
self.signal_label.setStyleSheet("color: #00aa33;")
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
self.status_label.setText("NO MATCH")
|
||||||
|
self.status_label.setStyleSheet("color: #aa4444;")
|
||||||
|
else:
|
||||||
|
self.status_label.setText(f"MATCH {result['score']:.0%}")
|
||||||
|
self.status_label.setStyleSheet("color: #00ff41;")
|
||||||
|
self.result_artist.setText(f"Artist: {result['artist']}")
|
||||||
|
self.result_title.setText(f"Title: {result['title']}")
|
||||||
|
self.result_artist.setStyleSheet("color: #cccccc;")
|
||||||
|
self.result_title.setStyleSheet("color: #00ff41;")
|
||||||
|
|
||||||
|
def _show_error(self, error: str):
|
||||||
|
self.btn_fingerprint.setEnabled(True)
|
||||||
|
self.progress.hide()
|
||||||
|
self.signal_label.setText("● ERROR")
|
||||||
|
self.signal_label.setStyleSheet("color: #aa4444;")
|
||||||
|
self.status_label.setText("ERROR")
|
||||||
|
self.status_label.setStyleSheet("color: #ff4444;")
|
||||||
|
self.result_artist.setText("Error:")
|
||||||
|
self.result_title.setText(error[:60])
|
||||||
|
self.result_artist.setStyleSheet("color: #aa4444;")
|
||||||
|
self.result_title.setStyleSheet("color: #aa4444;")
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# history_widget.py
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import musicbrainzngs
|
||||||
|
from mutagen import File as MutagenFile
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFrame, QVBoxLayout, QLabel,
|
||||||
|
QScrollArea, QWidget
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
|
||||||
|
musicbrainzngs.set_useragent("RadioPanel", "1.0", "radio@panel.local")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_file_tags(filepath: str) -> dict:
|
||||||
|
try:
|
||||||
|
tags = MutagenFile(filepath, easy=True)
|
||||||
|
if tags is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get(key):
|
||||||
|
val = tags.get(key)
|
||||||
|
return val[0] if val else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'artist': get('artist'),
|
||||||
|
'title': get('title'),
|
||||||
|
'album': get('album'),
|
||||||
|
'year': get('date'),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_musicbrainz(artist: str, title: str) -> dict:
|
||||||
|
try:
|
||||||
|
result = musicbrainzngs.search_recordings(
|
||||||
|
recording=title,
|
||||||
|
artist=artist,
|
||||||
|
limit=1
|
||||||
|
)
|
||||||
|
recordings = result.get('recording-list', [])
|
||||||
|
if not recordings:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
rec = recordings[0]
|
||||||
|
releases = rec.get('release-list', [])
|
||||||
|
if not releases:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
release = releases[0]
|
||||||
|
album = release.get('title', None)
|
||||||
|
year = None
|
||||||
|
|
||||||
|
date = release.get('date', '')
|
||||||
|
if date:
|
||||||
|
year = date[:4]
|
||||||
|
|
||||||
|
return {'album': album, 'year': year}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryEntry(QFrame):
|
||||||
|
def __init__(self, index: int, filepath: str, metadata: dict, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.setFrameStyle(QFrame.Box)
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(8, 6, 8, 6)
|
||||||
|
layout.setSpacing(2)
|
||||||
|
|
||||||
|
artist = metadata.get('artist') or 'Unknown Artist'
|
||||||
|
title = metadata.get('title') or self._filename(filepath)
|
||||||
|
|
||||||
|
top_row = QLabel(f"{index}. {artist} — {title}")
|
||||||
|
top_row.setFont(QFont("Arial", 11, QFont.Bold))
|
||||||
|
top_row.setStyleSheet("color: #00ff41; background: transparent; border: none;")
|
||||||
|
top_row.setWordWrap(True)
|
||||||
|
layout.addWidget(top_row)
|
||||||
|
|
||||||
|
album = metadata.get('album') or ''
|
||||||
|
year = metadata.get('year') or ''
|
||||||
|
|
||||||
|
sub_parts = []
|
||||||
|
if album:
|
||||||
|
sub_parts.append(album)
|
||||||
|
if year:
|
||||||
|
sub_parts.append(year[:4])
|
||||||
|
|
||||||
|
if sub_parts:
|
||||||
|
sub_row = QLabel(" " + " · ".join(sub_parts))
|
||||||
|
sub_row.setFont(QFont("Arial", 9))
|
||||||
|
sub_row.setStyleSheet(
|
||||||
|
"color: #888888; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
sub_row.setWordWrap(True)
|
||||||
|
layout.addWidget(sub_row)
|
||||||
|
|
||||||
|
def _filename(self, filepath: str) -> str:
|
||||||
|
import os
|
||||||
|
name = os.path.basename(filepath)
|
||||||
|
return os.path.splitext(name)[0]
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryWidget(QFrame):
|
||||||
|
_metadata_ready = Signal(str, dict)
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self._history = []
|
||||||
|
self._pending = {}
|
||||||
|
|
||||||
|
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #111111;
|
||||||
|
border: 2px solid #336633;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self._metadata_ready.connect(self._on_metadata_ready)
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(8, 8, 8, 8)
|
||||||
|
root.setSpacing(6)
|
||||||
|
|
||||||
|
header = QLabel("PLAY HISTORY")
|
||||||
|
header.setFont(QFont("Arial", 11, QFont.Bold))
|
||||||
|
header.setStyleSheet("""
|
||||||
|
color: #336633;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 2px;
|
||||||
|
""")
|
||||||
|
header.setAlignment(Qt.AlignCenter)
|
||||||
|
root.addWidget(header)
|
||||||
|
|
||||||
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||||
|
self.scroll.setStyleSheet("""
|
||||||
|
QScrollArea {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background: #1a1a1a;
|
||||||
|
width: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background: #336633;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
self.list_widget = QWidget()
|
||||||
|
self.list_widget.setStyleSheet("background: transparent;")
|
||||||
|
self.list_layout = QVBoxLayout(self.list_widget)
|
||||||
|
self.list_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.list_layout.setSpacing(0)
|
||||||
|
self.list_layout.addStretch()
|
||||||
|
|
||||||
|
self.scroll.setWidget(self.list_widget)
|
||||||
|
root.addWidget(self.scroll)
|
||||||
|
|
||||||
|
def add_track(self, filepath: str):
|
||||||
|
index = len(self._history) + 1
|
||||||
|
metadata = _read_file_tags(filepath)
|
||||||
|
self._history.append((filepath, metadata))
|
||||||
|
self._insert_entry(index, filepath, metadata)
|
||||||
|
|
||||||
|
if not metadata.get('artist') or not metadata.get('album'):
|
||||||
|
self._pending[filepath] = index
|
||||||
|
threading.Thread(
|
||||||
|
target=self._enrich_metadata,
|
||||||
|
args=(filepath, metadata),
|
||||||
|
daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _insert_entry(self, index: int, filepath: str, metadata: dict):
|
||||||
|
entry = HistoryEntry(index, filepath, metadata)
|
||||||
|
self.list_layout.insertWidget(0, entry)
|
||||||
|
self.scroll.verticalScrollBar().setValue(0)
|
||||||
|
|
||||||
|
def _enrich_metadata(self, filepath: str, existing: dict):
|
||||||
|
artist = existing.get('artist')
|
||||||
|
title = existing.get('title')
|
||||||
|
|
||||||
|
if not artist or not title:
|
||||||
|
return
|
||||||
|
|
||||||
|
mb_data = _lookup_musicbrainz(artist, title)
|
||||||
|
if not mb_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
merged = dict(existing)
|
||||||
|
if not merged.get('album') and mb_data.get('album'):
|
||||||
|
merged['album'] = mb_data['album']
|
||||||
|
if not merged.get('year') and mb_data.get('year'):
|
||||||
|
merged['year'] = mb_data['year']
|
||||||
|
|
||||||
|
self._metadata_ready.emit(filepath, merged)
|
||||||
|
|
||||||
|
def _on_metadata_ready(self, filepath: str, metadata: dict):
|
||||||
|
for i, (fp, _) in enumerate(self._history):
|
||||||
|
if fp == filepath:
|
||||||
|
self._history[i] = (fp, metadata)
|
||||||
|
break
|
||||||
|
self._rebuild_list()
|
||||||
|
|
||||||
|
def _rebuild_list(self):
|
||||||
|
while self.list_layout.count() > 1:
|
||||||
|
item = self.list_layout.takeAt(0)
|
||||||
|
if item.widget():
|
||||||
|
item.widget().deleteLater()
|
||||||
|
|
||||||
|
for i, (filepath, metadata) in enumerate(reversed(self._history)):
|
||||||
|
index = len(self._history) - i
|
||||||
|
entry = HistoryEntry(index, filepath, metadata)
|
||||||
|
self.list_layout.insertWidget(
|
||||||
|
self.list_layout.count() - 1,
|
||||||
|
entry
|
||||||
|
)
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# main.py
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication, QMainWindow, QWidget,
|
||||||
|
QVBoxLayout, QHBoxLayout, QSplitter
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QFont, QColor, QPalette
|
||||||
|
|
||||||
|
from audio_engine import AudioEngine
|
||||||
|
from deck_widget import DeckWidget
|
||||||
|
from cart_widget import CartBankWidget
|
||||||
|
from playlist_widget import PlaylistWidget
|
||||||
|
from fingerprint_widget import FingerprintWidget
|
||||||
|
from midi_engine import MidiEngine
|
||||||
|
from top_bar_widget import TopBarWidget, THEMES
|
||||||
|
from history_widget import HistoryWidget
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# AcoustID API Key
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
ACOUSTID_API_KEY = "Y0RZoLiEmr"
|
||||||
|
|
||||||
|
|
||||||
|
class RadioPanelWindow(QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.engine = AudioEngine()
|
||||||
|
|
||||||
|
self.setWindowTitle("Radio Panel")
|
||||||
|
self.setMinimumSize(1280, 720)
|
||||||
|
self.resize(1920, 1080)
|
||||||
|
self._apply_global_style(THEMES[0])
|
||||||
|
|
||||||
|
central = QWidget()
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
|
||||||
|
outer = QVBoxLayout(central)
|
||||||
|
outer.setContentsMargins(8, 8, 8, 8)
|
||||||
|
outer.setSpacing(8)
|
||||||
|
|
||||||
|
# Top bar
|
||||||
|
self.top_bar = TopBarWidget()
|
||||||
|
self.top_bar.theme_changed.connect(self._on_theme_changed)
|
||||||
|
outer.addWidget(self.top_bar)
|
||||||
|
|
||||||
|
# ── Build widgets ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
self.deck1 = DeckWidget(
|
||||||
|
deck_key="deck1",
|
||||||
|
label="DECK 1",
|
||||||
|
accent_color="#00897b",
|
||||||
|
engine=self.engine
|
||||||
|
)
|
||||||
|
self.deck2 = DeckWidget(
|
||||||
|
deck_key="deck2",
|
||||||
|
label="DECK 2",
|
||||||
|
accent_color="#f9ca24",
|
||||||
|
engine=self.engine
|
||||||
|
)
|
||||||
|
|
||||||
|
self.cart_bank = CartBankWidget(self.engine)
|
||||||
|
self.cart_bank.setFixedHeight(280)
|
||||||
|
|
||||||
|
self.playlist_widget = PlaylistWidget(
|
||||||
|
engine=self.engine,
|
||||||
|
deck_widgets={
|
||||||
|
"deck1": self.deck1,
|
||||||
|
"deck2": self.deck2,
|
||||||
|
},
|
||||||
|
cart_slots=self.cart_bank.slots
|
||||||
|
)
|
||||||
|
|
||||||
|
self.history_widget = HistoryWidget()
|
||||||
|
|
||||||
|
self.fingerprint_panel = FingerprintWidget(
|
||||||
|
engine=self.engine,
|
||||||
|
api_key=ACOUSTID_API_KEY
|
||||||
|
)
|
||||||
|
self.fingerprint_panel.setFixedHeight(240)
|
||||||
|
|
||||||
|
# ── Centre column ─────────────────────────────────────────────────
|
||||||
|
centre_col = QVBoxLayout()
|
||||||
|
centre_col.setSpacing(8)
|
||||||
|
|
||||||
|
deck_row = QHBoxLayout()
|
||||||
|
deck_row.setSpacing(8)
|
||||||
|
deck_row.addWidget(self.deck1)
|
||||||
|
deck_row.addWidget(self.deck2)
|
||||||
|
centre_col.addLayout(deck_row)
|
||||||
|
|
||||||
|
centre_col.addWidget(self.cart_bank)
|
||||||
|
centre_col.addWidget(self.fingerprint_panel)
|
||||||
|
centre_col.addStretch()
|
||||||
|
|
||||||
|
centre_widget = QWidget()
|
||||||
|
centre_widget.setStyleSheet("background: transparent; border: none;")
|
||||||
|
centre_widget.setLayout(centre_col)
|
||||||
|
|
||||||
|
# ── Splitter ──────────────────────────────────────────────────────
|
||||||
|
self.splitter = QSplitter(Qt.Horizontal)
|
||||||
|
self.splitter.setHandleWidth(6)
|
||||||
|
self._style_splitter(THEMES[0])
|
||||||
|
|
||||||
|
self.splitter.addWidget(self.playlist_widget)
|
||||||
|
self.splitter.addWidget(centre_widget)
|
||||||
|
self.splitter.addWidget(self.history_widget)
|
||||||
|
self.splitter.setSizes([400, 1120, 400])
|
||||||
|
|
||||||
|
outer.addWidget(self.splitter)
|
||||||
|
|
||||||
|
# ── Wire signals ──────────────────────────────────────────────────
|
||||||
|
self.deck1.track_started.connect(self.history_widget.add_track)
|
||||||
|
self.deck2.track_started.connect(self.history_widget.add_track)
|
||||||
|
|
||||||
|
# ── MIDI ──────────────────────────────────────────────────────────
|
||||||
|
self._setup_midi()
|
||||||
|
|
||||||
|
def _style_splitter(self, theme: dict):
|
||||||
|
self.splitter.setStyleSheet(f"""
|
||||||
|
QSplitter::handle {{
|
||||||
|
background-color: #333333;
|
||||||
|
border-radius: 3px;
|
||||||
|
}}
|
||||||
|
QSplitter::handle:hover {{
|
||||||
|
background-color: {theme['accent']};
|
||||||
|
}}
|
||||||
|
QSplitter::handle:pressed {{
|
||||||
|
background-color: {theme['accent_dim']};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def _on_theme_changed(self, theme: dict):
|
||||||
|
self._apply_global_style(theme)
|
||||||
|
self._style_splitter(theme)
|
||||||
|
|
||||||
|
def _apply_global_style(self, theme: dict):
|
||||||
|
self.setStyleSheet(f"""
|
||||||
|
QMainWindow {{
|
||||||
|
background-color: {theme['bg']};
|
||||||
|
}}
|
||||||
|
QWidget {{
|
||||||
|
background-color: {theme['bg']};
|
||||||
|
color: {theme['text']};
|
||||||
|
font-family: Arial;
|
||||||
|
}}
|
||||||
|
QToolTip {{
|
||||||
|
background-color: {theme['bg2']};
|
||||||
|
color: {theme['text']};
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 4px;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def _setup_midi(self):
|
||||||
|
self.midi = MidiEngine()
|
||||||
|
|
||||||
|
self.midi.deck_play_pause.connect(self._on_midi_play_pause)
|
||||||
|
self.midi.deck_cue.connect(self._on_midi_cue)
|
||||||
|
self.midi.deck_jog.connect(self._on_midi_jog)
|
||||||
|
self.midi.cart_trigger.connect(self._on_midi_cart)
|
||||||
|
self.midi.controller_connected.connect(self._on_midi_status)
|
||||||
|
|
||||||
|
self.midi.start()
|
||||||
|
|
||||||
|
def _on_midi_play_pause(self, deck: str):
|
||||||
|
if deck == 'deck1':
|
||||||
|
self.deck1.toggle_play()
|
||||||
|
elif deck == 'deck2':
|
||||||
|
self.deck2.toggle_play()
|
||||||
|
|
||||||
|
def _on_midi_cue(self, deck: str):
|
||||||
|
if deck == 'deck1':
|
||||||
|
self.deck1.cue()
|
||||||
|
elif deck == 'deck2':
|
||||||
|
self.deck2.cue()
|
||||||
|
|
||||||
|
def _on_midi_jog(self, deck: str, delta: float):
|
||||||
|
if deck == 'deck1':
|
||||||
|
self.deck1.scrub(delta)
|
||||||
|
elif deck == 'deck2':
|
||||||
|
self.deck2.scrub(delta)
|
||||||
|
|
||||||
|
def _on_midi_cart(self, slot: int):
|
||||||
|
self.cart_bank.slots[slot].toggle()
|
||||||
|
|
||||||
|
def _on_midi_status(self, connected: bool, name: str):
|
||||||
|
if connected:
|
||||||
|
print(f"[MIDI] Connected: {name}")
|
||||||
|
else:
|
||||||
|
print(f"[MIDI] Not connected: {name}")
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
self.midi.stop()
|
||||||
|
self.engine.shutdown()
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName("Radio Panel")
|
||||||
|
app.setOrganizationName("GTI")
|
||||||
|
|
||||||
|
palette = QPalette()
|
||||||
|
palette.setColor(QPalette.Window, QColor("#0d0d0d"))
|
||||||
|
palette.setColor(QPalette.WindowText, QColor("#cccccc"))
|
||||||
|
palette.setColor(QPalette.Base, QColor("#111111"))
|
||||||
|
palette.setColor(QPalette.AlternateBase, QColor("#1a1a1a"))
|
||||||
|
palette.setColor(QPalette.Text, QColor("#cccccc"))
|
||||||
|
palette.setColor(QPalette.Button, QColor("#2a2a2a"))
|
||||||
|
palette.setColor(QPalette.ButtonText, QColor("#cccccc"))
|
||||||
|
palette.setColor(QPalette.Highlight, QColor("#1a3a5a"))
|
||||||
|
palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
|
||||||
|
app.setPalette(palette)
|
||||||
|
|
||||||
|
window = RadioPanelWindow()
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
# midi_engine.py
|
||||||
|
"""
|
||||||
|
MIDI Engine for Radio Panel
|
||||||
|
Numark DJ2Go controller support.
|
||||||
|
|
||||||
|
Confirmed mapping from hardware testing:
|
||||||
|
------------------------------------------
|
||||||
|
Play/Pause Deck 1 : Note 0x3B ch=0
|
||||||
|
Play/Pause Deck 2 : Note 0x42 ch=0
|
||||||
|
Cue Deck 1 : Note 0x33 ch=0
|
||||||
|
Cue Deck 2 : Note 0x3C ch=0
|
||||||
|
Jog Wheel Deck 1 : CC 0x19 ch=0
|
||||||
|
Jog Wheel Deck 2 : CC 0x18 ch=0
|
||||||
|
Cart 1 : Note 0x44 ch=0
|
||||||
|
Cart 2 : Note 0x43 ch=0
|
||||||
|
Cart 3 : Note 0x46 ch=0
|
||||||
|
Cart 4 : Note 0x45 ch=0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import rtmidi
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# MIDI Constants
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
NOTE_ON = 0x90
|
||||||
|
NOTE_OFF = 0x80
|
||||||
|
CC = 0xB0
|
||||||
|
|
||||||
|
# All controls on ch=0, differentiated by note/CC number only
|
||||||
|
DECK1_PLAY = 0x3B
|
||||||
|
DECK1_CUE = 0x33
|
||||||
|
DECK1_JOG = 0x19
|
||||||
|
|
||||||
|
DECK2_PLAY = 0x42
|
||||||
|
DECK2_CUE = 0x3C
|
||||||
|
DECK2_JOG = 0x18
|
||||||
|
|
||||||
|
# Cart buttons - confirmed from hardware test
|
||||||
|
CART_NOTES = {
|
||||||
|
0x44: 0, # Cart slot 1
|
||||||
|
0x43: 1, # Cart slot 2
|
||||||
|
0x46: 2, # Cart slot 3
|
||||||
|
0x45: 3, # Cart slot 4
|
||||||
|
}
|
||||||
|
|
||||||
|
JOG_SCRUB_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_relative_jog(value: int) -> int:
|
||||||
|
if 1 <= value <= 63:
|
||||||
|
return 1
|
||||||
|
elif 65 <= value <= 127:
|
||||||
|
return -1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class MidiEngine(QObject):
|
||||||
|
deck_play_pause = Signal(str)
|
||||||
|
deck_cue = Signal(str)
|
||||||
|
deck_jog = Signal(str, float)
|
||||||
|
cart_trigger = Signal(int)
|
||||||
|
controller_connected = Signal(bool, str)
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._midi_in: Optional[rtmidi.MidiIn] = None
|
||||||
|
self._port_name: str = ""
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def start(self, port_index: Optional[int] = None) -> bool:
|
||||||
|
try:
|
||||||
|
self._midi_in = rtmidi.MidiIn()
|
||||||
|
self._midi_in.ignore_types(sysex=True, timing=True, active_sense=True)
|
||||||
|
|
||||||
|
available_ports = self._midi_in.get_ports()
|
||||||
|
logger.info(f"Available MIDI ports: {available_ports}")
|
||||||
|
|
||||||
|
if not available_ports:
|
||||||
|
logger.error("No MIDI ports found.")
|
||||||
|
self.controller_connected.emit(False, "No MIDI ports found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if port_index is None:
|
||||||
|
port_index = self._find_numark_port(available_ports)
|
||||||
|
|
||||||
|
self._port_name = available_ports[port_index]
|
||||||
|
self._midi_in.open_port(port_index)
|
||||||
|
self._midi_in.set_callback(self._midi_callback)
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
logger.info(f"MIDI connected: {self._port_name}")
|
||||||
|
self.controller_connected.emit(True, self._port_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to open MIDI port: {e}")
|
||||||
|
self.controller_connected.emit(False, str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
if self._midi_in:
|
||||||
|
try:
|
||||||
|
self._midi_in.cancel_callback()
|
||||||
|
self._midi_in.close_port()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing MIDI port: {e}")
|
||||||
|
finally:
|
||||||
|
del self._midi_in
|
||||||
|
self._midi_in = None
|
||||||
|
logger.info("MIDI engine stopped.")
|
||||||
|
|
||||||
|
def list_ports(self) -> list[str]:
|
||||||
|
try:
|
||||||
|
tmp = rtmidi.MidiIn()
|
||||||
|
ports = tmp.get_ports()
|
||||||
|
del tmp
|
||||||
|
return ports
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _find_numark_port(self, ports: list[str]) -> int:
|
||||||
|
for i, name in enumerate(ports):
|
||||||
|
if "numark" in name.lower():
|
||||||
|
logger.info(f"Auto-detected Numark on port {i}: {name}")
|
||||||
|
return i
|
||||||
|
logger.warning("No Numark port found, defaulting to port 0.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _midi_callback(self, event, data=None):
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
message, _ = event
|
||||||
|
|
||||||
|
if len(message) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
status = message[0]
|
||||||
|
data_byte = message[1]
|
||||||
|
value = message[2] if len(message) > 2 else 0
|
||||||
|
|
||||||
|
status_type = status & 0xF0
|
||||||
|
channel = status & 0x0F
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"MIDI IN status=0x{status:02X} "
|
||||||
|
f"data=0x{data_byte:02X} "
|
||||||
|
f"val={value} "
|
||||||
|
f"type=0x{status_type:02X} ch={channel}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if status_type == NOTE_ON and value > 0:
|
||||||
|
self._handle_note_on(data_byte)
|
||||||
|
elif status_type == CC:
|
||||||
|
self._handle_cc(data_byte, value)
|
||||||
|
|
||||||
|
def _handle_note_on(self, note: int):
|
||||||
|
if note == DECK1_PLAY:
|
||||||
|
logger.debug("DECK 1 PLAY/PAUSE")
|
||||||
|
self.deck_play_pause.emit('deck1')
|
||||||
|
|
||||||
|
elif note == DECK1_CUE:
|
||||||
|
logger.debug("DECK 1 CUE")
|
||||||
|
self.deck_cue.emit('deck1')
|
||||||
|
|
||||||
|
elif note == DECK2_PLAY:
|
||||||
|
logger.debug("DECK 2 PLAY/PAUSE")
|
||||||
|
self.deck_play_pause.emit('deck2')
|
||||||
|
|
||||||
|
elif note == DECK2_CUE:
|
||||||
|
logger.debug("DECK 2 CUE")
|
||||||
|
self.deck_cue.emit('deck2')
|
||||||
|
|
||||||
|
elif note in CART_NOTES:
|
||||||
|
slot = CART_NOTES[note]
|
||||||
|
logger.debug(f"CART {slot + 1} TRIGGER")
|
||||||
|
self.cart_trigger.emit(slot)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.debug(f"Unmapped note: 0x{note:02X}")
|
||||||
|
|
||||||
|
def _handle_cc(self, cc_num: int, value: int):
|
||||||
|
direction = _decode_relative_jog(value)
|
||||||
|
if direction == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
delta = direction * JOG_SCRUB_SECONDS
|
||||||
|
|
||||||
|
if cc_num == DECK1_JOG:
|
||||||
|
logger.debug(f"DECK 1 JOG delta={delta:+.1f}s")
|
||||||
|
self.deck_jog.emit('deck1', delta)
|
||||||
|
|
||||||
|
elif cc_num == DECK2_JOG:
|
||||||
|
logger.debug(f"DECK 2 JOG delta={delta:+.1f}s")
|
||||||
|
self.deck_jog.emit('deck2', delta)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.debug(f"Unmapped CC: cc=0x{cc_num:02X} val={value}")
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
# playlist_widget.py
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QFileDialog, QFrame, QSizePolicy,
|
||||||
|
QListWidget, QListWidgetItem, QAbstractItemView,
|
||||||
|
QMenu, QSplitter
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt, QTimer
|
||||||
|
from PySide6.QtGui import QFont, QColor, QAction
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# PLAYLIST ITEM
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class PlaylistItem(QListWidgetItem):
|
||||||
|
def __init__(self, filepath: str):
|
||||||
|
self.filepath = filepath
|
||||||
|
self.filename = os.path.basename(filepath)
|
||||||
|
display = os.path.splitext(self.filename)[0]
|
||||||
|
super().__init__(display)
|
||||||
|
self.setFont(QFont("Courier New", 10, QFont.Bold))
|
||||||
|
self.setForeground(QColor("#cccccc"))
|
||||||
|
self.setToolTip(filepath)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# HOUR DISPLAY + CLOCK
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class HourDisplay(QFrame):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setFrameStyle(QFrame.Box | QFrame.Sunken)
|
||||||
|
self.setFixedHeight(44)
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #0a0a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(8, 2, 8, 2)
|
||||||
|
|
||||||
|
self.hour_label = QLabel("HOUR --:--")
|
||||||
|
self.hour_label.setFont(QFont("Courier New", 12, QFont.Bold))
|
||||||
|
self.hour_label.setStyleSheet(
|
||||||
|
"color: #4488ff; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
self.hour_label.setAlignment(Qt.AlignCenter)
|
||||||
|
layout.addWidget(self.hour_label)
|
||||||
|
|
||||||
|
self.clock_label = QLabel("00:00:00")
|
||||||
|
self.clock_label.setFont(QFont("Courier New", 12, QFont.Bold))
|
||||||
|
self.clock_label.setStyleSheet(
|
||||||
|
"color: #aaaaaa; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
self.clock_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
|
layout.addWidget(self.clock_label)
|
||||||
|
|
||||||
|
self.clock_timer = QTimer(self)
|
||||||
|
self.clock_timer.setInterval(1000)
|
||||||
|
self.clock_timer.timeout.connect(self._update_clock)
|
||||||
|
self.clock_timer.start()
|
||||||
|
self._update_clock()
|
||||||
|
|
||||||
|
def _update_clock(self):
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.now()
|
||||||
|
self.clock_label.setText(now.strftime("%H:%M:%S"))
|
||||||
|
self.hour_label.setText(f"HOUR {now.strftime('%H:00')}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# DECK SELECTOR
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class DeckSelector(QFrame):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setFixedHeight(36)
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(6, 2, 6, 2)
|
||||||
|
layout.setSpacing(6)
|
||||||
|
|
||||||
|
label = QLabel("SEND TO:")
|
||||||
|
label.setFont(QFont("Arial", 9, QFont.Bold))
|
||||||
|
label.setStyleSheet("color: #888; background: transparent; border: none;")
|
||||||
|
layout.addWidget(label)
|
||||||
|
|
||||||
|
self.deck1_btn = QPushButton("DECK 1")
|
||||||
|
self.deck1_btn.setCheckable(True)
|
||||||
|
self.deck1_btn.setChecked(True)
|
||||||
|
self.deck1_btn.setFixedHeight(26)
|
||||||
|
self.deck1_btn.setFont(QFont("Arial", 9, QFont.Bold))
|
||||||
|
self._style_btn(self.deck1_btn, "#00897b")
|
||||||
|
|
||||||
|
self.deck2_btn = QPushButton("DECK 2")
|
||||||
|
self.deck2_btn.setCheckable(True)
|
||||||
|
self.deck2_btn.setFixedHeight(26)
|
||||||
|
self.deck2_btn.setFont(QFont("Arial", 9, QFont.Bold))
|
||||||
|
self._style_btn(self.deck2_btn, "#f9ca24")
|
||||||
|
|
||||||
|
self.deck1_btn.clicked.connect(lambda: self._select("deck1"))
|
||||||
|
self.deck2_btn.clicked.connect(lambda: self._select("deck2"))
|
||||||
|
|
||||||
|
layout.addWidget(self.deck1_btn)
|
||||||
|
layout.addWidget(self.deck2_btn)
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
self._selected = "deck1"
|
||||||
|
|
||||||
|
def _style_btn(self, btn: QPushButton, accent: str):
|
||||||
|
btn.setStyleSheet(f"""
|
||||||
|
QPushButton {{
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
color: #888;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}}
|
||||||
|
QPushButton:checked {{
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: {accent};
|
||||||
|
border: 1px solid {accent};
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background-color: #333; }}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def _select(self, deck: str):
|
||||||
|
self._selected = deck
|
||||||
|
self.deck1_btn.setChecked(deck == "deck1")
|
||||||
|
self.deck2_btn.setChecked(deck == "deck2")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_deck(self) -> str:
|
||||||
|
return self._selected
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# BASE PLAYLIST PANEL
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class BasePlaylistPanel(QWidget):
|
||||||
|
AUDIO_FILTER = (
|
||||||
|
"Audio Files "
|
||||||
|
"(*.wav *.flac *.mp3 *.m4a *.mp4 *.ogg *.opus *.aac *.aiff)"
|
||||||
|
)
|
||||||
|
AUDIO_EXTENSIONS = {
|
||||||
|
'.wav', '.flac', '.mp3', '.m4a', '.mp4',
|
||||||
|
'.ogg', '.opus', '.aac', '.aiff'
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
def _make_list(self) -> QListWidget:
|
||||||
|
lst = QListWidget()
|
||||||
|
lst.setStyleSheet("""
|
||||||
|
QListWidget {
|
||||||
|
background-color: #0d0d0d;
|
||||||
|
color: #cccccc;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
QListWidget::item {
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
QListWidget::item:selected {
|
||||||
|
background-color: #1a3a5a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
QListWidget::item:hover {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
}
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background: #1a1a1a;
|
||||||
|
width: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background: #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
lst.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
|
lst.setDragDropMode(QAbstractItemView.InternalMove)
|
||||||
|
lst.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
|
return lst
|
||||||
|
|
||||||
|
def _make_btn(self, text: str, color: str, height: int = 28) -> QPushButton:
|
||||||
|
btn = QPushButton(text)
|
||||||
|
btn.setFixedHeight(height)
|
||||||
|
btn.setFont(QFont("Arial", 9, QFont.Bold))
|
||||||
|
btn.setStyleSheet(f"""
|
||||||
|
QPushButton {{
|
||||||
|
background-color: {color};
|
||||||
|
color: #cccccc;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 3px;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background-color: #555; }}
|
||||||
|
QPushButton:pressed {{ background-color: #222; }}
|
||||||
|
""")
|
||||||
|
return btn
|
||||||
|
|
||||||
|
def _add_files_to(self, lst: QListWidget):
|
||||||
|
paths, _ = QFileDialog.getOpenFileNames(
|
||||||
|
self, "Add Files",
|
||||||
|
os.path.expanduser("~"),
|
||||||
|
self.AUDIO_FILTER
|
||||||
|
)
|
||||||
|
for path in paths:
|
||||||
|
self._add_item_to(lst, path)
|
||||||
|
self._update_count(lst)
|
||||||
|
|
||||||
|
def _add_folder_to(self, lst: QListWidget):
|
||||||
|
folder = QFileDialog.getExistingDirectory(
|
||||||
|
self, "Add Folder",
|
||||||
|
os.path.expanduser("~")
|
||||||
|
)
|
||||||
|
if not folder:
|
||||||
|
return
|
||||||
|
for root_dir, dirs, files in os.walk(folder):
|
||||||
|
dirs.sort()
|
||||||
|
for filename in sorted(files):
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
if ext in self.AUDIO_EXTENSIONS:
|
||||||
|
self._add_item_to(lst, os.path.join(root_dir, filename))
|
||||||
|
self._update_count(lst)
|
||||||
|
|
||||||
|
def _add_item_to(self, lst: QListWidget, filepath: str):
|
||||||
|
for i in range(lst.count()):
|
||||||
|
item = lst.item(i)
|
||||||
|
if isinstance(item, PlaylistItem) and item.filepath == filepath:
|
||||||
|
return
|
||||||
|
lst.addItem(PlaylistItem(filepath))
|
||||||
|
|
||||||
|
def _update_count(self, lst: QListWidget, label: QLabel = None):
|
||||||
|
n = lst.count()
|
||||||
|
if label:
|
||||||
|
label.setText(f"{n} track{'s' if n != 1 else ''}")
|
||||||
|
|
||||||
|
def _highlight(self, item: PlaylistItem,
|
||||||
|
active: bool = False, queued: bool = False):
|
||||||
|
if active:
|
||||||
|
item.setForeground(QColor("#00ff41"))
|
||||||
|
elif queued:
|
||||||
|
item.setForeground(QColor("#f9ca24"))
|
||||||
|
else:
|
||||||
|
item.setForeground(QColor("#cccccc"))
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# TRACK PLAYLIST PANEL
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class TrackPlaylistPanel(BasePlaylistPanel):
|
||||||
|
def __init__(self, engine, deck_widgets: dict, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.engine = engine
|
||||||
|
self.deck_widgets = deck_widgets
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QWidget {
|
||||||
|
background-color: #111111;
|
||||||
|
border: 2px solid #333333;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(8, 8, 8, 8)
|
||||||
|
root.setSpacing(6)
|
||||||
|
|
||||||
|
header = QLabel("PLAYLIST")
|
||||||
|
header.setFont(QFont("Arial", 12, QFont.Bold))
|
||||||
|
header.setStyleSheet(
|
||||||
|
"color: #aaaaaa; background: transparent; border: none; padding: 2px;"
|
||||||
|
)
|
||||||
|
header.setAlignment(Qt.AlignCenter)
|
||||||
|
root.addWidget(header)
|
||||||
|
|
||||||
|
self.hour_display = HourDisplay()
|
||||||
|
root.addWidget(self.hour_display)
|
||||||
|
|
||||||
|
self.deck_selector = DeckSelector()
|
||||||
|
root.addWidget(self.deck_selector)
|
||||||
|
|
||||||
|
self.playlist = self._make_list()
|
||||||
|
self.playlist.itemDoubleClicked.connect(self._send_to_deck)
|
||||||
|
self.playlist.customContextMenuRequested.connect(self._context_menu)
|
||||||
|
root.addWidget(self.playlist)
|
||||||
|
|
||||||
|
row1 = QHBoxLayout()
|
||||||
|
row1.setSpacing(4)
|
||||||
|
self.btn_add_files = self._make_btn("ADD FILES", "#2a5a8a")
|
||||||
|
self.btn_add_folder = self._make_btn("ADD FOLDER", "#2a4a6a")
|
||||||
|
row1.addWidget(self.btn_add_files)
|
||||||
|
row1.addWidget(self.btn_add_folder)
|
||||||
|
root.addLayout(row1)
|
||||||
|
|
||||||
|
row2 = QHBoxLayout()
|
||||||
|
row2.setSpacing(4)
|
||||||
|
self.btn_send_deck = self._make_btn("→ LOAD DECK", "#1a5a3a")
|
||||||
|
self.btn_send_queue = self._make_btn("→ QUEUE", "#1a3a2a")
|
||||||
|
row2.addWidget(self.btn_send_deck)
|
||||||
|
row2.addWidget(self.btn_send_queue)
|
||||||
|
root.addLayout(row2)
|
||||||
|
|
||||||
|
row3 = QHBoxLayout()
|
||||||
|
row3.setSpacing(4)
|
||||||
|
self.btn_remove = self._make_btn("REMOVE", "#5a1a1a")
|
||||||
|
self.btn_clear = self._make_btn("CLEAR ALL", "#3a1a1a")
|
||||||
|
row3.addWidget(self.btn_remove)
|
||||||
|
row3.addWidget(self.btn_clear)
|
||||||
|
root.addLayout(row3)
|
||||||
|
|
||||||
|
self.count_label = QLabel("0 tracks")
|
||||||
|
self.count_label.setFont(QFont("Courier New", 9))
|
||||||
|
self.count_label.setStyleSheet(
|
||||||
|
"color: #555; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
self.count_label.setAlignment(Qt.AlignCenter)
|
||||||
|
root.addWidget(self.count_label)
|
||||||
|
|
||||||
|
self.btn_add_files.clicked.connect(
|
||||||
|
lambda: self._add_files_to(self.playlist)
|
||||||
|
)
|
||||||
|
self.btn_add_folder.clicked.connect(
|
||||||
|
lambda: self._add_folder_to(self.playlist)
|
||||||
|
)
|
||||||
|
self.btn_send_deck.clicked.connect(self._send_to_deck)
|
||||||
|
self.btn_send_queue.clicked.connect(self._send_to_queue)
|
||||||
|
self.btn_remove.clicked.connect(self._remove_selected)
|
||||||
|
self.btn_clear.clicked.connect(self._clear_all)
|
||||||
|
|
||||||
|
def _send_to_deck(self, item=None):
|
||||||
|
if item is None:
|
||||||
|
item = self.playlist.currentItem()
|
||||||
|
if not isinstance(item, PlaylistItem):
|
||||||
|
return
|
||||||
|
deck_key = self.deck_selector.selected_deck
|
||||||
|
self.engine.load_track(deck_key, item.filepath)
|
||||||
|
name = os.path.splitext(item.filename)[0]
|
||||||
|
if deck_key in self.deck_widgets:
|
||||||
|
self.deck_widgets[deck_key].set_track_name(name)
|
||||||
|
self._highlight(item, active=True)
|
||||||
|
|
||||||
|
def _send_to_queue(self):
|
||||||
|
item = self.playlist.currentItem()
|
||||||
|
if not isinstance(item, PlaylistItem):
|
||||||
|
return
|
||||||
|
deck_key = self.deck_selector.selected_deck
|
||||||
|
self.engine.load_queue(deck_key, item.filepath)
|
||||||
|
name = os.path.splitext(item.filename)[0]
|
||||||
|
if deck_key in self.deck_widgets:
|
||||||
|
self.deck_widgets[deck_key].set_queue_name(name)
|
||||||
|
self._highlight(item, queued=True)
|
||||||
|
|
||||||
|
def _remove_selected(self):
|
||||||
|
row = self.playlist.currentRow()
|
||||||
|
if row >= 0:
|
||||||
|
self.playlist.takeItem(row)
|
||||||
|
self._update_count(self.playlist, self.count_label)
|
||||||
|
|
||||||
|
def _clear_all(self):
|
||||||
|
self.playlist.clear()
|
||||||
|
self._update_count(self.playlist, self.count_label)
|
||||||
|
|
||||||
|
def _context_menu(self, position):
|
||||||
|
item = self.playlist.itemAt(position)
|
||||||
|
if not isinstance(item, PlaylistItem):
|
||||||
|
return
|
||||||
|
|
||||||
|
menu = QMenu(self)
|
||||||
|
menu.setStyleSheet("""
|
||||||
|
QMenu {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #cccccc;
|
||||||
|
border: 1px solid #444;
|
||||||
|
}
|
||||||
|
QMenu::item:selected { background-color: #1a3a5a; }
|
||||||
|
""")
|
||||||
|
|
||||||
|
def load(deck_key):
|
||||||
|
self.engine.load_track(deck_key, item.filepath)
|
||||||
|
name = os.path.splitext(item.filename)[0]
|
||||||
|
if deck_key in self.deck_widgets:
|
||||||
|
self.deck_widgets[deck_key].set_track_name(name)
|
||||||
|
self._highlight(item, active=True)
|
||||||
|
|
||||||
|
def queue(deck_key):
|
||||||
|
self.engine.load_queue(deck_key, item.filepath)
|
||||||
|
name = os.path.splitext(item.filename)[0]
|
||||||
|
if deck_key in self.deck_widgets:
|
||||||
|
self.deck_widgets[deck_key].set_queue_name(name)
|
||||||
|
self._highlight(item, queued=True)
|
||||||
|
|
||||||
|
a1 = QAction("Load → Deck 1", self)
|
||||||
|
a2 = QAction("Load → Deck 2", self)
|
||||||
|
a3 = QAction("Queue → Deck 1", self)
|
||||||
|
a4 = QAction("Queue → Deck 2", self)
|
||||||
|
ar = QAction("Remove", self)
|
||||||
|
|
||||||
|
a1.triggered.connect(lambda: load("deck1"))
|
||||||
|
a2.triggered.connect(lambda: load("deck2"))
|
||||||
|
a3.triggered.connect(lambda: queue("deck1"))
|
||||||
|
a4.triggered.connect(lambda: queue("deck2"))
|
||||||
|
ar.triggered.connect(
|
||||||
|
lambda: self.playlist.takeItem(self.playlist.row(item))
|
||||||
|
)
|
||||||
|
|
||||||
|
menu.addAction(a1)
|
||||||
|
menu.addAction(a2)
|
||||||
|
menu.addSeparator()
|
||||||
|
menu.addAction(a3)
|
||||||
|
menu.addAction(a4)
|
||||||
|
menu.addSeparator()
|
||||||
|
menu.addAction(ar)
|
||||||
|
menu.exec(self.playlist.viewport().mapToGlobal(position))
|
||||||
|
self._update_count(self.playlist, self.count_label)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# CART PLAYLIST PANEL
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class CartPlaylistPanel(BasePlaylistPanel):
|
||||||
|
def __init__(self, engine, cart_slots: list, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.engine = engine
|
||||||
|
self.cart_slots = cart_slots
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QWidget {
|
||||||
|
background-color: #111111;
|
||||||
|
border: 2px solid #cc3300;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(8, 8, 8, 8)
|
||||||
|
root.setSpacing(6)
|
||||||
|
|
||||||
|
header = QLabel("CART PLAYLIST")
|
||||||
|
header.setFont(QFont("Arial", 12, QFont.Bold))
|
||||||
|
header.setStyleSheet(
|
||||||
|
"color: #cc3300; background: transparent; border: none; padding: 2px;"
|
||||||
|
)
|
||||||
|
header.setAlignment(Qt.AlignCenter)
|
||||||
|
root.addWidget(header)
|
||||||
|
|
||||||
|
self.playlist = self._make_list()
|
||||||
|
self.playlist.itemDoubleClicked.connect(self._send_to_next_free_cart)
|
||||||
|
self.playlist.customContextMenuRequested.connect(self._context_menu)
|
||||||
|
root.addWidget(self.playlist)
|
||||||
|
|
||||||
|
row1 = QHBoxLayout()
|
||||||
|
row1.setSpacing(4)
|
||||||
|
self.btn_add_files = self._make_btn("ADD FILES", "#5a2a00")
|
||||||
|
self.btn_add_folder = self._make_btn("ADD FOLDER", "#4a2000")
|
||||||
|
row1.addWidget(self.btn_add_files)
|
||||||
|
row1.addWidget(self.btn_add_folder)
|
||||||
|
root.addLayout(row1)
|
||||||
|
|
||||||
|
row2 = QHBoxLayout()
|
||||||
|
row2.setSpacing(4)
|
||||||
|
self.btn_remove = self._make_btn("REMOVE", "#5a1a1a")
|
||||||
|
self.btn_clear = self._make_btn("CLEAR ALL", "#3a1a1a")
|
||||||
|
row2.addWidget(self.btn_remove)
|
||||||
|
row2.addWidget(self.btn_clear)
|
||||||
|
root.addLayout(row2)
|
||||||
|
|
||||||
|
self.count_label = QLabel("0 tracks")
|
||||||
|
self.count_label.setFont(QFont("Courier New", 9))
|
||||||
|
self.count_label.setStyleSheet(
|
||||||
|
"color: #555; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
self.count_label.setAlignment(Qt.AlignCenter)
|
||||||
|
root.addWidget(self.count_label)
|
||||||
|
|
||||||
|
self.btn_add_files.clicked.connect(
|
||||||
|
lambda: self._add_files_to(self.playlist)
|
||||||
|
)
|
||||||
|
self.btn_add_folder.clicked.connect(
|
||||||
|
lambda: self._add_folder_to(self.playlist)
|
||||||
|
)
|
||||||
|
self.btn_remove.clicked.connect(self._remove_selected)
|
||||||
|
self.btn_clear.clicked.connect(self._clear_all)
|
||||||
|
|
||||||
|
def _send_to_cart(self, item: PlaylistItem, cart_index: int):
|
||||||
|
if not isinstance(item, PlaylistItem):
|
||||||
|
return
|
||||||
|
slot = self.cart_slots[cart_index]
|
||||||
|
slot.load_track(item.filepath)
|
||||||
|
self._highlight(item, active=True)
|
||||||
|
|
||||||
|
def _send_to_next_free_cart(self, item=None):
|
||||||
|
if item is None:
|
||||||
|
item = self.playlist.currentItem()
|
||||||
|
if not isinstance(item, PlaylistItem):
|
||||||
|
return
|
||||||
|
for i in range(4):
|
||||||
|
cart_key = f"cart{i + 1}"
|
||||||
|
if self.engine.decks[cart_key].track is None:
|
||||||
|
self._send_to_cart(item, i)
|
||||||
|
return
|
||||||
|
self._send_to_cart(item, 0)
|
||||||
|
|
||||||
|
def _remove_selected(self):
|
||||||
|
row = self.playlist.currentRow()
|
||||||
|
if row >= 0:
|
||||||
|
self.playlist.takeItem(row)
|
||||||
|
self._update_count(self.playlist, self.count_label)
|
||||||
|
|
||||||
|
def _clear_all(self):
|
||||||
|
self.playlist.clear()
|
||||||
|
self._update_count(self.playlist, self.count_label)
|
||||||
|
|
||||||
|
def _context_menu(self, position):
|
||||||
|
item = self.playlist.itemAt(position)
|
||||||
|
if not isinstance(item, PlaylistItem):
|
||||||
|
return
|
||||||
|
|
||||||
|
menu = QMenu(self)
|
||||||
|
menu.setStyleSheet("""
|
||||||
|
QMenu {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #cccccc;
|
||||||
|
border: 1px solid #444;
|
||||||
|
}
|
||||||
|
QMenu::item:selected { background-color: #1a3a5a; }
|
||||||
|
""")
|
||||||
|
|
||||||
|
for i in range(4):
|
||||||
|
action = QAction(f"Load → Cart {i + 1}", self)
|
||||||
|
action.triggered.connect(
|
||||||
|
lambda checked, idx=i: self._send_to_cart(item, idx)
|
||||||
|
)
|
||||||
|
menu.addAction(action)
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
ar = QAction("Remove", self)
|
||||||
|
ar.triggered.connect(
|
||||||
|
lambda: self.playlist.takeItem(self.playlist.row(item))
|
||||||
|
)
|
||||||
|
menu.addAction(ar)
|
||||||
|
menu.exec(self.playlist.viewport().mapToGlobal(position))
|
||||||
|
self._update_count(self.playlist, self.count_label)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# COMBINED PLAYLIST WIDGET
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class PlaylistWidget(QWidget):
|
||||||
|
def __init__(self, engine, deck_widgets: dict,
|
||||||
|
cart_slots: list, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.engine = engine
|
||||||
|
|
||||||
|
# Expandable - no fixed width
|
||||||
|
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||||
|
self.setMinimumWidth(200)
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
|
root.setSpacing(0)
|
||||||
|
|
||||||
|
splitter = QSplitter(Qt.Vertical)
|
||||||
|
splitter.setStyleSheet("""
|
||||||
|
QSplitter::handle {
|
||||||
|
background-color: #333;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
self.track_panel = TrackPlaylistPanel(engine, deck_widgets)
|
||||||
|
self.cart_panel = CartPlaylistPanel(engine, cart_slots)
|
||||||
|
|
||||||
|
splitter.addWidget(self.track_panel)
|
||||||
|
splitter.addWidget(self.cart_panel)
|
||||||
|
splitter.setSizes([600, 400])
|
||||||
|
|
||||||
|
root.addWidget(splitter)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
PySide6
|
||||||
|
numpy
|
||||||
|
soundfile
|
||||||
|
requests
|
||||||
|
mutagen
|
||||||
|
musicbrainzngs
|
||||||
|
python-rtmidi
|
||||||
|
PyJACK
|
||||||
|
librosa
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# top_bar_widget.py
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton
|
||||||
|
from PySide6.QtCore import Qt, QTimer, Signal
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# THEMES
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
THEMES = [
|
||||||
|
{
|
||||||
|
'name': 'GREEN',
|
||||||
|
'accent': '#00ff41',
|
||||||
|
'accent_dim': '#00aa2a',
|
||||||
|
'bg': '#0d0d0d',
|
||||||
|
'bg2': '#111111',
|
||||||
|
'border': '#1a3a1a',
|
||||||
|
'text': '#cccccc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'AMBER',
|
||||||
|
'accent': '#ffaa00',
|
||||||
|
'accent_dim': '#cc8800',
|
||||||
|
'bg': '#0d0d00',
|
||||||
|
'bg2': '#111100',
|
||||||
|
'border': '#3a3000',
|
||||||
|
'text': '#ddddcc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'BLUE',
|
||||||
|
'accent': '#00aaff',
|
||||||
|
'accent_dim': '#0077cc',
|
||||||
|
'bg': '#00080d',
|
||||||
|
'bg2': '#001122',
|
||||||
|
'border': '#001a3a',
|
||||||
|
'text': '#ccd8dd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'RED',
|
||||||
|
'accent': '#ff4444',
|
||||||
|
'accent_dim': '#cc2222',
|
||||||
|
'bg': '#0d0000',
|
||||||
|
'bg2': '#110000',
|
||||||
|
'border': '#3a0000',
|
||||||
|
'text': '#ddcccc',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TopBarWidget(QFrame):
|
||||||
|
theme_changed = Signal(dict)
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self._theme_index = 0
|
||||||
|
|
||||||
|
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||||
|
self.setFixedHeight(80)
|
||||||
|
self._apply_bar_style()
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
self.timer = QTimer(self)
|
||||||
|
self.timer.setInterval(1000)
|
||||||
|
self.timer.timeout.connect(self._update)
|
||||||
|
self.timer.start()
|
||||||
|
|
||||||
|
self._update()
|
||||||
|
|
||||||
|
def _apply_bar_style(self):
|
||||||
|
theme = THEMES[self._theme_index]
|
||||||
|
self.setStyleSheet(f"""
|
||||||
|
QFrame {{
|
||||||
|
background-color: {theme['bg2']};
|
||||||
|
border: 2px solid {theme['border']};
|
||||||
|
border-radius: 6px;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(20, 8, 20, 8)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
# Theme cycle button
|
||||||
|
self.theme_btn = QPushButton("◈")
|
||||||
|
self.theme_btn.setFixedSize(40, 40)
|
||||||
|
self.theme_btn.setFont(QFont("Arial", 16, QFont.Bold))
|
||||||
|
self.theme_btn.setToolTip("Cycle colour theme")
|
||||||
|
self.theme_btn.clicked.connect(self._cycle_theme)
|
||||||
|
self._style_theme_btn()
|
||||||
|
layout.addWidget(self.theme_btn)
|
||||||
|
|
||||||
|
# Station name
|
||||||
|
self.station_label = QLabel("RADIO PANEL")
|
||||||
|
self.station_label.setFont(QFont("Arial", 16, QFont.Bold))
|
||||||
|
self.station_label.setStyleSheet(
|
||||||
|
"color: #444444; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
self.station_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||||
|
layout.addWidget(self.station_label)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
# Date
|
||||||
|
self.date_label = QLabel("")
|
||||||
|
self.date_label.setFont(QFont("Arial", 20, QFont.Bold))
|
||||||
|
self.date_label.setStyleSheet(
|
||||||
|
"color: #aaaaaa; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
self.date_label.setAlignment(Qt.AlignCenter | Qt.AlignVCenter)
|
||||||
|
layout.addWidget(self.date_label)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
# Clock
|
||||||
|
self.clock_label = QLabel("")
|
||||||
|
self.clock_label.setFont(QFont("Courier New", 42, QFont.Bold))
|
||||||
|
self.clock_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
|
self.clock_label.setMinimumWidth(220)
|
||||||
|
self._style_clock()
|
||||||
|
layout.addWidget(self.clock_label)
|
||||||
|
|
||||||
|
def _style_theme_btn(self):
|
||||||
|
theme = THEMES[self._theme_index]
|
||||||
|
self.theme_btn.setStyleSheet(f"""
|
||||||
|
QPushButton {{
|
||||||
|
background-color: transparent;
|
||||||
|
color: {theme['accent']};
|
||||||
|
border: 2px solid {theme['accent']};
|
||||||
|
border-radius: 20px;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{
|
||||||
|
background-color: {theme['accent']};
|
||||||
|
color: #000000;
|
||||||
|
}}
|
||||||
|
QPushButton:pressed {{
|
||||||
|
background-color: {theme['accent_dim']};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def _style_clock(self):
|
||||||
|
theme = THEMES[self._theme_index]
|
||||||
|
self.clock_label.setStyleSheet(
|
||||||
|
f"color: {theme['accent']}; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cycle_theme(self):
|
||||||
|
self._theme_index = (self._theme_index + 1) % len(THEMES)
|
||||||
|
self._apply_bar_style()
|
||||||
|
self._style_theme_btn()
|
||||||
|
self._style_clock()
|
||||||
|
self.theme_changed.emit(THEMES[self._theme_index])
|
||||||
|
|
||||||
|
def _update(self):
|
||||||
|
now = datetime.now()
|
||||||
|
self.clock_label.setText(now.strftime("%H:%M:%S"))
|
||||||
|
self.date_label.setText(now.strftime("%A %d %B %Y"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_theme(self) -> dict:
|
||||||
|
return THEMES[self._theme_index]
|
||||||
Reference in New Issue
Block a user