diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c33b61f --- /dev/null +++ b/AGENTS.md @@ -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. \ No newline at end of file diff --git a/README b/README new file mode 100644 index 0000000..6bcacca --- /dev/null +++ b/README @@ -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 + + diff --git a/__pycache__/audio_engine.cpython-314.pyc b/__pycache__/audio_engine.cpython-314.pyc index 91cd2a4..64d474e 100644 Binary files a/__pycache__/audio_engine.cpython-314.pyc and b/__pycache__/audio_engine.cpython-314.pyc differ diff --git a/__pycache__/cart_widget.cpython-314.pyc b/__pycache__/cart_widget.cpython-314.pyc index 05f67ed..1f5498c 100644 Binary files a/__pycache__/cart_widget.cpython-314.pyc and b/__pycache__/cart_widget.cpython-314.pyc differ diff --git a/__pycache__/deck_widget.cpython-314.pyc b/__pycache__/deck_widget.cpython-314.pyc index 214eaa2..a5d7a1d 100644 Binary files a/__pycache__/deck_widget.cpython-314.pyc and b/__pycache__/deck_widget.cpython-314.pyc differ diff --git a/__pycache__/history_widget.cpython-314.pyc b/__pycache__/history_widget.cpython-314.pyc index 0c10aff..92ac722 100644 Binary files a/__pycache__/history_widget.cpython-314.pyc and b/__pycache__/history_widget.cpython-314.pyc differ diff --git a/__pycache__/lufs_meter_widget.cpython-314.pyc b/__pycache__/lufs_meter_widget.cpython-314.pyc new file mode 100644 index 0000000..692b7a6 Binary files /dev/null and b/__pycache__/lufs_meter_widget.cpython-314.pyc differ diff --git a/__pycache__/midi_engine.cpython-314.pyc b/__pycache__/midi_engine.cpython-314.pyc index 2a40488..88a303e 100644 Binary files a/__pycache__/midi_engine.cpython-314.pyc and b/__pycache__/midi_engine.cpython-314.pyc differ diff --git a/__pycache__/playlist_widget.cpython-314.pyc b/__pycache__/playlist_widget.cpython-314.pyc index 9a6f9b6..f97dad9 100644 Binary files a/__pycache__/playlist_widget.cpython-314.pyc and b/__pycache__/playlist_widget.cpython-314.pyc differ diff --git a/__pycache__/top_bar_widget.cpython-314.pyc b/__pycache__/top_bar_widget.cpython-314.pyc index 2f2c624..25db27a 100644 Binary files a/__pycache__/top_bar_widget.cpython-314.pyc and b/__pycache__/top_bar_widget.cpython-314.pyc differ diff --git a/audio_engine.py b/audio_engine.py index 3624ab4..1edc91a 100644 --- a/audio_engine.py +++ b/audio_engine.py @@ -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 - - if self.record_position >= self.record_target_frames: - self.recording = False - if self.record_callback: - audio_copy = self.record_buffer.copy() - cb = self.record_callback - sr = self.sr - self.record_buffer = None - threading.Thread(target=cb, args=(audio_copy, sr), daemon=True).start() - - def start_recording(self, duration_seconds: int, callback=None): - frames = int(duration_seconds * self.sr) - self.record_buffer = np.zeros((2, frames), dtype=np.float32) - self.record_position = 0 - self.record_target_frames = frames - self.record_callback = callback - self.recording = True - - def stop_recording(self): - self.recording = False - actual = self.record_buffer[:, :self.record_position].copy() - self.record_buffer = None - return actual, self.sr + def _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 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(): diff --git a/cart_widget.py b/cart_widget.py index e46f0fa..48a5222 100644 --- a/cart_widget.py +++ b/cart_widget.py @@ -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) @@ -292,4 +284,10 @@ class CartBankWidget(QWidget): grid.addWidget(slot, row, col) self.slots.append(slot) - root.addLayout(grid) \ No newline at end of file + root.addLayout(grid) + + def apply_theme(self, theme: dict): + """Propagate theme to all cart slots.""" + for slot in self.slots: + slot.apply_theme(theme) + \ No newline at end of file diff --git a/deck_widget.py b/deck_widget.py index 02a71b0..e120d81 100644 --- a/deck_widget.py +++ b/deck_widget.py @@ -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( diff --git a/history_widget.py b/history_widget.py index 26744e6..6febf8a 100644 --- a/history_widget.py +++ b/history_widget.py @@ -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) diff --git a/lufs_meter_widget.py b/lufs_meter_widget.py new file mode 100644 index 0000000..e8033df --- /dev/null +++ b/lufs_meter_widget.py @@ -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) \ No newline at end of file diff --git a/main.py b/main.py index d6f49b3..231b635 100644 --- a/main.py +++ b/main.py @@ -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""" diff --git a/midi_engine.py b/midi_engine.py index 9d6065b..a28e633 100644 --- a/midi_engine.py +++ b/midi_engine.py @@ -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}") \ No newline at end of file + self.deck_jog.emit('deck2', delta) \ No newline at end of file diff --git a/top_bar_widget.py b/top_bar_widget.py index 088639d..2e30805 100644 --- a/top_bar_widget.py +++ b/top_bar_widget.py @@ -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', + }, ]