Tired and Poor and Old - Radio Panel v0.1.1
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
+36
-70
@@ -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.
|
||||||
|
"""
|
||||||
if self.record_position >= self.record_target_frames:
|
# Mean square energy across 3 second window
|
||||||
self.recording = False
|
mean_square = np.mean(self.lufs_buffer ** 2)
|
||||||
if self.record_callback:
|
|
||||||
audio_copy = self.record_buffer.copy()
|
if mean_square < 1e-12: # Silence threshold
|
||||||
cb = self.record_callback
|
self.lufs_current = -70.0
|
||||||
sr = self.sr
|
else:
|
||||||
self.record_buffer = None
|
# Convert to dB (simplified - proper K-weighting would use filters)
|
||||||
threading.Thread(target=cb, args=(audio_copy, sr), daemon=True).start()
|
self.lufs_current = -0.691 + 10 * np.log10(mean_square)
|
||||||
|
|
||||||
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():
|
||||||
|
|||||||
+22
-24
@@ -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)
|
||||||
@@ -292,4 +284,10 @@ class CartBankWidget(QWidget):
|
|||||||
grid.addWidget(slot, row, col)
|
grid.addWidget(slot, row, col)
|
||||||
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
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"""
|
||||||
|
|||||||
+15
-53
@@ -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
@@ -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',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user