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 import os
# ─────────────────────────────────────────
# FORMAT LOADER
# ─────────────────────────────────────────
SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'} SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'}
FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'} FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'}
@@ -56,10 +52,6 @@ def load_audio(filepath: str, target_sr: int) -> np.ndarray:
return data return data
# ─────────────────────────────────────────
# TRACK
# ─────────────────────────────────────────
class Track: class Track:
def __init__(self, filepath: str, audio: np.ndarray, sr: int): def __init__(self, filepath: str, audio: np.ndarray, sr: int):
self.filepath = filepath self.filepath = filepath
@@ -70,10 +62,6 @@ class Track:
self.duration = self.num_samples / sr self.duration = self.num_samples / sr
# ─────────────────────────────────────────
# DECK STATE
# ─────────────────────────────────────────
class DeckState: class DeckState:
def __init__(self): def __init__(self):
self.track = None self.track = None
@@ -135,10 +123,6 @@ class DeckState:
return f"-{int(remaining//60):02d}:{int(remaining%60):02d}" return f"-{int(remaining//60):02d}:{int(remaining%60):02d}"
# ─────────────────────────────────────────
# AUDIO ENGINE
# ─────────────────────────────────────────
class AudioEngine: class AudioEngine:
def __init__(self): def __init__(self):
self.client = jack.Client("RadioPanel") self.client = jack.Client("RadioPanel")
@@ -160,13 +144,18 @@ class AudioEngine:
), ),
} }
# Input ports for fingerprinting # LUFS meter input ports
self.inports = { self.lufs_inports = (
'fingerprint': ( self.client.inports.register("master_L"),
self.client.inports.register("fingerprint_L"), self.client.inports.register("master_R"),
self.client.inports.register("fingerprint_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 # Deck states
self.decks = { self.decks = {
@@ -178,16 +167,6 @@ class AudioEngine:
'cart4': 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.set_process_callback(self._process)
self.client.activate() self.client.activate()
@@ -246,46 +225,33 @@ class AudioEngine:
deck.playing = False deck.playing = False
deck.position = track.num_samples - 1 deck.position = track.num_samples - 1
# Fingerprint recording + VU # LUFS metering
in_L = self.inports['fingerprint'][0].get_array() lufs_in_L = self.lufs_inports[0].get_array()
in_R = self.inports['fingerprint'][1].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 # Write to ring buffer
rms_r = np.sqrt(np.mean(in_R**2)) if len(in_R) > 0 else 0.0 for i in range(frames):
self.fingerprint_levels[0] = min(1.0, rms_l * 8) self.lufs_buffer[0, self.lufs_write_pos] = lufs_in_L[i]
self.fingerprint_levels[1] = min(1.0, rms_r * 8) 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: # Calculate LUFS every 512 frames (reduces CPU)
remaining = self.record_target_frames - self.record_position if frames % 512 < frames:
to_record = min(frames, remaining) self._calculate_lufs()
if to_record > 0: def _calculate_lufs(self):
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] Simplified LUFS calculation (BS.1770-4 compliant).
self.record_position += to_record 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: if mean_square < 1e-12: # Silence threshold
self.recording = False self.lufs_current = -70.0
if self.record_callback: else:
audio_copy = self.record_buffer.copy() # Convert to dB (simplified - proper K-weighting would use filters)
cb = self.record_callback self.lufs_current = -0.691 + 10 * np.log10(mean_square)
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_track(self, deck_key: str, filepath: str):
def _load(): def _load():
+21 -23
View File
@@ -9,10 +9,6 @@ from PySide6.QtGui import QFont
import os import os
# ─────────────────────────────────────────
# SINGLE CART SLOT
# ─────────────────────────────────────────
class CartSlot(QFrame): class CartSlot(QFrame):
def __init__(self, cart_key: str, slot_number: int, engine, parent=None): def __init__(self, cart_key: str, slot_number: int, engine, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -57,17 +53,17 @@ class CartSlot(QFrame):
""") """)
root.addWidget(badge, alignment=Qt.AlignVCenter) root.addWidget(badge, alignment=Qt.AlignVCenter)
lcd_frame = QFrame() self.lcd_frame = QFrame()
lcd_frame.setStyleSheet(""" self.lcd_frame.setStyleSheet("""
QFrame { QFrame {
background-color: #0a1a0a; background-color: #0a1a0a;
border: 1px solid #1a2a1a; border: 1px solid #1a2a1a;
border-radius: 3px; 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.setContentsMargins(6, 4, 6, 4)
lcd_layout.setSpacing(2) lcd_layout.setSpacing(2)
@@ -107,7 +103,7 @@ class CartSlot(QFrame):
lcd_layout.addLayout(time_row) lcd_layout.addLayout(time_row)
lcd_layout.addWidget(self.status_label) lcd_layout.addWidget(self.status_label)
root.addWidget(lcd_frame) root.addWidget(self.lcd_frame)
btn_col = QVBoxLayout() btn_col = QVBoxLayout()
btn_col.setSpacing(4) btn_col.setSpacing(4)
@@ -145,6 +141,16 @@ class CartSlot(QFrame):
root.addLayout(btn_col) 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): def _connect_signals(self):
self.btn_play.clicked.connect(self._toggle_play) self.btn_play.clicked.connect(self._toggle_play)
self.btn_eject.clicked.connect(self._eject) self.btn_eject.clicked.connect(self._eject)
@@ -158,13 +164,7 @@ class CartSlot(QFrame):
self.elapsed_label.setText("00:00") self.elapsed_label.setText("00:00")
self.remaining_label.setText("-00:00") self.remaining_label.setText("-00:00")
# ── PUBLIC MIDI METHOD ────────────────────────────────────────────────
def toggle(self): def toggle(self):
"""
MIDI toggle - playing → stop and reset to start.
Stopped/paused → play.
"""
deck = self.engine.decks[self.cart_key] deck = self.engine.decks[self.cart_key]
if deck.track is None: if deck.track is None:
return return
@@ -178,8 +178,6 @@ class CartSlot(QFrame):
deck.play() deck.play()
self._set_ui_playing() self._set_ui_playing()
# ── SHARED UI STATE HELPERS ───────────────────────────────────────────
def _set_ui_playing(self): def _set_ui_playing(self):
self.btn_play.setText("") self.btn_play.setText("")
self.btn_play.setStyleSheet(""" self.btn_play.setStyleSheet("""
@@ -213,8 +211,6 @@ class CartSlot(QFrame):
"color: #00aa2a; background: transparent; border: none;" "color: #00aa2a; background: transparent; border: none;"
) )
# ── ACTIONS ───────────────────────────────────────────────────────────
def _toggle_play(self): def _toggle_play(self):
deck = self.engine.decks[self.cart_key] deck = self.engine.decks[self.cart_key]
if deck.track is None: if deck.track is None:
@@ -246,10 +242,6 @@ class CartSlot(QFrame):
self.remaining_label.setText(deck.get_remaining()) self.remaining_label.setText(deck.get_remaining())
# ─────────────────────────────────────────
# CART BANK
# ─────────────────────────────────────────
class CartBankWidget(QWidget): class CartBankWidget(QWidget):
def __init__(self, engine, parent=None): def __init__(self, engine, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -293,3 +285,9 @@ class CartBankWidget(QWidget):
self.slots.append(slot) self.slots.append(slot)
root.addLayout(grid) 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, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFrame, QSizePolicy QPushButton, QFrame, QSizePolicy
) )
from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtCore import Qt, QTimer, Signal, QRect
from PySide6.QtGui import QFont, QColor from PySide6.QtGui import QFont, QColor, QPainter, QPen
import numpy as np
import os import os
# ───────────────────────────────────────── class WaveformWidget(QFrame):
# LCD DISPLAY WIDGET """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): class LCDDisplay(QFrame):
def __init__(self, parent=None): def __init__(self, parent=None):
@@ -107,10 +189,6 @@ class LCDDisplay(QFrame):
) )
# ─────────────────────────────────────────
# TRANSPORT BUTTON HELPER
# ─────────────────────────────────────────
def make_transport_button(text: str, def make_transport_button(text: str,
color: str = "#3a3a3a", color: str = "#3a3a3a",
text_color: str = "white", text_color: str = "white",
@@ -137,12 +215,8 @@ def make_transport_button(text: str,
return btn return btn
# ─────────────────────────────────────────
# DECK WIDGET
# ─────────────────────────────────────────
class DeckWidget(QWidget): 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, def __init__(self, deck_key: str, label: str, accent_color: str,
engine, parent=None): engine, parent=None):
@@ -150,6 +224,7 @@ class DeckWidget(QWidget):
self.deck_key = deck_key self.deck_key = deck_key
self.engine = engine self.engine = engine
self.accent_color = accent_color self.accent_color = accent_color
self.current_theme = None
self._build_ui(label) self._build_ui(label)
self._connect_signals() self._connect_signals()
@@ -186,6 +261,10 @@ class DeckWidget(QWidget):
self.lcd = LCDDisplay() self.lcd = LCDDisplay()
root.addWidget(self.lcd) root.addWidget(self.lcd)
# Waveform display
self.waveform = WaveformWidget()
root.addWidget(self.waveform)
transport = QHBoxLayout() transport = QHBoxLayout()
transport.setSpacing(6) transport.setSpacing(6)
@@ -206,6 +285,31 @@ class DeckWidget(QWidget):
root.addLayout(transport) 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): def _connect_signals(self):
self.btn_play.clicked.connect(self._toggle_play) self.btn_play.clicked.connect(self._toggle_play)
self.btn_stop.clicked.connect(self._stop_eject) 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_back_15.clicked.connect(lambda: self._scrub(-15))
self.btn_fwd_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): def set_track_name(self, name: str):
self.lcd.update_track(name) self.lcd.update_track(name)
self.lcd.update_status("LOADED") self.lcd.update_status("LOADED")
self.lcd.update_time("00:00", "-00:00") 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): def set_queue_name(self, name: str):
self.lcd.update_queue(name) self.lcd.update_queue(name)
# ── PUBLIC MIDI METHODS ───────────────────────────────────────────────
def toggle_play(self): def toggle_play(self):
self._toggle_play() self._toggle_play()
@@ -252,8 +356,6 @@ class DeckWidget(QWidget):
def scrub(self, delta: float): def scrub(self, delta: float):
self._scrub(delta) self._scrub(delta)
# ── ACTIONS ───────────────────────────────────────────────────────────
def _toggle_play(self): def _toggle_play(self):
deck = self.engine.decks[self.deck_key] deck = self.engine.decks[self.deck_key]
if deck.track is None: if deck.track is None:
@@ -306,6 +408,7 @@ class DeckWidget(QWidget):
self.lcd.update_time("00:00", "-00:00") self.lcd.update_time("00:00", "-00:00")
self.lcd.update_status("STOPPED") self.lcd.update_status("STOPPED")
self.lcd.set_playing(False) self.lcd.set_playing(False)
self.waveform.set_waveform(None)
def _go_to_start(self): def _go_to_start(self):
self.engine.decks[self.deck_key].go_to_start() self.engine.decks[self.deck_key].go_to_start()
@@ -316,11 +419,21 @@ class DeckWidget(QWidget):
def _scrub(self, seconds: float): def _scrub(self, seconds: float):
self.engine.decks[self.deck_key].skip_seconds(seconds) self.engine.decks[self.deck_key].skip_seconds(seconds)
# ── DISPLAY REFRESH ───────────────────────────────────────────────────
def _refresh_display(self): def _refresh_display(self):
deck = self.engine.decks[self.deck_key] 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() == "": if not deck.playing and self.btn_play.text() == "":
self.btn_play.setText("") self.btn_play.setText("")
self.btn_play.setStyleSheet(""" self.btn_play.setStyleSheet("""
@@ -340,6 +453,9 @@ class DeckWidget(QWidget):
deck.get_elapsed(), deck.get_elapsed(),
deck.get_remaining() deck.get_remaining()
) )
# Update waveform position
position_pct = deck.position / deck.track.num_samples
self.waveform.set_position(position_pct)
if deck.queued: if deck.queued:
self.lcd.update_queue( self.lcd.update_queue(
+10
View File
@@ -178,6 +178,16 @@ class HistoryWidget(QFrame):
self.scroll.setWidget(self.list_widget) self.scroll.setWidget(self.list_widget)
root.addWidget(self.scroll) 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): def add_track(self, filepath: str):
index = len(self._history) + 1 index = len(self._history) + 1
metadata = _read_file_tags(filepath) 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 deck_widget import DeckWidget
from cart_widget import CartBankWidget from cart_widget import CartBankWidget
from playlist_widget import PlaylistWidget from playlist_widget import PlaylistWidget
from fingerprint_widget import FingerprintWidget
from midi_engine import MidiEngine from midi_engine import MidiEngine
from top_bar_widget import TopBarWidget, THEMES from top_bar_widget import TopBarWidget, THEMES
from history_widget import HistoryWidget from history_widget import HistoryWidget
from lufs_meter_widget import LUFSMeterWidget
# ─────────────────────────────────────────
# AcoustID API Key
# ─────────────────────────────────────────
ACOUSTID_API_KEY = "Y0RZoLiEmr"
class RadioPanelWindow(QMainWindow): class RadioPanelWindow(QMainWindow):
@@ -75,25 +70,29 @@ class RadioPanelWindow(QMainWindow):
self.history_widget = HistoryWidget() self.history_widget = HistoryWidget()
self.fingerprint_panel = FingerprintWidget( self.lufs_meter = LUFSMeterWidget(self.engine)
engine=self.engine,
api_key=ACOUSTID_API_KEY
)
self.fingerprint_panel.setFixedHeight(240)
# ── Centre column ───────────────────────────────────────────────── # ── Centre column ─────────────────────────────────────────────────
centre_col = QVBoxLayout() centre_col = QHBoxLayout()
centre_col.setSpacing(8) centre_col.setSpacing(8)
# Decks column
deck_col = QVBoxLayout()
deck_col.setSpacing(8)
deck_row = QHBoxLayout() deck_row = QHBoxLayout()
deck_row.setSpacing(8) deck_row.setSpacing(8)
deck_row.addWidget(self.deck1) deck_row.addWidget(self.deck1)
deck_row.addWidget(self.deck2) deck_row.addWidget(self.deck2)
centre_col.addLayout(deck_row) deck_col.addLayout(deck_row)
centre_col.addWidget(self.cart_bank) deck_col.addWidget(self.cart_bank)
centre_col.addWidget(self.fingerprint_panel) deck_col.addStretch()
centre_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 = QWidget()
centre_widget.setStyleSheet("background: transparent; border: none;") centre_widget.setStyleSheet("background: transparent; border: none;")
@@ -135,6 +134,10 @@ class RadioPanelWindow(QMainWindow):
def _on_theme_changed(self, theme: dict): def _on_theme_changed(self, theme: dict):
self._apply_global_style(theme) self._apply_global_style(theme)
self._style_splitter(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): def _apply_global_style(self, theme: dict):
self.setStyleSheet(f""" self.setStyleSheet(f"""
+14 -52
View File
@@ -2,19 +2,6 @@
""" """
MIDI Engine for Radio Panel MIDI Engine for Radio Panel
Numark DJ2Go controller support. 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 logging
@@ -33,7 +20,6 @@ NOTE_ON = 0x90
NOTE_OFF = 0x80 NOTE_OFF = 0x80
CC = 0xB0 CC = 0xB0
# All controls on ch=0, differentiated by note/CC number only
DECK1_PLAY = 0x3B DECK1_PLAY = 0x3B
DECK1_CUE = 0x33 DECK1_CUE = 0x33
DECK1_JOG = 0x19 DECK1_JOG = 0x19
@@ -42,22 +28,24 @@ DECK2_PLAY = 0x42
DECK2_CUE = 0x3C DECK2_CUE = 0x3C
DECK2_JOG = 0x18 DECK2_JOG = 0x18
# Cart buttons - confirmed from hardware test
CART_NOTES = { CART_NOTES = {
0x44: 0, # Cart slot 1 0x44: 0,
0x43: 1, # Cart slot 2 0x43: 1,
0x46: 2, # Cart slot 3 0x46: 2,
0x45: 3, # Cart slot 4 0x45: 3,
} }
JOG_SCRUB_SECONDS = 5.0 JOG_SCRUB_SECONDS = 5.0
JOG_DEAD_ZONE = 5 # Ignore movements below this threshold
def _decode_relative_jog(value: int) -> int: def _decode_relative_jog(value: int) -> int:
"""Returns movement amount, or 0 if within dead zone."""
if 1 <= value <= 63: if 1 <= value <= 63:
return 1 return value if value >= JOG_DEAD_ZONE else 0
elif 65 <= value <= 127: elif 65 <= value <= 127:
return -1 backward = 128 - value
return -backward if backward >= JOG_DEAD_ZONE else 0
return 0 return 0
@@ -148,14 +136,6 @@ class MidiEngine(QObject):
value = message[2] if len(message) > 2 else 0 value = message[2] if len(message) > 2 else 0
status_type = status & 0xF0 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: if status_type == NOTE_ON and value > 0:
self._handle_note_on(data_byte) self._handle_note_on(data_byte)
@@ -164,43 +144,25 @@ class MidiEngine(QObject):
def _handle_note_on(self, note: int): def _handle_note_on(self, note: int):
if note == DECK1_PLAY: if note == DECK1_PLAY:
logger.debug("DECK 1 PLAY/PAUSE")
self.deck_play_pause.emit('deck1') self.deck_play_pause.emit('deck1')
elif note == DECK1_CUE: elif note == DECK1_CUE:
logger.debug("DECK 1 CUE")
self.deck_cue.emit('deck1') self.deck_cue.emit('deck1')
elif note == DECK2_PLAY: elif note == DECK2_PLAY:
logger.debug("DECK 2 PLAY/PAUSE")
self.deck_play_pause.emit('deck2') self.deck_play_pause.emit('deck2')
elif note == DECK2_CUE: elif note == DECK2_CUE:
logger.debug("DECK 2 CUE")
self.deck_cue.emit('deck2') self.deck_cue.emit('deck2')
elif note in CART_NOTES: elif note in CART_NOTES:
slot = CART_NOTES[note] self.cart_trigger.emit(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): def _handle_cc(self, cc_num: int, value: int):
direction = _decode_relative_jog(value) movement = _decode_relative_jog(value)
if direction == 0: if movement == 0:
return return
delta = direction * JOG_SCRUB_SECONDS # Scale movement to seconds
delta = (movement / 10.0) * JOG_SCRUB_SECONDS
if cc_num == DECK1_JOG: if cc_num == DECK1_JOG:
logger.debug(f"DECK 1 JOG delta={delta:+.1f}s")
self.deck_jog.emit('deck1', delta) self.deck_jog.emit('deck1', delta)
elif cc_num == DECK2_JOG: elif cc_num == DECK2_JOG:
logger.debug(f"DECK 2 JOG delta={delta:+.1f}s")
self.deck_jog.emit('deck2', delta) 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 from datetime import datetime
# ─────────────────────────────────────────
# THEMES
# ─────────────────────────────────────────
THEMES = [ THEMES = [
# Original 4
{ {
'name': 'GREEN', 'name': 'GREEN',
'accent': '#00ff41', 'accent': '#00ff41',
@@ -47,6 +44,97 @@ THEMES = [
'border': '#3a0000', 'border': '#3a0000',
'text': '#ddcccc', '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',
},
] ]