Initial commit - Radio Panel v0.1

This commit is contained in:
2026-04-29 16:21:44 +10:00
commit a9249d395f
20 changed files with 2787 additions and 0 deletions
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
View File
@@ -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()
+68
View File
@@ -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
View File
@@ -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)
+26
View File
@@ -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
View File
@@ -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)
)
+296
View File
@@ -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;")
+238
View File
@@ -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
)
+225
View File
@@ -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
View File
@@ -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}")
+605
View File
@@ -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)
+9
View File
@@ -0,0 +1,9 @@
PySide6
numpy
soundfile
requests
mutagen
musicbrainzngs
python-rtmidi
PyJACK
librosa
+166
View File
@@ -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]