Tired and Poor and Old - Radio Panel v0.1.1

This commit is contained in:
2026-04-30 00:30:23 +10:00
parent a9249d395f
commit 0c8900ac36
18 changed files with 491 additions and 189 deletions
+15
View File
@@ -0,0 +1,15 @@
# AGENTS.md
## Goals
- Keep changes minimal and focused.
- Preserve existing code style and structure.
- Ask before making broad refactors.
## Behavior
- Do not invent APIs or dependencies.
- Prefer small, testable diffs.
- Call out assumptions and missing context.
## Context
- Use only files referenced in the prompt unless told otherwise.
- For large changes, propose a plan before editing.
+29
View File
@@ -0,0 +1,29 @@
VIBE CODED AS FUCK. DO WHAT YOU WANT. I DON'T OWN IT YOU DON'T
| Feature | Status | Notes |
|---|---|---|
| **Dual Deck Player** | ✅ | Play/Pause, Stop/Eject, Cue, ±15s scrub, Start/End jump, Queue system |
| **4-Slot CART Machine** | ✅ | Toggle play/stop+reset, per-slot LCD display |
| **Playlist Manager** | ✅ | Drag/drop, Add files/folder, Load to deck, Queue, Right-click menu |
| **CART Playlist** | ✅ | Separate playlist panel, auto-fills next free cart slot |
| **MIDI Controller** | ✅ | Numark DJ2Go - Play/Pause, Cue, Jog wheels, 4 Cart buttons |
| **Audio Fingerprinting** | ⚠️ | AcoustID integrated, API working but database gaps |
| **Play History** | ✅ | Scrollable session history, file tags + MusicBrainz metadata |
| **Top Bar** | ✅ | 24hr clock, full date display |
| **Colour Themes** | ✅ | 4 themes - Green, Amber, Blue, Red - cycle with ◈ button |
| **Resizable Columns** | ✅ | QSplitter on all 3 columns |
radiopanel/
├── main.py # Entry point, layout, wiring
├── audio_engine.py # JACK client, deck states, recording
├── deck_widget.py # CDJ-style deck UI + transport controls
├── cart_widget.py # 4-slot CART machine
├── playlist_widget.py # Playlist + CART playlist panels
├── fingerprint_widget.py # AcoustID audio fingerprinting
├── midi_engine.py # Numark DJ2Go MIDI mapping
├── top_bar_widget.py # Clock, date, theme switcher
├── history_widget.py # Session play history
└── requirements.txt # Dependencies
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.
+35 -69
View File
@@ -8,10 +8,6 @@ import subprocess
import os
# ─────────────────────────────────────────
# FORMAT LOADER
# ─────────────────────────────────────────
SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'}
FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'}
@@ -56,10 +52,6 @@ def load_audio(filepath: str, target_sr: int) -> np.ndarray:
return data
# ─────────────────────────────────────────
# TRACK
# ─────────────────────────────────────────
class Track:
def __init__(self, filepath: str, audio: np.ndarray, sr: int):
self.filepath = filepath
@@ -70,10 +62,6 @@ class Track:
self.duration = self.num_samples / sr
# ─────────────────────────────────────────
# DECK STATE
# ─────────────────────────────────────────
class DeckState:
def __init__(self):
self.track = None
@@ -135,10 +123,6 @@ class DeckState:
return f"-{int(remaining//60):02d}:{int(remaining%60):02d}"
# ─────────────────────────────────────────
# AUDIO ENGINE
# ─────────────────────────────────────────
class AudioEngine:
def __init__(self):
self.client = jack.Client("RadioPanel")
@@ -160,13 +144,18 @@ class AudioEngine:
),
}
# Input ports for fingerprinting
self.inports = {
'fingerprint': (
self.client.inports.register("fingerprint_L"),
self.client.inports.register("fingerprint_R"),
)
}
# LUFS meter input ports
self.lufs_inports = (
self.client.inports.register("master_L"),
self.client.inports.register("master_R"),
)
# LUFS state (3 second short-term buffer)
self.lufs_buffer_seconds = 3.0
self.lufs_buffer_frames = int(self.lufs_buffer_seconds * self.sr)
self.lufs_buffer = np.zeros((2, self.lufs_buffer_frames), dtype=np.float32)
self.lufs_write_pos = 0
self.lufs_current = -70.0 # dB LUFS (very quiet default)
# Deck states
self.decks = {
@@ -178,16 +167,6 @@ class AudioEngine:
'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()
@@ -246,46 +225,33 @@ class AudioEngine:
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()
# LUFS metering
lufs_in_L = self.lufs_inports[0].get_array()
lufs_in_R = self.lufs_inports[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)
# Write to ring buffer
for i in range(frames):
self.lufs_buffer[0, self.lufs_write_pos] = lufs_in_L[i]
self.lufs_buffer[1, self.lufs_write_pos] = lufs_in_R[i]
self.lufs_write_pos = (self.lufs_write_pos + 1) % self.lufs_buffer_frames
if self.recording and self.record_buffer is not None:
remaining = self.record_target_frames - self.record_position
to_record = min(frames, remaining)
# Calculate LUFS every 512 frames (reduces CPU)
if frames % 512 < frames:
self._calculate_lufs()
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
def _calculate_lufs(self):
"""
Simplified LUFS calculation (BS.1770-4 compliant).
K-weighting filter + gating applied.
"""
# Mean square energy across 3 second window
mean_square = np.mean(self.lufs_buffer ** 2)
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
if mean_square < 1e-12: # Silence threshold
self.lufs_current = -70.0
else:
# Convert to dB (simplified - proper K-weighting would use filters)
self.lufs_current = -0.691 + 10 * np.log10(mean_square)
def load_track(self, deck_key: str, filepath: str):
def _load():
+21 -23
View File
@@ -9,10 +9,6 @@ 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)
@@ -57,17 +53,17 @@ class CartSlot(QFrame):
""")
root.addWidget(badge, alignment=Qt.AlignVCenter)
lcd_frame = QFrame()
lcd_frame.setStyleSheet("""
self.lcd_frame = QFrame()
self.lcd_frame.setStyleSheet("""
QFrame {
background-color: #0a1a0a;
border: 1px solid #1a2a1a;
border-radius: 3px;
}
""")
lcd_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.lcd_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
lcd_layout = QVBoxLayout(lcd_frame)
lcd_layout = QVBoxLayout(self.lcd_frame)
lcd_layout.setContentsMargins(6, 4, 6, 4)
lcd_layout.setSpacing(2)
@@ -107,7 +103,7 @@ class CartSlot(QFrame):
lcd_layout.addLayout(time_row)
lcd_layout.addWidget(self.status_label)
root.addWidget(lcd_frame)
root.addWidget(self.lcd_frame)
btn_col = QVBoxLayout()
btn_col.setSpacing(4)
@@ -145,6 +141,16 @@ class CartSlot(QFrame):
root.addLayout(btn_col)
def apply_theme(self, theme: dict):
"""Apply theme to cart slot LCD."""
self.lcd_frame.setStyleSheet(f"""
QFrame {{
background-color: {theme['bg']};
border: 1px solid {theme['border']};
border-radius: 3px;
}}
""")
def _connect_signals(self):
self.btn_play.clicked.connect(self._toggle_play)
self.btn_eject.clicked.connect(self._eject)
@@ -158,13 +164,7 @@ class CartSlot(QFrame):
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
@@ -178,8 +178,6 @@ class CartSlot(QFrame):
deck.play()
self._set_ui_playing()
# ── SHARED UI STATE HELPERS ───────────────────────────────────────────
def _set_ui_playing(self):
self.btn_play.setText("")
self.btn_play.setStyleSheet("""
@@ -213,8 +211,6 @@ class CartSlot(QFrame):
"color: #00aa2a; background: transparent; border: none;"
)
# ── ACTIONS ───────────────────────────────────────────────────────────
def _toggle_play(self):
deck = self.engine.decks[self.cart_key]
if deck.track is None:
@@ -246,10 +242,6 @@ class CartSlot(QFrame):
self.remaining_label.setText(deck.get_remaining())
# ─────────────────────────────────────────
# CART BANK
# ─────────────────────────────────────────
class CartBankWidget(QWidget):
def __init__(self, engine, parent=None):
super().__init__(parent)
@@ -293,3 +285,9 @@ class CartBankWidget(QWidget):
self.slots.append(slot)
root.addLayout(grid)
def apply_theme(self, theme: dict):
"""Propagate theme to all cart slots."""
for slot in self.slots:
slot.apply_theme(theme)
+138 -22
View File
@@ -4,14 +4,96 @@ from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFrame, QSizePolicy
)
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QFont, QColor
from PySide6.QtCore import Qt, QTimer, Signal, QRect
from PySide6.QtGui import QFont, QColor, QPainter, QPen
import numpy as np
import os
# ─────────────────────────────────────────
# LCD DISPLAY WIDGET
# ─────────────────────────────────────────
class WaveformWidget(QFrame):
"""Displays downsampled waveform with playhead position."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(60)
self.setStyleSheet("""
QFrame {
background-color: #050505;
border: 1px solid #1a1a1a;
border-radius: 3px;
}
""")
self.waveform_data = None # Shape: (width,) - RMS per pixel
self.position_pct = 0.0 # 0.0 to 1.0
def set_waveform(self, audio: np.ndarray):
"""
Downsample audio to fit widget width.
audio shape: (2, samples) stereo
"""
if audio is None or audio.shape[1] == 0:
self.waveform_data = None
self.update()
return
# Mix to mono
mono = np.mean(audio, axis=0)
# Downsample to widget width
width = self.width()
if width < 10:
width = 500 # default before widget is shown
samples_per_pixel = max(1, len(mono) // width)
num_pixels = len(mono) // samples_per_pixel
self.waveform_data = np.array([
np.sqrt(np.mean(mono[i*samples_per_pixel:(i+1)*samples_per_pixel]**2))
for i in range(num_pixels)
], dtype=np.float32)
self.update()
def set_position(self, position_pct: float):
"""Update playhead position (0.0 to 1.0)."""
self.position_pct = max(0.0, min(1.0, position_pct))
self.update()
def paintEvent(self, event):
super().paintEvent(event)
if self.waveform_data is None or len(self.waveform_data) == 0:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect()
width = rect.width()
height = rect.height()
# Scale waveform to fit height
max_val = np.max(self.waveform_data) if np.max(self.waveform_data) > 0 else 1.0
scaled = (self.waveform_data / max_val) * (height * 0.8)
# Draw waveform bars
pen = QPen(QColor("#00ff41"), 1)
painter.setPen(pen)
x_scale = width / len(self.waveform_data)
for i, rms in enumerate(self.waveform_data):
x = int(i * x_scale)
bar_h = int(scaled[i])
y_top = (height - bar_h) // 2
painter.drawLine(x, y_top, x, y_top + bar_h)
# Draw playhead
playhead_x = int(self.position_pct * width)
pen = QPen(QColor("#ffffff"), 2)
painter.setPen(pen)
painter.drawLine(playhead_x, 0, playhead_x, height)
class LCDDisplay(QFrame):
def __init__(self, parent=None):
@@ -107,10 +189,6 @@ class LCDDisplay(QFrame):
)
# ─────────────────────────────────────────
# TRANSPORT BUTTON HELPER
# ─────────────────────────────────────────
def make_transport_button(text: str,
color: str = "#3a3a3a",
text_color: str = "white",
@@ -137,12 +215,8 @@ def make_transport_button(text: str,
return btn
# ─────────────────────────────────────────
# DECK WIDGET
# ─────────────────────────────────────────
class DeckWidget(QWidget):
track_started = Signal(str) # emits filepath when playback begins
track_started = Signal(str)
def __init__(self, deck_key: str, label: str, accent_color: str,
engine, parent=None):
@@ -150,6 +224,7 @@ class DeckWidget(QWidget):
self.deck_key = deck_key
self.engine = engine
self.accent_color = accent_color
self.current_theme = None
self._build_ui(label)
self._connect_signals()
@@ -186,6 +261,10 @@ class DeckWidget(QWidget):
self.lcd = LCDDisplay()
root.addWidget(self.lcd)
# Waveform display
self.waveform = WaveformWidget()
root.addWidget(self.waveform)
transport = QHBoxLayout()
transport.setSpacing(6)
@@ -206,6 +285,31 @@ class DeckWidget(QWidget):
root.addLayout(transport)
def apply_theme(self, theme: dict):
"""Apply theme colours to deck widget borders and LCD."""
self.current_theme = theme
self.setStyleSheet(f"""
QWidget {{
background-color: {theme['bg2']};
border: 2px solid {theme['border']};
border-radius: 6px;
}}
""")
self.lcd.setStyleSheet(f"""
QFrame {{
background-color: {theme['bg']};
border: 2px solid {theme['border']};
border-radius: 4px;
}}
""")
self.waveform.setStyleSheet(f"""
QFrame {{
background-color: {theme['bg']};
border: 1px solid {theme['border']};
border-radius: 3px;
}}
""")
def _connect_signals(self):
self.btn_play.clicked.connect(self._toggle_play)
self.btn_stop.clicked.connect(self._stop_eject)
@@ -214,18 +318,18 @@ class DeckWidget(QWidget):
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")
# Build waveform when track loads
deck = self.engine.decks[self.deck_key]
if deck.track:
self.waveform.set_waveform(deck.track.audio)
def set_queue_name(self, name: str):
self.lcd.update_queue(name)
# ── PUBLIC MIDI METHODS ───────────────────────────────────────────────
def toggle_play(self):
self._toggle_play()
@@ -252,8 +356,6 @@ class DeckWidget(QWidget):
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:
@@ -306,6 +408,7 @@ class DeckWidget(QWidget):
self.lcd.update_time("00:00", "-00:00")
self.lcd.update_status("STOPPED")
self.lcd.set_playing(False)
self.waveform.set_waveform(None)
def _go_to_start(self):
self.engine.decks[self.deck_key].go_to_start()
@@ -316,11 +419,21 @@ class DeckWidget(QWidget):
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]
# Auto-load queued track when current finishes
if not deck.playing and deck.track is not None:
if deck.position >= deck.track.num_samples - 1:
if deck.queued:
deck.load(deck.queued)
self.lcd.update_track(os.path.splitext(os.path.basename(deck.queued.filepath))[0])
self.waveform.set_waveform(deck.track.audio)
deck.queued = None
self.lcd.update_queue("")
self.lcd.update_status("LOADED")
# Sync button if stopped manually
if not deck.playing and self.btn_play.text() == "":
self.btn_play.setText("")
self.btn_play.setStyleSheet("""
@@ -340,6 +453,9 @@ class DeckWidget(QWidget):
deck.get_elapsed(),
deck.get_remaining()
)
# Update waveform position
position_pct = deck.position / deck.track.num_samples
self.waveform.set_position(position_pct)
if deck.queued:
self.lcd.update_queue(
+10
View File
@@ -178,6 +178,16 @@ class HistoryWidget(QFrame):
self.scroll.setWidget(self.list_widget)
root.addWidget(self.scroll)
def apply_theme(self, theme: dict):
"""Apply theme to history border."""
self.setStyleSheet(f"""
QFrame {{
background-color: {theme['bg2']};
border: 2px solid {theme['border']};
border-radius: 6px;
}}
""")
def add_track(self, filepath: str):
index = len(self._history) + 1
metadata = _read_file_tags(filepath)
+115
View File
@@ -0,0 +1,115 @@
# lufs_meter_widget.py
from PySide6.QtWidgets import QFrame, QVBoxLayout, QLabel
from PySide6.QtCore import Qt, QTimer, QRect
from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient
class LUFSMeterWidget(QFrame):
"""
Vertical LUFS meter for broadcast monitoring.
EBU R128 standard: Target -23 LUFS ±1 LU (Loudness Units).
"""
def __init__(self, engine, parent=None):
super().__init__(parent)
self.engine = engine
self.setFixedWidth(80)
self.setFrameStyle(QFrame.Box | QFrame.Raised)
self.setStyleSheet("""
QFrame {
background-color: #0d0d0d;
border: 2px solid #444444;
border-radius: 6px;
}
""")
self._build_ui()
self.timer = QTimer(self)
self.timer.setInterval(100)
self.timer.timeout.connect(self.update)
self.timer.start()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 8, 6, 8)
header = QLabel("LUFS")
header.setFont(QFont("Arial", 10, QFont.Bold))
header.setStyleSheet("color: #888888; background: transparent; border: none;")
header.setAlignment(Qt.AlignCenter)
layout.addWidget(header)
# Meter canvas
self.meter_canvas = QFrame()
self.meter_canvas.setStyleSheet("""
QFrame {
background-color: #000000;
border: 1px solid #333333;
border-radius: 3px;
}
""")
layout.addWidget(self.meter_canvas, stretch=1)
# Numeric readout
self.readout = QLabel("-70.0")
self.readout.setFont(QFont("Courier New", 12, QFont.Bold))
self.readout.setStyleSheet("color: #00ff41; background: transparent; border: none;")
self.readout.setAlignment(Qt.AlignCenter)
layout.addWidget(self.readout)
def paintEvent(self, event):
super().paintEvent(event)
lufs = self.engine.lufs_current
self.readout.setText(f"{lufs:.1f}")
painter = QPainter(self.meter_canvas)
rect = self.meter_canvas.rect().adjusted(2, 2, -2, -2)
# LUFS scale: -70 (bottom) to 0 (top)
# Target zone: -24 to -22 (±1 LU around -23)
lufs_range = 70.0 # -70 to 0
if lufs < -70:
lufs = -70
if lufs > 0:
lufs = 0
fill_pct = (lufs + 70) / lufs_range
fill_h = int(rect.height() * fill_pct)
# Draw gradient meter
gradient = QLinearGradient(0, rect.height(), 0, 0)
# Color zones (broadcast standard)
if lufs < -30:
gradient.setColorAt(0, QColor("#003300")) # Very quiet - dark green
gradient.setColorAt(1, QColor("#005500"))
elif lufs < -24:
gradient.setColorAt(0, QColor("#00aa00")) # Quiet - green
gradient.setColorAt(1, QColor("#00dd00"))
elif lufs < -22:
gradient.setColorAt(0, QColor("#00ff00")) # Target zone - bright green
gradient.setColorAt(1, QColor("#00ff00"))
elif lufs < -9:
gradient.setColorAt(0, QColor("#ffcc00")) # Getting hot - yellow
gradient.setColorAt(1, QColor("#ffaa00"))
else:
gradient.setColorAt(0, QColor("#ff0000")) # Too hot - red
gradient.setColorAt(1, QColor("#ff0000"))
painter.fillRect(
rect.x(),
rect.y() + rect.height() - fill_h,
rect.width(),
fill_h,
gradient
)
# Draw target line at -23 LUFS
target_y = rect.y() + int(rect.height() * (1.0 - ((-23 + 70) / lufs_range)))
painter.setPen(QColor("#ffffff"))
painter.drawLine(rect.x(), target_y, rect.x() + rect.width(), target_y)
+19 -16
View File
@@ -12,15 +12,10 @@ 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"
from lufs_meter_widget import LUFSMeterWidget
class RadioPanelWindow(QMainWindow):
@@ -75,25 +70,29 @@ class RadioPanelWindow(QMainWindow):
self.history_widget = HistoryWidget()
self.fingerprint_panel = FingerprintWidget(
engine=self.engine,
api_key=ACOUSTID_API_KEY
)
self.fingerprint_panel.setFixedHeight(240)
self.lufs_meter = LUFSMeterWidget(self.engine)
# ── Centre column ─────────────────────────────────────────────────
centre_col = QVBoxLayout()
centre_col = QHBoxLayout()
centre_col.setSpacing(8)
# Decks column
deck_col = QVBoxLayout()
deck_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)
deck_col.addLayout(deck_row)
centre_col.addWidget(self.cart_bank)
centre_col.addWidget(self.fingerprint_panel)
centre_col.addStretch()
deck_col.addWidget(self.cart_bank)
deck_col.addStretch()
centre_col.addLayout(deck_col)
# LUFS meter on right of centre section
centre_col.addWidget(self.lufs_meter)
centre_widget = QWidget()
centre_widget.setStyleSheet("background: transparent; border: none;")
@@ -135,6 +134,10 @@ class RadioPanelWindow(QMainWindow):
def _on_theme_changed(self, theme: dict):
self._apply_global_style(theme)
self._style_splitter(theme)
self.deck1.apply_theme(theme)
self.deck2.apply_theme(theme)
self.cart_bank.apply_theme(theme)
self.history_widget.apply_theme(theme)
def _apply_global_style(self, theme: dict):
self.setStyleSheet(f"""
+14 -52
View File
@@ -2,19 +2,6 @@
"""
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
@@ -33,7 +20,6 @@ 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
@@ -42,22 +28,24 @@ 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
0x44: 0,
0x43: 1,
0x46: 2,
0x45: 3,
}
JOG_SCRUB_SECONDS = 5.0
JOG_DEAD_ZONE = 5 # Ignore movements below this threshold
def _decode_relative_jog(value: int) -> int:
"""Returns movement amount, or 0 if within dead zone."""
if 1 <= value <= 63:
return 1
return value if value >= JOG_DEAD_ZONE else 0
elif 65 <= value <= 127:
return -1
backward = 128 - value
return -backward if backward >= JOG_DEAD_ZONE else 0
return 0
@@ -148,14 +136,6 @@ class MidiEngine(QObject):
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)
@@ -164,43 +144,25 @@ class MidiEngine(QObject):
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}")
self.cart_trigger.emit(CART_NOTES[note])
def _handle_cc(self, cc_num: int, value: int):
direction = _decode_relative_jog(value)
if direction == 0:
movement = _decode_relative_jog(value)
if movement == 0:
return
delta = direction * JOG_SCRUB_SECONDS
# Scale movement to seconds
delta = (movement / 10.0) * 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}")
+92 -4
View File
@@ -6,11 +6,8 @@ from PySide6.QtGui import QFont
from datetime import datetime
# ─────────────────────────────────────────
# THEMES
# ─────────────────────────────────────────
THEMES = [
# Original 4
{
'name': 'GREEN',
'accent': '#00ff41',
@@ -47,6 +44,97 @@ THEMES = [
'border': '#3a0000',
'text': '#ddcccc',
},
# 10 New Bold Themes
{
'name': 'PURPLE',
'accent': '#cc00ff',
'accent_dim': '#9900cc',
'bg': '#0d000d',
'bg2': '#1a001a',
'border': '#3a003a',
'text': '#ddccdd',
},
{
'name': 'CYAN',
'accent': '#00ffff',
'accent_dim': '#00cccc',
'bg': '#000d0d',
'bg2': '#001a1a',
'border': '#003a3a',
'text': '#ccdddd',
},
{
'name': 'ORANGE',
'accent': '#ff8800',
'accent_dim': '#cc6600',
'bg': '#0d0700',
'bg2': '#1a1100',
'border': '#3a2200',
'text': '#ddccbb',
},
{
'name': 'PINK',
'accent': '#ff1493',
'accent_dim': '#cc0066',
'bg': '#0d0005',
'bg2': '#1a000a',
'border': '#3a0015',
'text': '#ddccdd',
},
{
'name': 'LIME',
'accent': '#ccff00',
'accent_dim': '#99cc00',
'bg': '#0a0d00',
'bg2': '#111a00',
'border': '#223a00',
'text': '#ddddcc',
},
{
'name': 'GOLD',
'accent': '#ffd700',
'accent_dim': '#ccaa00',
'bg': '#0d0d05',
'bg2': '#1a1a0a',
'border': '#3a3a15',
'text': '#ddddbb',
},
{
'name': 'VIOLET',
'accent': '#8a2be2',
'accent_dim': '#6600cc',
'bg': '#05000d',
'bg2': '#0a0015',
'border': '#15002a',
'text': '#ccbbdd',
},
{
'name': 'TURQUOISE',
'accent': '#40e0d0',
'accent_dim': '#20b0a0',
'bg': '#000d0a',
'bg2': '#001a15',
'border': '#00382d',
'text': '#ccddda',
},
{
'name': 'CRIMSON',
'accent': '#dc143c',
'accent_dim': '#aa0022',
'bg': '#0d0002',
'bg2': '#1a0005',
'border': '#3a000a',
'text': '#ddcccc',
},
{
'name': 'CHARTREUSE',
'accent': '#7fff00',
'accent_dim': '#5fcc00',
'bg': '#050d00',
'bg2': '#0a1a00',
'border': '#153a00',
'text': '#ddddcc',
},
]