commit a9249d395f02236cd720f9851a791d41815db1fa Author: njmb Date: Wed Apr 29 16:21:44 2026 +1000 Initial commit - Radio Panel v0.1 diff --git a/__pycache__/audio_engine.cpython-314.pyc b/__pycache__/audio_engine.cpython-314.pyc new file mode 100644 index 0000000..91cd2a4 Binary files /dev/null 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 new file mode 100644 index 0000000..05f67ed Binary files /dev/null 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 new file mode 100644 index 0000000..214eaa2 Binary files /dev/null and b/__pycache__/deck_widget.cpython-314.pyc differ diff --git a/__pycache__/fingerprint_widget.cpython-314.pyc b/__pycache__/fingerprint_widget.cpython-314.pyc new file mode 100644 index 0000000..bc55621 Binary files /dev/null and b/__pycache__/fingerprint_widget.cpython-314.pyc differ diff --git a/__pycache__/history_widget.cpython-314.pyc b/__pycache__/history_widget.cpython-314.pyc new file mode 100644 index 0000000..0c10aff Binary files /dev/null and b/__pycache__/history_widget.cpython-314.pyc differ diff --git a/__pycache__/midi_engine.cpython-314.pyc b/__pycache__/midi_engine.cpython-314.pyc new file mode 100644 index 0000000..2a40488 Binary files /dev/null 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 new file mode 100644 index 0000000..9a6f9b6 Binary files /dev/null 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 new file mode 100644 index 0000000..2f2c624 Binary files /dev/null and b/__pycache__/top_bar_widget.cpython-314.pyc differ diff --git a/audio_engine.py b/audio_engine.py new file mode 100644 index 0000000..3624ab4 --- /dev/null +++ b/audio_engine.py @@ -0,0 +1,306 @@ +# audio_engine.py + +import numpy as np +import soundfile as sf +import jack +import threading +import subprocess +import os + + +# ───────────────────────────────────────── +# FORMAT LOADER +# ───────────────────────────────────────── + +SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'} +FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'} + + +def load_audio(filepath: str, target_sr: int) -> np.ndarray: + ext = os.path.splitext(filepath)[1].lower() + + if ext in SOUNDFILE_FORMATS: + data, sr = sf.read(filepath, always_2d=True, dtype='float32') + data = data.T + + elif ext in FFMPEG_FORMATS: + cmd = [ + 'ffmpeg', '-i', filepath, '-f', 'f32le', '-acodec', 'pcm_f32le', + '-ar', str(target_sr), '-ac', '2', '-', '-loglevel', 'quiet' + ] + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.returncode != 0: + raise ValueError(f"FFmpeg failed: {result.stderr.decode()}") + raw = np.frombuffer(result.stdout, dtype=np.float32) + data = raw.reshape(-1, 2).T + sr = target_sr + + else: + try: + data, sr = sf.read(filepath, always_2d=True, dtype='float32') + data = data.T + except Exception as e: + raise ValueError(f"Unsupported format: {ext} — {e}") + + if sr != target_sr: + import librosa + data = np.array([ + librosa.resample(data[ch], orig_sr=sr, target_sr=target_sr) + for ch in range(data.shape[0]) + ], dtype=np.float32) + + if data.shape[0] == 1: + data = np.vstack([data, data]) + + data = np.clip(data, -1.0, 1.0) + return data + + +# ───────────────────────────────────────── +# TRACK +# ───────────────────────────────────────── + +class Track: + def __init__(self, filepath: str, audio: np.ndarray, sr: int): + self.filepath = filepath + self.filename = os.path.basename(filepath) + self.audio = audio + self.sr = sr + self.num_samples = audio.shape[1] + self.duration = self.num_samples / sr + + +# ───────────────────────────────────────── +# DECK STATE +# ───────────────────────────────────────── + +class DeckState: + def __init__(self): + self.track = None + self.queued = None + self.position = 0 + self.playing = False + self.lock = threading.Lock() + + def load(self, track: Track): + with self.lock: + self.track = track + self.position = 0 + self.playing = False + + def load_queue(self, track: Track): + with self.lock: + self.queued = track + + def play(self): + with self.lock: + if self.track: + self.playing = True + + def pause(self): + with self.lock: + self.playing = False + + def stop(self): + with self.lock: + self.playing = False + self.track = None + self.position = 0 + + def skip_seconds(self, seconds: int): + with self.lock: + if self.track: + new = self.position + int(seconds * self.track.sr) + self.position = max(0, min(new, self.track.num_samples - 1)) + + def go_to_start(self): + with self.lock: + self.position = 0 + + def go_to_end(self): + with self.lock: + if self.track: + self.position = self.track.num_samples - 1 + + def get_elapsed(self) -> str: + if not self.track: + return "00:00" + s = self.position / self.track.sr + return f"{int(s//60):02d}:{int(s%60):02d}" + + def get_remaining(self) -> str: + if not self.track: + return "-00:00" + remaining = max(0, self.track.duration - (self.position / self.track.sr)) + return f"-{int(remaining//60):02d}:{int(remaining%60):02d}" + + +# ───────────────────────────────────────── +# AUDIO ENGINE +# ───────────────────────────────────────── + +class AudioEngine: + def __init__(self): + self.client = jack.Client("RadioPanel") + self.sr = self.client.samplerate + + # Output ports + self.ports = { + 'deck1': ( + self.client.outports.register("deck1_L"), + self.client.outports.register("deck1_R"), + ), + 'deck2': ( + self.client.outports.register("deck2_L"), + self.client.outports.register("deck2_R"), + ), + 'cart': ( + self.client.outports.register("cart_L"), + self.client.outports.register("cart_R"), + ), + } + + # Input ports for fingerprinting + self.inports = { + 'fingerprint': ( + self.client.inports.register("fingerprint_L"), + self.client.inports.register("fingerprint_R"), + ) + } + + # Deck states + self.decks = { + 'deck1': DeckState(), + 'deck2': DeckState(), + 'cart1': DeckState(), + 'cart2': DeckState(), + 'cart3': DeckState(), + 'cart4': DeckState(), + } + + # Recording state + self.recording = False + self.record_buffer = None + self.record_position = 0 + self.record_target_frames = 0 + self.record_callback = None + + # VU levels (L, R) 0.0-1.0 + self.fingerprint_levels = [0.0, 0.0] + + self.client.set_process_callback(self._process) + self.client.activate() + + def _fill_port(self, port_L, port_R, deck: DeckState, frames: int): + buf_L = port_L.get_array() + buf_R = port_R.get_array() + buf_L.fill(0.0) + buf_R.fill(0.0) + + if not deck.playing or deck.track is None: + return + + pos = deck.position + track = deck.track + end = min(pos + frames, track.num_samples) + n = end - pos + + if n <= 0: + deck.playing = False + return + + buf_L[:n] = track.audio[0, pos:end] + buf_R[:n] = track.audio[1, pos:end] + deck.position += n + + if deck.position >= track.num_samples: + deck.playing = False + deck.position = track.num_samples - 1 + + def _process(self, frames: int): + # Decks + self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames) + self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames) + + # Mix carts + buf_L = self.ports['cart'][0].get_array() + buf_R = self.ports['cart'][1].get_array() + buf_L.fill(0.0) + buf_R.fill(0.0) + + for cart_key in ('cart1', 'cart2', 'cart3', 'cart4'): + deck = self.decks[cart_key] + if not deck.playing or deck.track is None: + continue + pos = deck.position + track = deck.track + end = min(pos + frames, track.num_samples) + n = end - pos + if n <= 0: + deck.playing = False + continue + buf_L[:n] += track.audio[0, pos:end] + buf_R[:n] += track.audio[1, pos:end] + deck.position += n + if deck.position >= track.num_samples: + deck.playing = False + deck.position = track.num_samples - 1 + + # Fingerprint recording + VU + in_L = self.inports['fingerprint'][0].get_array() + in_R = self.inports['fingerprint'][1].get_array() + + rms_l = np.sqrt(np.mean(in_L**2)) if len(in_L) > 0 else 0.0 + rms_r = np.sqrt(np.mean(in_R**2)) if len(in_R) > 0 else 0.0 + self.fingerprint_levels[0] = min(1.0, rms_l * 8) + self.fingerprint_levels[1] = min(1.0, rms_r * 8) + + if self.recording and self.record_buffer is not None: + remaining = self.record_target_frames - self.record_position + to_record = min(frames, remaining) + + if to_record > 0: + self.record_buffer[0, self.record_position:self.record_position + to_record] = in_L[:to_record] + self.record_buffer[1, self.record_position:self.record_position + to_record] = in_R[:to_record] + self.record_position += to_record + + if self.record_position >= self.record_target_frames: + self.recording = False + if self.record_callback: + audio_copy = self.record_buffer.copy() + cb = self.record_callback + sr = self.sr + self.record_buffer = None + threading.Thread(target=cb, args=(audio_copy, sr), daemon=True).start() + + def start_recording(self, duration_seconds: int, callback=None): + frames = int(duration_seconds * self.sr) + self.record_buffer = np.zeros((2, frames), dtype=np.float32) + self.record_position = 0 + self.record_target_frames = frames + self.record_callback = callback + self.recording = True + + def stop_recording(self): + self.recording = False + actual = self.record_buffer[:, :self.record_position].copy() + self.record_buffer = None + return actual, self.sr + + def load_track(self, deck_key: str, filepath: str): + def _load(): + audio = load_audio(filepath, self.sr) + track = Track(filepath, audio, self.sr) + self.decks[deck_key].load(track) + threading.Thread(target=_load, daemon=True).start() + + def load_queue(self, deck_key: str, filepath: str): + def _load(): + audio = load_audio(filepath, self.sr) + track = Track(filepath, audio, self.sr) + self.decks[deck_key].load_queue(track) + threading.Thread(target=_load, daemon=True).start() + + def shutdown(self): + self.client.deactivate() + self.client.close() \ No newline at end of file diff --git a/audio_test.py b/audio_test.py new file mode 100644 index 0000000..5625491 --- /dev/null +++ b/audio_test.py @@ -0,0 +1,68 @@ +# audio_test.py +import soundfile as sf +import numpy as np +import jack +import sys + +def test_audio(filepath): + client = jack.Client("AudioTest") + sr = client.samplerate + + out_L = client.outports.register("test_L") + out_R = client.outports.register("test_R") + + # Load file + try: + data, file_sr = sf.read(filepath, always_2d=True) + data = data.T.astype(np.float32) + print(f"✓ Loaded: {data.shape} at {file_sr}Hz") + if file_sr != sr: + print(f"⚠ Sample rate mismatch: file={file_sr} JACK={sr}") + print(f" Install librosa for auto-resampling") + except Exception as e: + print(f"✗ Load failed: {e}") + sys.exit(1) + + position = [0] + playing = [True] + + def callback(frames): + out_L.get_array().fill(0.0) + out_R.get_array().fill(0.0) + + if playing[0]: + pos = position[0] + end = min(pos + frames, data.shape[1]) + chunk = data[:, pos:end] + n = chunk.shape[1] + + out_L.get_array()[:n] = chunk[0] + out_R.get_array()[:n] = chunk[1] if data.shape[0] > 1 else chunk[0] + + position[0] += n + if position[0] >= data.shape[1]: + playing[0] = False + print("✓ Playback complete") + + client.set_process_callback(callback) + client.activate() + + print(f"Playing {filepath}") + print("Wire 'AudioTest:test_L/R' in qpwgraph to hear audio") + print("Press Ctrl+C to stop") + + import time + try: + while playing[0]: + time.sleep(0.1) + except KeyboardInterrupt: + pass + + client.deactivate() + client.close() + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python audio_test.py /path/to/file.flac") + sys.exit(1) + test_audio(sys.argv[1]) \ No newline at end of file diff --git a/cart_widget.py b/cart_widget.py new file mode 100644 index 0000000..e46f0fa --- /dev/null +++ b/cart_widget.py @@ -0,0 +1,295 @@ +# cart_widget.py + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QFrame, QSizePolicy, QGridLayout +) +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QFont +import os + + +# ───────────────────────────────────────── +# SINGLE CART SLOT +# ───────────────────────────────────────── + +class CartSlot(QFrame): + def __init__(self, cart_key: str, slot_number: int, engine, parent=None): + super().__init__(parent) + self.cart_key = cart_key + self.slot_number = slot_number + self.engine = engine + + self.setFrameStyle(QFrame.Box | QFrame.Raised) + self.setLineWidth(1) + self.setStyleSheet(""" + QFrame { + background-color: #1e1e1e; + border: 1px solid #444; + border-radius: 5px; + } + """) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setFixedHeight(110) + + self._build_ui() + self._connect_signals() + + self.timer = QTimer(self) + self.timer.setInterval(100) + self.timer.timeout.connect(self._refresh_display) + self.timer.start() + + def _build_ui(self): + root = QHBoxLayout(self) + root.setContentsMargins(8, 6, 8, 6) + root.setSpacing(8) + + badge = QLabel(str(self.slot_number)) + badge.setFixedSize(28, 28) + badge.setAlignment(Qt.AlignCenter) + badge.setFont(QFont("Arial", 12, QFont.Bold)) + badge.setStyleSheet(""" + background-color: #cc3300; + color: white; + border-radius: 14px; + border: none; + """) + root.addWidget(badge, alignment=Qt.AlignVCenter) + + lcd_frame = QFrame() + lcd_frame.setStyleSheet(""" + QFrame { + background-color: #0a1a0a; + border: 1px solid #1a2a1a; + border-radius: 3px; + } + """) + lcd_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + lcd_layout = QVBoxLayout(lcd_frame) + lcd_layout.setContentsMargins(6, 4, 6, 4) + lcd_layout.setSpacing(2) + + self.track_label = QLabel("EMPTY") + self.track_label.setFont(QFont("Courier New", 9, QFont.Bold)) + self.track_label.setStyleSheet( + "color: #00ff41; background: transparent; border: none;" + ) + self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + + time_row = QHBoxLayout() + + self.elapsed_label = QLabel("00:00") + self.elapsed_label.setFont(QFont("Courier New", 14, QFont.Bold)) + self.elapsed_label.setStyleSheet( + "color: #00ff41; background: transparent; border: none;" + ) + + self.remaining_label = QLabel("-00:00") + self.remaining_label.setFont(QFont("Courier New", 10)) + self.remaining_label.setStyleSheet( + "color: #00cc33; background: transparent; border: none;" + ) + self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + time_row.addWidget(self.elapsed_label) + time_row.addStretch() + time_row.addWidget(self.remaining_label) + + self.status_label = QLabel("STOPPED") + self.status_label.setFont(QFont("Courier New", 8)) + self.status_label.setStyleSheet( + "color: #008822; background: transparent; border: none;" + ) + + lcd_layout.addWidget(self.track_label) + lcd_layout.addLayout(time_row) + lcd_layout.addWidget(self.status_label) + + root.addWidget(lcd_frame) + + btn_col = QVBoxLayout() + btn_col.setSpacing(4) + + self.btn_play = QPushButton("▶") + self.btn_play.setFixedSize(60, 44) + self.btn_play.setFont(QFont("Arial", 13, QFont.Bold)) + self.btn_play.setStyleSheet(""" + QPushButton { + background-color: #1a6b3a; + color: #00ff41; + border: 1px solid #555; + border-radius: 3px; + } + QPushButton:hover { background-color: #2a7b4a; } + QPushButton:pressed { background-color: #0a5a2a; } + """) + + self.btn_eject = QPushButton("⏏") + self.btn_eject.setFixedSize(60, 30) + self.btn_eject.setFont(QFont("Arial", 11, QFont.Bold)) + self.btn_eject.setStyleSheet(""" + QPushButton { + background-color: #8a1a1a; + color: #ff4444; + border: 1px solid #555; + border-radius: 3px; + } + QPushButton:hover { background-color: #9a2a2a; } + QPushButton:pressed { background-color: #6a0a0a; } + """) + + btn_col.addWidget(self.btn_play) + btn_col.addWidget(self.btn_eject) + + root.addLayout(btn_col) + + def _connect_signals(self): + self.btn_play.clicked.connect(self._toggle_play) + self.btn_eject.clicked.connect(self._eject) + + def load_track(self, filepath: str): + self.engine.load_track(self.cart_key, filepath) + name = os.path.basename(filepath) + short = name[:28] + "..." if len(name) > 28 else name + self.track_label.setText(short) + self.status_label.setText("LOADED") + self.elapsed_label.setText("00:00") + self.remaining_label.setText("-00:00") + + # ── PUBLIC MIDI METHOD ──────────────────────────────────────────────── + + def toggle(self): + """ + MIDI toggle - playing → stop and reset to start. + Stopped/paused → play. + """ + deck = self.engine.decks[self.cart_key] + if deck.track is None: + return + + if deck.playing: + deck.stop() + deck.go_to_start() + self._set_ui_stopped() + self.status_label.setText("READY") + else: + deck.play() + self._set_ui_playing() + + # ── SHARED UI STATE HELPERS ─────────────────────────────────────────── + + def _set_ui_playing(self): + self.btn_play.setText("⏸") + self.btn_play.setStyleSheet(""" + QPushButton { + background-color: #00aa33; + color: white; + border: 1px solid #00ff41; + border-radius: 3px; + } + QPushButton:hover { background-color: #00cc44; } + """) + self.status_label.setText("PLAYING") + self.elapsed_label.setStyleSheet( + "color: #00ff41; background: transparent; border: none;" + ) + + def _set_ui_stopped(self): + self.btn_play.setText("▶") + self.btn_play.setStyleSheet(""" + QPushButton { + background-color: #1a6b3a; + color: #00ff41; + border: 1px solid #555; + border-radius: 3px; + } + QPushButton:hover { background-color: #2a7b4a; } + """) + self.elapsed_label.setText("00:00") + self.remaining_label.setText("-00:00") + self.elapsed_label.setStyleSheet( + "color: #00aa2a; background: transparent; border: none;" + ) + + # ── ACTIONS ─────────────────────────────────────────────────────────── + + def _toggle_play(self): + deck = self.engine.decks[self.cart_key] + if deck.track is None: + return + if deck.playing: + deck.pause() + self._set_ui_stopped() + self.status_label.setText("PAUSED") + else: + deck.play() + self._set_ui_playing() + + def _eject(self): + deck = self.engine.decks[self.cart_key] + deck.stop() + self._set_ui_stopped() + self.track_label.setText("EMPTY") + self.status_label.setText("STOPPED") + + def _refresh_display(self): + deck = self.engine.decks[self.cart_key] + + if not deck.playing and self.btn_play.text() == "⏸": + self._set_ui_stopped() + self.status_label.setText("STOPPED") + + if deck.track: + self.elapsed_label.setText(deck.get_elapsed()) + self.remaining_label.setText(deck.get_remaining()) + + +# ───────────────────────────────────────── +# CART BANK +# ───────────────────────────────────────── + +class CartBankWidget(QWidget): + def __init__(self, engine, parent=None): + super().__init__(parent) + self.engine = engine + self._build_ui() + + def _build_ui(self): + self.setStyleSheet(""" + QWidget { + background-color: #111; + border: 2px solid #cc3300; + border-radius: 6px; + } + """) + + root = QVBoxLayout(self) + root.setContentsMargins(8, 6, 8, 6) + root.setSpacing(6) + + header = QLabel("CARTS") + header.setFont(QFont("Arial", 11, QFont.Bold)) + header.setStyleSheet(""" + color: #cc3300; + background: transparent; + border: none; + padding: 2px; + """) + header.setAlignment(Qt.AlignCenter) + root.addWidget(header) + + grid = QGridLayout() + grid.setSpacing(6) + + self.slots = [] + for i in range(4): + cart_key = f"cart{i + 1}" + slot = CartSlot(cart_key, i + 1, self.engine) + row = i // 2 + col = i % 2 + grid.addWidget(slot, row, col) + self.slots.append(slot) + + root.addLayout(grid) \ No newline at end of file diff --git a/debug_audio.py b/debug_audio.py new file mode 100644 index 0000000..ed4e7d0 --- /dev/null +++ b/debug_audio.py @@ -0,0 +1,26 @@ +# debug_audio.py - save and run this +import jack +import numpy as np +import time + +client = jack.Client("DebugTest") +client.activate() + +in_L = client.inports.register("test_in_L") +in_R = client.inports.register("test_in_R") + +@client.set_process_callback +def process(frames): + lvl_L = np.max(np.abs(in_L.get_array())) if len(in_L.get_array()) > 0 else 0 + lvl_R = np.max(np.abs(in_R.get_array())) if len(in_R.get_array()) > 0 else 0 + if lvl_L > 0.001 or lvl_R > 0.001: + print(f"Levels: L={lvl_L:.4f} R={lvl_R:.4f}") + +print("Connect DebugTest:test_in_L/R to your audio source in qpwgraph") +print("Press Ctrl+C to stop") + +try: + while True: + time.sleep(1) +except KeyboardInterrupt: + client.deactivate() \ No newline at end of file diff --git a/deck_widget.py b/deck_widget.py new file mode 100644 index 0000000..02a71b0 --- /dev/null +++ b/deck_widget.py @@ -0,0 +1,347 @@ +# deck_widget.py + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QFrame, QSizePolicy +) +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtGui import QFont, QColor +import os + + +# ───────────────────────────────────────── +# LCD DISPLAY WIDGET +# ───────────────────────────────────────── + +class LCDDisplay(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setFrameStyle(QFrame.Box | QFrame.Sunken) + self.setStyleSheet(""" + QFrame { + background-color: #0a1a0a; + border: 2px solid #1a2a1a; + border-radius: 4px; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 8, 10, 8) + layout.setSpacing(4) + + self.track_label = QLabel("NO TRACK LOADED") + self.track_label.setFont(QFont("Courier New", 13, QFont.Bold)) + self.track_label.setStyleSheet( + "color: #00ff41; background: transparent;" + ) + self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self.track_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + time_row = QHBoxLayout() + time_row.setSpacing(4) + + self.elapsed_label = QLabel("00:00") + self.elapsed_label.setFont(QFont("Courier New", 32, QFont.Bold)) + self.elapsed_label.setStyleSheet( + "color: #00ff41; background: transparent;" + ) + self.elapsed_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + + self.remaining_label = QLabel("-00:00") + self.remaining_label.setFont(QFont("Courier New", 18, QFont.Bold)) + self.remaining_label.setStyleSheet( + "color: #00cc33; background: transparent;" + ) + self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + time_row.addWidget(self.elapsed_label) + time_row.addStretch() + time_row.addWidget(self.remaining_label) + + status_row = QHBoxLayout() + + self.status_label = QLabel("STOPPED") + self.status_label.setFont(QFont("Courier New", 11, QFont.Bold)) + self.status_label.setStyleSheet( + "color: #008822; background: transparent;" + ) + + self.queue_label = QLabel("QUEUE: EMPTY") + self.queue_label.setFont(QFont("Courier New", 10)) + self.queue_label.setStyleSheet( + "color: #008822; background: transparent;" + ) + self.queue_label.setAlignment(Qt.AlignRight) + + status_row.addWidget(self.status_label) + status_row.addStretch() + status_row.addWidget(self.queue_label) + + layout.addWidget(self.track_label) + layout.addLayout(time_row) + layout.addLayout(status_row) + + def update_track(self, name: str): + if len(name) > 40: + name = name[:37] + "..." + self.track_label.setText(name) + + def update_time(self, elapsed: str, remaining: str): + self.elapsed_label.setText(elapsed) + self.remaining_label.setText(remaining) + + def update_status(self, status: str): + self.status_label.setText(status) + + def update_queue(self, name: str): + if name: + short = name[:25] + "..." if len(name) > 25 else name + self.queue_label.setText(f"QUEUE: {short}") + else: + self.queue_label.setText("QUEUE: EMPTY") + + def set_playing(self, playing: bool): + colour = "#00ff41" if playing else "#00aa2a" + self.elapsed_label.setStyleSheet( + f"color: {colour}; background: transparent;" + ) + + +# ───────────────────────────────────────── +# TRANSPORT BUTTON HELPER +# ───────────────────────────────────────── + +def make_transport_button(text: str, + color: str = "#3a3a3a", + text_color: str = "white", + width: int = 52, + height: int = 48) -> QPushButton: + btn = QPushButton(text) + btn.setFixedSize(width, height) + btn.setFont(QFont("Arial", 11, QFont.Bold)) + btn.setStyleSheet(f""" + QPushButton {{ + background-color: {color}; + color: {text_color}; + border: 1px solid #555; + border-radius: 4px; + }} + QPushButton:hover {{ + background-color: #555; + }} + QPushButton:pressed {{ + background-color: #222; + border: 1px solid #888; + }} + """) + return btn + + +# ───────────────────────────────────────── +# DECK WIDGET +# ───────────────────────────────────────── + +class DeckWidget(QWidget): + track_started = Signal(str) # emits filepath when playback begins + + def __init__(self, deck_key: str, label: str, accent_color: str, + engine, parent=None): + super().__init__(parent) + self.deck_key = deck_key + self.engine = engine + self.accent_color = accent_color + + self._build_ui(label) + self._connect_signals() + + self.timer = QTimer(self) + self.timer.setInterval(100) + self.timer.timeout.connect(self._refresh_display) + self.timer.start() + + def _build_ui(self, label: str): + self.setStyleSheet(f""" + QWidget {{ + background-color: #1c1c1c; + border: 2px solid {self.accent_color}; + border-radius: 6px; + }} + """) + + root = QVBoxLayout(self) + root.setContentsMargins(10, 10, 10, 10) + root.setSpacing(8) + + header = QLabel(label) + header.setFont(QFont("Arial", 13, QFont.Bold)) + header.setStyleSheet(f""" + color: {self.accent_color}; + background: transparent; + border: none; + padding: 2px; + """) + header.setAlignment(Qt.AlignCenter) + root.addWidget(header) + + self.lcd = LCDDisplay() + root.addWidget(self.lcd) + + transport = QHBoxLayout() + transport.setSpacing(6) + + self.btn_to_start = make_transport_button("⏮", "#3a3a3a", "white", 52, 48) + self.btn_back_15 = make_transport_button("-15", "#3a3a3a", "#aaaaaa", 52, 48) + self.btn_play = make_transport_button("▶", "#1a6b3a", "#00ff41", 68, 48) + self.btn_fwd_15 = make_transport_button("+15", "#3a3a3a", "#aaaaaa", 52, 48) + self.btn_to_end = make_transport_button("⏭", "#3a3a3a", "white", 52, 48) + self.btn_stop = make_transport_button("■", "#8a1a1a", "#ff4444", 52, 48) + + transport.addWidget(self.btn_to_start) + transport.addWidget(self.btn_back_15) + transport.addWidget(self.btn_play) + transport.addWidget(self.btn_fwd_15) + transport.addWidget(self.btn_to_end) + transport.addStretch() + transport.addWidget(self.btn_stop) + + root.addLayout(transport) + + def _connect_signals(self): + self.btn_play.clicked.connect(self._toggle_play) + self.btn_stop.clicked.connect(self._stop_eject) + self.btn_to_start.clicked.connect(self._go_to_start) + self.btn_to_end.clicked.connect(self._go_to_end) + self.btn_back_15.clicked.connect(lambda: self._scrub(-15)) + self.btn_fwd_15.clicked.connect(lambda: self._scrub(15)) + + # ── PUBLIC — called by playlist ─────────────────────────────────────── + + def set_track_name(self, name: str): + self.lcd.update_track(name) + self.lcd.update_status("LOADED") + self.lcd.update_time("00:00", "-00:00") + + def set_queue_name(self, name: str): + self.lcd.update_queue(name) + + # ── PUBLIC MIDI METHODS ─────────────────────────────────────────────── + + def toggle_play(self): + self._toggle_play() + + def cue(self): + deck = self.engine.decks[self.deck_key] + if deck.track is None: + return + if deck.playing: + deck.pause() + deck.go_to_start() + self.btn_play.setText("▶") + self.btn_play.setStyleSheet(""" + QPushButton { + background-color: #1a6b3a; + color: #00ff41; + border: 1px solid #555; + border-radius: 4px; + } + QPushButton:hover { background-color: #2a7b4a; } + """) + self.lcd.update_status("CUE") + self.lcd.set_playing(False) + + def scrub(self, delta: float): + self._scrub(delta) + + # ── ACTIONS ─────────────────────────────────────────────────────────── + + def _toggle_play(self): + deck = self.engine.decks[self.deck_key] + if deck.track is None: + return + if deck.playing: + deck.pause() + self.btn_play.setText("▶") + self.btn_play.setStyleSheet(""" + QPushButton { + background-color: #1a6b3a; + color: #00ff41; + border: 1px solid #555; + border-radius: 4px; + } + QPushButton:hover { background-color: #2a7b4a; } + """) + self.lcd.update_status("PAUSED") + self.lcd.set_playing(False) + else: + deck.play() + self.btn_play.setText("⏸") + self.btn_play.setStyleSheet(""" + QPushButton { + background-color: #00aa33; + color: white; + border: 1px solid #00ff41; + border-radius: 4px; + } + QPushButton:hover { background-color: #00cc44; } + """) + self.lcd.update_status("PLAYING") + self.lcd.set_playing(True) + if deck.track: + self.track_started.emit(deck.track.filepath) + + def _stop_eject(self): + deck = self.engine.decks[self.deck_key] + deck.stop() + self.btn_play.setText("▶") + self.btn_play.setStyleSheet(""" + QPushButton { + background-color: #1a6b3a; + color: #00ff41; + border: 1px solid #555; + border-radius: 4px; + } + QPushButton:hover { background-color: #2a7b4a; } + """) + self.lcd.update_track("NO TRACK LOADED") + self.lcd.update_time("00:00", "-00:00") + self.lcd.update_status("STOPPED") + self.lcd.set_playing(False) + + def _go_to_start(self): + self.engine.decks[self.deck_key].go_to_start() + + def _go_to_end(self): + self.engine.decks[self.deck_key].go_to_end() + + def _scrub(self, seconds: float): + self.engine.decks[self.deck_key].skip_seconds(seconds) + + # ── DISPLAY REFRESH ─────────────────────────────────────────────────── + + def _refresh_display(self): + deck = self.engine.decks[self.deck_key] + + if not deck.playing and self.btn_play.text() == "⏸": + self.btn_play.setText("▶") + self.btn_play.setStyleSheet(""" + QPushButton { + background-color: #1a6b3a; + color: #00ff41; + border: 1px solid #555; + border-radius: 4px; + } + QPushButton:hover { background-color: #2a7b4a; } + """) + self.lcd.update_status("STOPPED") + self.lcd.set_playing(False) + + if deck.track: + self.lcd.update_time( + deck.get_elapsed(), + deck.get_remaining() + ) + + if deck.queued: + self.lcd.update_queue( + os.path.basename(deck.queued.filepath) + ) \ No newline at end of file diff --git a/fingerprint_widget.py b/fingerprint_widget.py new file mode 100644 index 0000000..99324fc --- /dev/null +++ b/fingerprint_widget.py @@ -0,0 +1,296 @@ +# fingerprint_widget.py + +import os +import json +import tempfile +import subprocess +import threading +import requests +import numpy as np +import soundfile as sf +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, + QPushButton, QProgressBar, QFrame +) +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtGui import QFont + + +class FingerprintWidget(QFrame): + result_ready = Signal(object) + error_ready = Signal(str) + + def __init__(self, engine, api_key: str, parent=None): + super().__init__(parent) + self.engine = engine + self.acoustid_key = api_key.strip() + self.recording_seconds = 25 + self.last_signal_check = False + + self.setFrameStyle(QFrame.Box | QFrame.Raised) + self.setStyleSheet(""" + QFrame { + background-color: #0d0d1a; + border: 2px solid #4444aa; + border-radius: 6px; + } + """) + + self._build_ui() + self.result_ready.connect(self._show_result) + self.error_ready.connect(self._show_error) + + self.signal_timer = QTimer(self) + self.signal_timer.setInterval(500) + self.signal_timer.timeout.connect(self._check_signal) + self.signal_timer.start() + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(10, 8, 10, 8) + root.setSpacing(6) + + header = QLabel("AUDIO FINGERPRINT") + header.setFont(QFont("Arial", 11, QFont.Bold)) + header.setStyleSheet("color: #6666cc; background: transparent; border: none;") + header.setAlignment(Qt.AlignCenter) + root.addWidget(header) + + port_info = QLabel("JACK: RadioPanel:fingerprint_L / R") + port_info.setFont(QFont("Courier New", 8)) + port_info.setStyleSheet("color: #444488; background: transparent; border: none;") + port_info.setAlignment(Qt.AlignCenter) + root.addWidget(port_info) + + self.signal_label = QLabel("● NO SIGNAL") + self.signal_label.setFont(QFont("Courier New", 14, QFont.Bold)) + self.signal_label.setStyleSheet("color: #330000; background: transparent; border: none;") + self.signal_label.setAlignment(Qt.AlignCenter) + root.addWidget(self.signal_label) + + self.progress = QProgressBar() + self.progress.setRange(0, self.recording_seconds * 10) + self.progress.setValue(0) + self.progress.setTextVisible(False) + self.progress.setFixedHeight(8) + self.progress.setStyleSheet(""" + QProgressBar { + border: 1px solid #4444aa; + border-radius: 3px; + background: #0a0a1a; + } + QProgressBar::chunk { + background-color: #6666cc; + } + """) + self.progress.hide() + root.addWidget(self.progress) + + self.status_label = QLabel("READY") + self.status_label.setFont(QFont("Courier New", 10, QFont.Bold)) + self.status_label.setStyleSheet("color: #444488; background: transparent; border: none;") + self.status_label.setAlignment(Qt.AlignCenter) + root.addWidget(self.status_label) + + result_frame = QFrame() + result_frame.setStyleSheet(""" + QFrame { + background-color: #0a0a1a; + border: 1px solid #333366; + border-radius: 3px; + } + """) + result_layout = QVBoxLayout(result_frame) + result_layout.setContentsMargins(6, 6, 6, 6) + + self.result_artist = QLabel("Artist: -") + self.result_artist.setFont(QFont("Courier New", 10, QFont.Bold)) + self.result_artist.setStyleSheet("color: #555588;") + + self.result_title = QLabel("Title: -") + self.result_title.setFont(QFont("Courier New", 11, QFont.Bold)) + self.result_title.setStyleSheet("color: #555588;") + + result_layout.addWidget(self.result_artist) + result_layout.addWidget(self.result_title) + root.addWidget(result_frame) + + self.btn_fingerprint = QPushButton("FINGERPRINT") + self.btn_fingerprint.setFixedHeight(44) + self.btn_fingerprint.setFont(QFont("Arial", 12, QFont.Bold)) + self.btn_fingerprint.setStyleSheet(""" + QPushButton { + background-color: #4444aa; + color: white; + border: 1px solid #6666cc; + border-radius: 4px; + } + QPushButton:hover { background-color: #5555bb; } + QPushButton:pressed { background-color: #333399; } + QPushButton:disabled { + background-color: #222244; + color: #666688; + } + """) + self.btn_fingerprint.clicked.connect(self._start_fingerprint) + root.addWidget(self.btn_fingerprint) + + root.addStretch() + + def _check_signal(self): + if self.engine.recording: + return + levels = self.engine.fingerprint_levels + has_signal = (levels[0] > 0.01 or levels[1] > 0.01) + if has_signal != self.last_signal_check: + self.last_signal_check = has_signal + if has_signal: + self.signal_label.setText("● SIGNAL PRESENT") + self.signal_label.setStyleSheet("color: #00ff41; background: transparent;") + else: + self.signal_label.setText("● NO SIGNAL") + self.signal_label.setStyleSheet("color: #330000; background: transparent;") + + def _start_fingerprint(self): + if not self.acoustid_key: + self.error_ready.emit("Set your AcoustID key in main.py first!") + return + if self.engine.recording: + return + + self.btn_fingerprint.setEnabled(False) + self.progress.show() + self.progress.setValue(0) + self.status_label.setText("LISTENING...") + self.status_label.setStyleSheet("color: #ccaa00;") + self.signal_label.setText("● RECORDING...") + self.signal_label.setStyleSheet("color: #ccaa00;") + self.result_artist.setText("Artist: -") + self.result_title.setText("Title: -") + + self.recording_timer = QTimer(self) + self.recording_timer.setInterval(100) + self.recording_timer.timeout.connect(self._update_progress) + self.recording_timer.start() + + self.engine.start_recording(self.recording_seconds, self._on_recording_finished) + + def _update_progress(self): + if not self.engine.recording: + self.recording_timer.stop() + self.progress.setValue(self.progress.maximum()) + return + if self.engine.record_buffer is not None: + progress = self.engine.record_position / self.engine.record_target_frames + self.progress.setValue(int(progress * self.progress.maximum())) + + def _on_recording_finished(self, audio_data: np.ndarray, sr: int): + self.recording_timer.stop() + self.status_label.setText("ANALYZING...") + self.status_label.setStyleSheet("color: #4488ff;") + self.signal_label.setText("● PROCESSING") + self.signal_label.setStyleSheet("color: #4488ff;") + threading.Thread( + target=self._fingerprint_thread, + args=(audio_data, sr), + daemon=True + ).start() + + def _fingerprint_thread(self, audio_data: np.ndarray, sr: int): + try: + result = self._identify_acoustid(audio_data, sr) + self.result_ready.emit(result) + except Exception as e: + err_str = str(e) + if "Invalid API key" in err_str: + err_str = "Invalid API Key" + elif "limit" in err_str.lower(): + err_str = "Daily limit reached" + self.error_ready.emit(err_str) + + def _identify_acoustid(self, audio_data: np.ndarray, sr: int) -> dict: + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + temp_path = f.name + try: + sf.write(temp_path, audio_data.T, sr, subtype='PCM_16') + + result = subprocess.run( + ['fpcalc', '-json', temp_path], + capture_output=True, + text=True, + timeout=15 + ) + + if result.returncode != 0: + raise RuntimeError(f"fpcalc failed: {result.stderr}") + + fp_data = json.loads(result.stdout) + fingerprint = fp_data['fingerprint'] + duration = fp_data.get('duration', 0) + + finally: + os.unlink(temp_path) + + url = 'https://api.acoustid.org/v2/lookup' + params = { + 'client': self.acoustid_key, + 'fingerprint': fingerprint, + 'duration': int(duration), + 'meta': 'recordings' + } + + response = requests.get(url, params=params, timeout=15) + data = response.json() + + if data.get('status') != 'ok': + err = data.get('error', {}).get('message', 'Unknown') + raise RuntimeError(f"AcoustID: {err}") + + results = data.get('results', []) + if not results: + return None + + best = results[0] + recordings = best.get('recordings', []) + if not recordings: + return None + + rec = recordings[0] + title = rec.get('title', 'Unknown') + artists = rec.get('artists', []) + artist = artists[0].get('name', 'Unknown') if artists else 'Unknown' + + return { + 'artist': artist, + 'title': title, + 'score': best.get('score', 0) + } + + def _show_result(self, result): + self.btn_fingerprint.setEnabled(True) + self.progress.hide() + self.signal_label.setText("● DONE") + self.signal_label.setStyleSheet("color: #00aa33;") + + if result is None: + self.status_label.setText("NO MATCH") + self.status_label.setStyleSheet("color: #aa4444;") + else: + self.status_label.setText(f"MATCH {result['score']:.0%}") + self.status_label.setStyleSheet("color: #00ff41;") + self.result_artist.setText(f"Artist: {result['artist']}") + self.result_title.setText(f"Title: {result['title']}") + self.result_artist.setStyleSheet("color: #cccccc;") + self.result_title.setStyleSheet("color: #00ff41;") + + def _show_error(self, error: str): + self.btn_fingerprint.setEnabled(True) + self.progress.hide() + self.signal_label.setText("● ERROR") + self.signal_label.setStyleSheet("color: #aa4444;") + self.status_label.setText("ERROR") + self.status_label.setStyleSheet("color: #ff4444;") + self.result_artist.setText("Error:") + self.result_title.setText(error[:60]) + self.result_artist.setStyleSheet("color: #aa4444;") + self.result_title.setStyleSheet("color: #aa4444;") \ No newline at end of file diff --git a/history_widget.py b/history_widget.py new file mode 100644 index 0000000..26744e6 --- /dev/null +++ b/history_widget.py @@ -0,0 +1,238 @@ +# history_widget.py + +import threading +import musicbrainzngs +from mutagen import File as MutagenFile +from PySide6.QtWidgets import ( + QFrame, QVBoxLayout, QLabel, + QScrollArea, QWidget +) +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QFont + +musicbrainzngs.set_useragent("RadioPanel", "1.0", "radio@panel.local") + + +def _read_file_tags(filepath: str) -> dict: + try: + tags = MutagenFile(filepath, easy=True) + if tags is None: + return {} + + def get(key): + val = tags.get(key) + return val[0] if val else None + + return { + 'artist': get('artist'), + 'title': get('title'), + 'album': get('album'), + 'year': get('date'), + } + except Exception: + return {} + + +def _lookup_musicbrainz(artist: str, title: str) -> dict: + try: + result = musicbrainzngs.search_recordings( + recording=title, + artist=artist, + limit=1 + ) + recordings = result.get('recording-list', []) + if not recordings: + return {} + + rec = recordings[0] + releases = rec.get('release-list', []) + if not releases: + return {} + + release = releases[0] + album = release.get('title', None) + year = None + + date = release.get('date', '') + if date: + year = date[:4] + + return {'album': album, 'year': year} + except Exception: + return {} + + +class HistoryEntry(QFrame): + def __init__(self, index: int, filepath: str, metadata: dict, parent=None): + super().__init__(parent) + + self.setFrameStyle(QFrame.Box) + self.setStyleSheet(""" + QFrame { + background-color: #1a1a1a; + border: none; + border-bottom: 1px solid #2a2a2a; + } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(2) + + artist = metadata.get('artist') or 'Unknown Artist' + title = metadata.get('title') or self._filename(filepath) + + top_row = QLabel(f"{index}. {artist} — {title}") + top_row.setFont(QFont("Arial", 11, QFont.Bold)) + top_row.setStyleSheet("color: #00ff41; background: transparent; border: none;") + top_row.setWordWrap(True) + layout.addWidget(top_row) + + album = metadata.get('album') or '' + year = metadata.get('year') or '' + + sub_parts = [] + if album: + sub_parts.append(album) + if year: + sub_parts.append(year[:4]) + + if sub_parts: + sub_row = QLabel(" " + " · ".join(sub_parts)) + sub_row.setFont(QFont("Arial", 9)) + sub_row.setStyleSheet( + "color: #888888; background: transparent; border: none;" + ) + sub_row.setWordWrap(True) + layout.addWidget(sub_row) + + def _filename(self, filepath: str) -> str: + import os + name = os.path.basename(filepath) + return os.path.splitext(name)[0] + + +class HistoryWidget(QFrame): + _metadata_ready = Signal(str, dict) + + def __init__(self, parent=None): + super().__init__(parent) + + self._history = [] + self._pending = {} + + self.setFrameStyle(QFrame.Box | QFrame.Raised) + self.setStyleSheet(""" + QFrame { + background-color: #111111; + border: 2px solid #336633; + border-radius: 6px; + } + """) + + self._build_ui() + self._metadata_ready.connect(self._on_metadata_ready) + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(8, 8, 8, 8) + root.setSpacing(6) + + header = QLabel("PLAY HISTORY") + header.setFont(QFont("Arial", 11, QFont.Bold)) + header.setStyleSheet(""" + color: #336633; + background: transparent; + border: none; + padding: 2px; + """) + header.setAlignment(Qt.AlignCenter) + root.addWidget(header) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.scroll.setStyleSheet(""" + QScrollArea { + border: none; + background: transparent; + } + QScrollBar:vertical { + background: #1a1a1a; + width: 8px; + border-radius: 4px; + } + QScrollBar::handle:vertical { + background: #336633; + border-radius: 4px; + } + """) + + self.list_widget = QWidget() + self.list_widget.setStyleSheet("background: transparent;") + self.list_layout = QVBoxLayout(self.list_widget) + self.list_layout.setContentsMargins(0, 0, 0, 0) + self.list_layout.setSpacing(0) + self.list_layout.addStretch() + + self.scroll.setWidget(self.list_widget) + root.addWidget(self.scroll) + + def add_track(self, filepath: str): + index = len(self._history) + 1 + metadata = _read_file_tags(filepath) + self._history.append((filepath, metadata)) + self._insert_entry(index, filepath, metadata) + + if not metadata.get('artist') or not metadata.get('album'): + self._pending[filepath] = index + threading.Thread( + target=self._enrich_metadata, + args=(filepath, metadata), + daemon=True + ).start() + + def _insert_entry(self, index: int, filepath: str, metadata: dict): + entry = HistoryEntry(index, filepath, metadata) + self.list_layout.insertWidget(0, entry) + self.scroll.verticalScrollBar().setValue(0) + + def _enrich_metadata(self, filepath: str, existing: dict): + artist = existing.get('artist') + title = existing.get('title') + + if not artist or not title: + return + + mb_data = _lookup_musicbrainz(artist, title) + if not mb_data: + return + + merged = dict(existing) + if not merged.get('album') and mb_data.get('album'): + merged['album'] = mb_data['album'] + if not merged.get('year') and mb_data.get('year'): + merged['year'] = mb_data['year'] + + self._metadata_ready.emit(filepath, merged) + + def _on_metadata_ready(self, filepath: str, metadata: dict): + for i, (fp, _) in enumerate(self._history): + if fp == filepath: + self._history[i] = (fp, metadata) + break + self._rebuild_list() + + def _rebuild_list(self): + while self.list_layout.count() > 1: + item = self.list_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + for i, (filepath, metadata) in enumerate(reversed(self._history)): + index = len(self._history) - i + entry = HistoryEntry(index, filepath, metadata) + self.list_layout.insertWidget( + self.list_layout.count() - 1, + entry + ) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..d6f49b3 --- /dev/null +++ b/main.py @@ -0,0 +1,225 @@ +# main.py + +import sys +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, + QVBoxLayout, QHBoxLayout, QSplitter +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont, QColor, QPalette + +from audio_engine import AudioEngine +from deck_widget import DeckWidget +from cart_widget import CartBankWidget +from playlist_widget import PlaylistWidget +from fingerprint_widget import FingerprintWidget +from midi_engine import MidiEngine +from top_bar_widget import TopBarWidget, THEMES +from history_widget import HistoryWidget + +# ───────────────────────────────────────── +# AcoustID API Key +# ───────────────────────────────────────── +ACOUSTID_API_KEY = "Y0RZoLiEmr" + + +class RadioPanelWindow(QMainWindow): + def __init__(self): + super().__init__() + + self.engine = AudioEngine() + + self.setWindowTitle("Radio Panel") + self.setMinimumSize(1280, 720) + self.resize(1920, 1080) + self._apply_global_style(THEMES[0]) + + central = QWidget() + self.setCentralWidget(central) + + outer = QVBoxLayout(central) + outer.setContentsMargins(8, 8, 8, 8) + outer.setSpacing(8) + + # Top bar + self.top_bar = TopBarWidget() + self.top_bar.theme_changed.connect(self._on_theme_changed) + outer.addWidget(self.top_bar) + + # ── Build widgets ───────────────────────────────────────────────── + + self.deck1 = DeckWidget( + deck_key="deck1", + label="DECK 1", + accent_color="#00897b", + engine=self.engine + ) + self.deck2 = DeckWidget( + deck_key="deck2", + label="DECK 2", + accent_color="#f9ca24", + engine=self.engine + ) + + self.cart_bank = CartBankWidget(self.engine) + self.cart_bank.setFixedHeight(280) + + self.playlist_widget = PlaylistWidget( + engine=self.engine, + deck_widgets={ + "deck1": self.deck1, + "deck2": self.deck2, + }, + cart_slots=self.cart_bank.slots + ) + + self.history_widget = HistoryWidget() + + self.fingerprint_panel = FingerprintWidget( + engine=self.engine, + api_key=ACOUSTID_API_KEY + ) + self.fingerprint_panel.setFixedHeight(240) + + # ── Centre column ───────────────────────────────────────────────── + centre_col = QVBoxLayout() + centre_col.setSpacing(8) + + deck_row = QHBoxLayout() + deck_row.setSpacing(8) + deck_row.addWidget(self.deck1) + deck_row.addWidget(self.deck2) + centre_col.addLayout(deck_row) + + centre_col.addWidget(self.cart_bank) + centre_col.addWidget(self.fingerprint_panel) + centre_col.addStretch() + + centre_widget = QWidget() + centre_widget.setStyleSheet("background: transparent; border: none;") + centre_widget.setLayout(centre_col) + + # ── Splitter ────────────────────────────────────────────────────── + self.splitter = QSplitter(Qt.Horizontal) + self.splitter.setHandleWidth(6) + self._style_splitter(THEMES[0]) + + self.splitter.addWidget(self.playlist_widget) + self.splitter.addWidget(centre_widget) + self.splitter.addWidget(self.history_widget) + self.splitter.setSizes([400, 1120, 400]) + + outer.addWidget(self.splitter) + + # ── Wire signals ────────────────────────────────────────────────── + self.deck1.track_started.connect(self.history_widget.add_track) + self.deck2.track_started.connect(self.history_widget.add_track) + + # ── MIDI ────────────────────────────────────────────────────────── + self._setup_midi() + + def _style_splitter(self, theme: dict): + self.splitter.setStyleSheet(f""" + QSplitter::handle {{ + background-color: #333333; + border-radius: 3px; + }} + QSplitter::handle:hover {{ + background-color: {theme['accent']}; + }} + QSplitter::handle:pressed {{ + background-color: {theme['accent_dim']}; + }} + """) + + def _on_theme_changed(self, theme: dict): + self._apply_global_style(theme) + self._style_splitter(theme) + + def _apply_global_style(self, theme: dict): + self.setStyleSheet(f""" + QMainWindow {{ + background-color: {theme['bg']}; + }} + QWidget {{ + background-color: {theme['bg']}; + color: {theme['text']}; + font-family: Arial; + }} + QToolTip {{ + background-color: {theme['bg2']}; + color: {theme['text']}; + border: 1px solid #444; + padding: 4px; + }} + """) + + def _setup_midi(self): + self.midi = MidiEngine() + + self.midi.deck_play_pause.connect(self._on_midi_play_pause) + self.midi.deck_cue.connect(self._on_midi_cue) + self.midi.deck_jog.connect(self._on_midi_jog) + self.midi.cart_trigger.connect(self._on_midi_cart) + self.midi.controller_connected.connect(self._on_midi_status) + + self.midi.start() + + def _on_midi_play_pause(self, deck: str): + if deck == 'deck1': + self.deck1.toggle_play() + elif deck == 'deck2': + self.deck2.toggle_play() + + def _on_midi_cue(self, deck: str): + if deck == 'deck1': + self.deck1.cue() + elif deck == 'deck2': + self.deck2.cue() + + def _on_midi_jog(self, deck: str, delta: float): + if deck == 'deck1': + self.deck1.scrub(delta) + elif deck == 'deck2': + self.deck2.scrub(delta) + + def _on_midi_cart(self, slot: int): + self.cart_bank.slots[slot].toggle() + + def _on_midi_status(self, connected: bool, name: str): + if connected: + print(f"[MIDI] Connected: {name}") + else: + print(f"[MIDI] Not connected: {name}") + + def closeEvent(self, event): + self.midi.stop() + self.engine.shutdown() + event.accept() + + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("Radio Panel") + app.setOrganizationName("GTI") + + palette = QPalette() + palette.setColor(QPalette.Window, QColor("#0d0d0d")) + palette.setColor(QPalette.WindowText, QColor("#cccccc")) + palette.setColor(QPalette.Base, QColor("#111111")) + palette.setColor(QPalette.AlternateBase, QColor("#1a1a1a")) + palette.setColor(QPalette.Text, QColor("#cccccc")) + palette.setColor(QPalette.Button, QColor("#2a2a2a")) + palette.setColor(QPalette.ButtonText, QColor("#cccccc")) + palette.setColor(QPalette.Highlight, QColor("#1a3a5a")) + palette.setColor(QPalette.HighlightedText, QColor("#ffffff")) + app.setPalette(palette) + + window = RadioPanelWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/midi_engine.py b/midi_engine.py new file mode 100644 index 0000000..9d6065b --- /dev/null +++ b/midi_engine.py @@ -0,0 +1,206 @@ +# midi_engine.py +""" +MIDI Engine for Radio Panel +Numark DJ2Go controller support. + +Confirmed mapping from hardware testing: +------------------------------------------ +Play/Pause Deck 1 : Note 0x3B ch=0 +Play/Pause Deck 2 : Note 0x42 ch=0 +Cue Deck 1 : Note 0x33 ch=0 +Cue Deck 2 : Note 0x3C ch=0 +Jog Wheel Deck 1 : CC 0x19 ch=0 +Jog Wheel Deck 2 : CC 0x18 ch=0 +Cart 1 : Note 0x44 ch=0 +Cart 2 : Note 0x43 ch=0 +Cart 3 : Note 0x46 ch=0 +Cart 4 : Note 0x45 ch=0 +""" + +import logging +import threading +from typing import Optional + +import rtmidi +from PySide6.QtCore import QObject, Signal + +logger = logging.getLogger(__name__) + +# ───────────────────────────────────────────── +# MIDI Constants +# ───────────────────────────────────────────── +NOTE_ON = 0x90 +NOTE_OFF = 0x80 +CC = 0xB0 + +# All controls on ch=0, differentiated by note/CC number only +DECK1_PLAY = 0x3B +DECK1_CUE = 0x33 +DECK1_JOG = 0x19 + +DECK2_PLAY = 0x42 +DECK2_CUE = 0x3C +DECK2_JOG = 0x18 + +# Cart buttons - confirmed from hardware test +CART_NOTES = { + 0x44: 0, # Cart slot 1 + 0x43: 1, # Cart slot 2 + 0x46: 2, # Cart slot 3 + 0x45: 3, # Cart slot 4 +} + +JOG_SCRUB_SECONDS = 5.0 + + +def _decode_relative_jog(value: int) -> int: + if 1 <= value <= 63: + return 1 + elif 65 <= value <= 127: + return -1 + return 0 + + +class MidiEngine(QObject): + deck_play_pause = Signal(str) + deck_cue = Signal(str) + deck_jog = Signal(str, float) + cart_trigger = Signal(int) + controller_connected = Signal(bool, str) + + def __init__(self, parent=None): + super().__init__(parent) + self._midi_in: Optional[rtmidi.MidiIn] = None + self._port_name: str = "" + self._running = False + + def start(self, port_index: Optional[int] = None) -> bool: + try: + self._midi_in = rtmidi.MidiIn() + self._midi_in.ignore_types(sysex=True, timing=True, active_sense=True) + + available_ports = self._midi_in.get_ports() + logger.info(f"Available MIDI ports: {available_ports}") + + if not available_ports: + logger.error("No MIDI ports found.") + self.controller_connected.emit(False, "No MIDI ports found") + return False + + if port_index is None: + port_index = self._find_numark_port(available_ports) + + self._port_name = available_ports[port_index] + self._midi_in.open_port(port_index) + self._midi_in.set_callback(self._midi_callback) + + self._running = True + logger.info(f"MIDI connected: {self._port_name}") + self.controller_connected.emit(True, self._port_name) + return True + + except Exception as e: + logger.exception(f"Failed to open MIDI port: {e}") + self.controller_connected.emit(False, str(e)) + return False + + def stop(self): + self._running = False + if self._midi_in: + try: + self._midi_in.cancel_callback() + self._midi_in.close_port() + except Exception as e: + logger.warning(f"Error closing MIDI port: {e}") + finally: + del self._midi_in + self._midi_in = None + logger.info("MIDI engine stopped.") + + def list_ports(self) -> list[str]: + try: + tmp = rtmidi.MidiIn() + ports = tmp.get_ports() + del tmp + return ports + except Exception: + return [] + + def _find_numark_port(self, ports: list[str]) -> int: + for i, name in enumerate(ports): + if "numark" in name.lower(): + logger.info(f"Auto-detected Numark on port {i}: {name}") + return i + logger.warning("No Numark port found, defaulting to port 0.") + return 0 + + def _midi_callback(self, event, data=None): + if not self._running: + return + + message, _ = event + + if len(message) < 2: + return + + status = message[0] + data_byte = message[1] + value = message[2] if len(message) > 2 else 0 + + status_type = status & 0xF0 + channel = status & 0x0F + + logger.debug( + f"MIDI IN status=0x{status:02X} " + f"data=0x{data_byte:02X} " + f"val={value} " + f"type=0x{status_type:02X} ch={channel}" + ) + + if status_type == NOTE_ON and value > 0: + self._handle_note_on(data_byte) + elif status_type == CC: + self._handle_cc(data_byte, value) + + def _handle_note_on(self, note: int): + if note == DECK1_PLAY: + logger.debug("DECK 1 PLAY/PAUSE") + self.deck_play_pause.emit('deck1') + + elif note == DECK1_CUE: + logger.debug("DECK 1 CUE") + self.deck_cue.emit('deck1') + + elif note == DECK2_PLAY: + logger.debug("DECK 2 PLAY/PAUSE") + self.deck_play_pause.emit('deck2') + + elif note == DECK2_CUE: + logger.debug("DECK 2 CUE") + self.deck_cue.emit('deck2') + + elif note in CART_NOTES: + slot = CART_NOTES[note] + logger.debug(f"CART {slot + 1} TRIGGER") + self.cart_trigger.emit(slot) + + else: + logger.debug(f"Unmapped note: 0x{note:02X}") + + def _handle_cc(self, cc_num: int, value: int): + direction = _decode_relative_jog(value) + if direction == 0: + return + + delta = direction * JOG_SCRUB_SECONDS + + if cc_num == DECK1_JOG: + logger.debug(f"DECK 1 JOG delta={delta:+.1f}s") + self.deck_jog.emit('deck1', delta) + + elif cc_num == DECK2_JOG: + logger.debug(f"DECK 2 JOG delta={delta:+.1f}s") + self.deck_jog.emit('deck2', delta) + + else: + logger.debug(f"Unmapped CC: cc=0x{cc_num:02X} val={value}") \ No newline at end of file diff --git a/playlist_widget.py b/playlist_widget.py new file mode 100644 index 0000000..780c586 --- /dev/null +++ b/playlist_widget.py @@ -0,0 +1,605 @@ +# playlist_widget.py + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QFileDialog, QFrame, QSizePolicy, + QListWidget, QListWidgetItem, QAbstractItemView, + QMenu, QSplitter +) +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QFont, QColor, QAction +import os + + +# ───────────────────────────────────────── +# PLAYLIST ITEM +# ───────────────────────────────────────── + +class PlaylistItem(QListWidgetItem): + def __init__(self, filepath: str): + self.filepath = filepath + self.filename = os.path.basename(filepath) + display = os.path.splitext(self.filename)[0] + super().__init__(display) + self.setFont(QFont("Courier New", 10, QFont.Bold)) + self.setForeground(QColor("#cccccc")) + self.setToolTip(filepath) + + +# ───────────────────────────────────────── +# HOUR DISPLAY + CLOCK +# ───────────────────────────────────────── + +class HourDisplay(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setFrameStyle(QFrame.Box | QFrame.Sunken) + self.setFixedHeight(44) + self.setStyleSheet(""" + QFrame { + background-color: #0a0a1a; + border: 1px solid #333; + border-radius: 3px; + } + """) + + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 2, 8, 2) + + self.hour_label = QLabel("HOUR --:--") + self.hour_label.setFont(QFont("Courier New", 12, QFont.Bold)) + self.hour_label.setStyleSheet( + "color: #4488ff; background: transparent; border: none;" + ) + self.hour_label.setAlignment(Qt.AlignCenter) + layout.addWidget(self.hour_label) + + self.clock_label = QLabel("00:00:00") + self.clock_label.setFont(QFont("Courier New", 12, QFont.Bold)) + self.clock_label.setStyleSheet( + "color: #aaaaaa; background: transparent; border: none;" + ) + self.clock_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + layout.addWidget(self.clock_label) + + self.clock_timer = QTimer(self) + self.clock_timer.setInterval(1000) + self.clock_timer.timeout.connect(self._update_clock) + self.clock_timer.start() + self._update_clock() + + def _update_clock(self): + from datetime import datetime + now = datetime.now() + self.clock_label.setText(now.strftime("%H:%M:%S")) + self.hour_label.setText(f"HOUR {now.strftime('%H:00')}") + + +# ───────────────────────────────────────── +# DECK SELECTOR +# ───────────────────────────────────────── + +class DeckSelector(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedHeight(36) + self.setStyleSheet(""" + QFrame { + background-color: #1a1a1a; + border: 1px solid #333; + border-radius: 3px; + } + """) + + layout = QHBoxLayout(self) + layout.setContentsMargins(6, 2, 6, 2) + layout.setSpacing(6) + + label = QLabel("SEND TO:") + label.setFont(QFont("Arial", 9, QFont.Bold)) + label.setStyleSheet("color: #888; background: transparent; border: none;") + layout.addWidget(label) + + self.deck1_btn = QPushButton("DECK 1") + self.deck1_btn.setCheckable(True) + self.deck1_btn.setChecked(True) + self.deck1_btn.setFixedHeight(26) + self.deck1_btn.setFont(QFont("Arial", 9, QFont.Bold)) + self._style_btn(self.deck1_btn, "#00897b") + + self.deck2_btn = QPushButton("DECK 2") + self.deck2_btn.setCheckable(True) + self.deck2_btn.setFixedHeight(26) + self.deck2_btn.setFont(QFont("Arial", 9, QFont.Bold)) + self._style_btn(self.deck2_btn, "#f9ca24") + + self.deck1_btn.clicked.connect(lambda: self._select("deck1")) + self.deck2_btn.clicked.connect(lambda: self._select("deck2")) + + layout.addWidget(self.deck1_btn) + layout.addWidget(self.deck2_btn) + layout.addStretch() + + self._selected = "deck1" + + def _style_btn(self, btn: QPushButton, accent: str): + btn.setStyleSheet(f""" + QPushButton {{ + background-color: #2a2a2a; + color: #888; + border: 1px solid #444; + border-radius: 3px; + padding: 0 8px; + }} + QPushButton:checked {{ + background-color: #1a1a1a; + color: {accent}; + border: 1px solid {accent}; + }} + QPushButton:hover {{ background-color: #333; }} + """) + + def _select(self, deck: str): + self._selected = deck + self.deck1_btn.setChecked(deck == "deck1") + self.deck2_btn.setChecked(deck == "deck2") + + @property + def selected_deck(self) -> str: + return self._selected + + +# ───────────────────────────────────────── +# BASE PLAYLIST PANEL +# ───────────────────────────────────────── + +class BasePlaylistPanel(QWidget): + AUDIO_FILTER = ( + "Audio Files " + "(*.wav *.flac *.mp3 *.m4a *.mp4 *.ogg *.opus *.aac *.aiff)" + ) + AUDIO_EXTENSIONS = { + '.wav', '.flac', '.mp3', '.m4a', '.mp4', + '.ogg', '.opus', '.aac', '.aiff' + } + + def __init__(self, parent=None): + super().__init__(parent) + + def _make_list(self) -> QListWidget: + lst = QListWidget() + lst.setStyleSheet(""" + QListWidget { + background-color: #0d0d0d; + color: #cccccc; + border: 1px solid #333; + border-radius: 3px; + outline: none; + } + QListWidget::item { + padding: 5px 6px; + border-bottom: 1px solid #1a1a1a; + } + QListWidget::item:selected { + background-color: #1a3a5a; + color: #ffffff; + } + QListWidget::item:hover { + background-color: #1e1e1e; + } + QScrollBar:vertical { + background: #1a1a1a; + width: 8px; + border-radius: 4px; + } + QScrollBar::handle:vertical { + background: #444; + border-radius: 4px; + } + """) + lst.setSelectionMode(QAbstractItemView.SingleSelection) + lst.setDragDropMode(QAbstractItemView.InternalMove) + lst.setContextMenuPolicy(Qt.CustomContextMenu) + return lst + + def _make_btn(self, text: str, color: str, height: int = 28) -> QPushButton: + btn = QPushButton(text) + btn.setFixedHeight(height) + btn.setFont(QFont("Arial", 9, QFont.Bold)) + btn.setStyleSheet(f""" + QPushButton {{ + background-color: {color}; + color: #cccccc; + border: 1px solid #444; + border-radius: 3px; + }} + QPushButton:hover {{ background-color: #555; }} + QPushButton:pressed {{ background-color: #222; }} + """) + return btn + + def _add_files_to(self, lst: QListWidget): + paths, _ = QFileDialog.getOpenFileNames( + self, "Add Files", + os.path.expanduser("~"), + self.AUDIO_FILTER + ) + for path in paths: + self._add_item_to(lst, path) + self._update_count(lst) + + def _add_folder_to(self, lst: QListWidget): + folder = QFileDialog.getExistingDirectory( + self, "Add Folder", + os.path.expanduser("~") + ) + if not folder: + return + for root_dir, dirs, files in os.walk(folder): + dirs.sort() + for filename in sorted(files): + ext = os.path.splitext(filename)[1].lower() + if ext in self.AUDIO_EXTENSIONS: + self._add_item_to(lst, os.path.join(root_dir, filename)) + self._update_count(lst) + + def _add_item_to(self, lst: QListWidget, filepath: str): + for i in range(lst.count()): + item = lst.item(i) + if isinstance(item, PlaylistItem) and item.filepath == filepath: + return + lst.addItem(PlaylistItem(filepath)) + + def _update_count(self, lst: QListWidget, label: QLabel = None): + n = lst.count() + if label: + label.setText(f"{n} track{'s' if n != 1 else ''}") + + def _highlight(self, item: PlaylistItem, + active: bool = False, queued: bool = False): + if active: + item.setForeground(QColor("#00ff41")) + elif queued: + item.setForeground(QColor("#f9ca24")) + else: + item.setForeground(QColor("#cccccc")) + + +# ───────────────────────────────────────── +# TRACK PLAYLIST PANEL +# ───────────────────────────────────────── + +class TrackPlaylistPanel(BasePlaylistPanel): + def __init__(self, engine, deck_widgets: dict, parent=None): + super().__init__(parent) + self.engine = engine + self.deck_widgets = deck_widgets + self._build_ui() + + def _build_ui(self): + self.setStyleSheet(""" + QWidget { + background-color: #111111; + border: 2px solid #333333; + border-radius: 6px; + } + """) + + root = QVBoxLayout(self) + root.setContentsMargins(8, 8, 8, 8) + root.setSpacing(6) + + header = QLabel("PLAYLIST") + header.setFont(QFont("Arial", 12, QFont.Bold)) + header.setStyleSheet( + "color: #aaaaaa; background: transparent; border: none; padding: 2px;" + ) + header.setAlignment(Qt.AlignCenter) + root.addWidget(header) + + self.hour_display = HourDisplay() + root.addWidget(self.hour_display) + + self.deck_selector = DeckSelector() + root.addWidget(self.deck_selector) + + self.playlist = self._make_list() + self.playlist.itemDoubleClicked.connect(self._send_to_deck) + self.playlist.customContextMenuRequested.connect(self._context_menu) + root.addWidget(self.playlist) + + row1 = QHBoxLayout() + row1.setSpacing(4) + self.btn_add_files = self._make_btn("ADD FILES", "#2a5a8a") + self.btn_add_folder = self._make_btn("ADD FOLDER", "#2a4a6a") + row1.addWidget(self.btn_add_files) + row1.addWidget(self.btn_add_folder) + root.addLayout(row1) + + row2 = QHBoxLayout() + row2.setSpacing(4) + self.btn_send_deck = self._make_btn("→ LOAD DECK", "#1a5a3a") + self.btn_send_queue = self._make_btn("→ QUEUE", "#1a3a2a") + row2.addWidget(self.btn_send_deck) + row2.addWidget(self.btn_send_queue) + root.addLayout(row2) + + row3 = QHBoxLayout() + row3.setSpacing(4) + self.btn_remove = self._make_btn("REMOVE", "#5a1a1a") + self.btn_clear = self._make_btn("CLEAR ALL", "#3a1a1a") + row3.addWidget(self.btn_remove) + row3.addWidget(self.btn_clear) + root.addLayout(row3) + + self.count_label = QLabel("0 tracks") + self.count_label.setFont(QFont("Courier New", 9)) + self.count_label.setStyleSheet( + "color: #555; background: transparent; border: none;" + ) + self.count_label.setAlignment(Qt.AlignCenter) + root.addWidget(self.count_label) + + self.btn_add_files.clicked.connect( + lambda: self._add_files_to(self.playlist) + ) + self.btn_add_folder.clicked.connect( + lambda: self._add_folder_to(self.playlist) + ) + self.btn_send_deck.clicked.connect(self._send_to_deck) + self.btn_send_queue.clicked.connect(self._send_to_queue) + self.btn_remove.clicked.connect(self._remove_selected) + self.btn_clear.clicked.connect(self._clear_all) + + def _send_to_deck(self, item=None): + if item is None: + item = self.playlist.currentItem() + if not isinstance(item, PlaylistItem): + return + deck_key = self.deck_selector.selected_deck + self.engine.load_track(deck_key, item.filepath) + name = os.path.splitext(item.filename)[0] + if deck_key in self.deck_widgets: + self.deck_widgets[deck_key].set_track_name(name) + self._highlight(item, active=True) + + def _send_to_queue(self): + item = self.playlist.currentItem() + if not isinstance(item, PlaylistItem): + return + deck_key = self.deck_selector.selected_deck + self.engine.load_queue(deck_key, item.filepath) + name = os.path.splitext(item.filename)[0] + if deck_key in self.deck_widgets: + self.deck_widgets[deck_key].set_queue_name(name) + self._highlight(item, queued=True) + + def _remove_selected(self): + row = self.playlist.currentRow() + if row >= 0: + self.playlist.takeItem(row) + self._update_count(self.playlist, self.count_label) + + def _clear_all(self): + self.playlist.clear() + self._update_count(self.playlist, self.count_label) + + def _context_menu(self, position): + item = self.playlist.itemAt(position) + if not isinstance(item, PlaylistItem): + return + + menu = QMenu(self) + menu.setStyleSheet(""" + QMenu { + background-color: #1e1e1e; + color: #cccccc; + border: 1px solid #444; + } + QMenu::item:selected { background-color: #1a3a5a; } + """) + + def load(deck_key): + self.engine.load_track(deck_key, item.filepath) + name = os.path.splitext(item.filename)[0] + if deck_key in self.deck_widgets: + self.deck_widgets[deck_key].set_track_name(name) + self._highlight(item, active=True) + + def queue(deck_key): + self.engine.load_queue(deck_key, item.filepath) + name = os.path.splitext(item.filename)[0] + if deck_key in self.deck_widgets: + self.deck_widgets[deck_key].set_queue_name(name) + self._highlight(item, queued=True) + + a1 = QAction("Load → Deck 1", self) + a2 = QAction("Load → Deck 2", self) + a3 = QAction("Queue → Deck 1", self) + a4 = QAction("Queue → Deck 2", self) + ar = QAction("Remove", self) + + a1.triggered.connect(lambda: load("deck1")) + a2.triggered.connect(lambda: load("deck2")) + a3.triggered.connect(lambda: queue("deck1")) + a4.triggered.connect(lambda: queue("deck2")) + ar.triggered.connect( + lambda: self.playlist.takeItem(self.playlist.row(item)) + ) + + menu.addAction(a1) + menu.addAction(a2) + menu.addSeparator() + menu.addAction(a3) + menu.addAction(a4) + menu.addSeparator() + menu.addAction(ar) + menu.exec(self.playlist.viewport().mapToGlobal(position)) + self._update_count(self.playlist, self.count_label) + + +# ───────────────────────────────────────── +# CART PLAYLIST PANEL +# ───────────────────────────────────────── + +class CartPlaylistPanel(BasePlaylistPanel): + def __init__(self, engine, cart_slots: list, parent=None): + super().__init__(parent) + self.engine = engine + self.cart_slots = cart_slots + self._build_ui() + + def _build_ui(self): + self.setStyleSheet(""" + QWidget { + background-color: #111111; + border: 2px solid #cc3300; + border-radius: 6px; + } + """) + + root = QVBoxLayout(self) + root.setContentsMargins(8, 8, 8, 8) + root.setSpacing(6) + + header = QLabel("CART PLAYLIST") + header.setFont(QFont("Arial", 12, QFont.Bold)) + header.setStyleSheet( + "color: #cc3300; background: transparent; border: none; padding: 2px;" + ) + header.setAlignment(Qt.AlignCenter) + root.addWidget(header) + + self.playlist = self._make_list() + self.playlist.itemDoubleClicked.connect(self._send_to_next_free_cart) + self.playlist.customContextMenuRequested.connect(self._context_menu) + root.addWidget(self.playlist) + + row1 = QHBoxLayout() + row1.setSpacing(4) + self.btn_add_files = self._make_btn("ADD FILES", "#5a2a00") + self.btn_add_folder = self._make_btn("ADD FOLDER", "#4a2000") + row1.addWidget(self.btn_add_files) + row1.addWidget(self.btn_add_folder) + root.addLayout(row1) + + row2 = QHBoxLayout() + row2.setSpacing(4) + self.btn_remove = self._make_btn("REMOVE", "#5a1a1a") + self.btn_clear = self._make_btn("CLEAR ALL", "#3a1a1a") + row2.addWidget(self.btn_remove) + row2.addWidget(self.btn_clear) + root.addLayout(row2) + + self.count_label = QLabel("0 tracks") + self.count_label.setFont(QFont("Courier New", 9)) + self.count_label.setStyleSheet( + "color: #555; background: transparent; border: none;" + ) + self.count_label.setAlignment(Qt.AlignCenter) + root.addWidget(self.count_label) + + self.btn_add_files.clicked.connect( + lambda: self._add_files_to(self.playlist) + ) + self.btn_add_folder.clicked.connect( + lambda: self._add_folder_to(self.playlist) + ) + self.btn_remove.clicked.connect(self._remove_selected) + self.btn_clear.clicked.connect(self._clear_all) + + def _send_to_cart(self, item: PlaylistItem, cart_index: int): + if not isinstance(item, PlaylistItem): + return + slot = self.cart_slots[cart_index] + slot.load_track(item.filepath) + self._highlight(item, active=True) + + def _send_to_next_free_cart(self, item=None): + if item is None: + item = self.playlist.currentItem() + if not isinstance(item, PlaylistItem): + return + for i in range(4): + cart_key = f"cart{i + 1}" + if self.engine.decks[cart_key].track is None: + self._send_to_cart(item, i) + return + self._send_to_cart(item, 0) + + def _remove_selected(self): + row = self.playlist.currentRow() + if row >= 0: + self.playlist.takeItem(row) + self._update_count(self.playlist, self.count_label) + + def _clear_all(self): + self.playlist.clear() + self._update_count(self.playlist, self.count_label) + + def _context_menu(self, position): + item = self.playlist.itemAt(position) + if not isinstance(item, PlaylistItem): + return + + menu = QMenu(self) + menu.setStyleSheet(""" + QMenu { + background-color: #1e1e1e; + color: #cccccc; + border: 1px solid #444; + } + QMenu::item:selected { background-color: #1a3a5a; } + """) + + for i in range(4): + action = QAction(f"Load → Cart {i + 1}", self) + action.triggered.connect( + lambda checked, idx=i: self._send_to_cart(item, idx) + ) + menu.addAction(action) + + menu.addSeparator() + ar = QAction("Remove", self) + ar.triggered.connect( + lambda: self.playlist.takeItem(self.playlist.row(item)) + ) + menu.addAction(ar) + menu.exec(self.playlist.viewport().mapToGlobal(position)) + self._update_count(self.playlist, self.count_label) + + +# ───────────────────────────────────────── +# COMBINED PLAYLIST WIDGET +# ───────────────────────────────────────── + +class PlaylistWidget(QWidget): + def __init__(self, engine, deck_widgets: dict, + cart_slots: list, parent=None): + super().__init__(parent) + self.engine = engine + + # Expandable - no fixed width + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setMinimumWidth(200) + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + splitter = QSplitter(Qt.Vertical) + splitter.setStyleSheet(""" + QSplitter::handle { + background-color: #333; + height: 4px; + } + """) + + self.track_panel = TrackPlaylistPanel(engine, deck_widgets) + self.cart_panel = CartPlaylistPanel(engine, cart_slots) + + splitter.addWidget(self.track_panel) + splitter.addWidget(self.cart_panel) + splitter.setSizes([600, 400]) + + root.addWidget(splitter) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cd3368f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +PySide6 +numpy +soundfile +requests +mutagen +musicbrainzngs +python-rtmidi +PyJACK +librosa \ No newline at end of file diff --git a/top_bar_widget.py b/top_bar_widget.py new file mode 100644 index 0000000..088639d --- /dev/null +++ b/top_bar_widget.py @@ -0,0 +1,166 @@ +# top_bar_widget.py + +from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtGui import QFont +from datetime import datetime + + +# ───────────────────────────────────────── +# THEMES +# ───────────────────────────────────────── + +THEMES = [ + { + 'name': 'GREEN', + 'accent': '#00ff41', + 'accent_dim': '#00aa2a', + 'bg': '#0d0d0d', + 'bg2': '#111111', + 'border': '#1a3a1a', + 'text': '#cccccc', + }, + { + 'name': 'AMBER', + 'accent': '#ffaa00', + 'accent_dim': '#cc8800', + 'bg': '#0d0d00', + 'bg2': '#111100', + 'border': '#3a3000', + 'text': '#ddddcc', + }, + { + 'name': 'BLUE', + 'accent': '#00aaff', + 'accent_dim': '#0077cc', + 'bg': '#00080d', + 'bg2': '#001122', + 'border': '#001a3a', + 'text': '#ccd8dd', + }, + { + 'name': 'RED', + 'accent': '#ff4444', + 'accent_dim': '#cc2222', + 'bg': '#0d0000', + 'bg2': '#110000', + 'border': '#3a0000', + 'text': '#ddcccc', + }, +] + + +class TopBarWidget(QFrame): + theme_changed = Signal(dict) + + def __init__(self, parent=None): + super().__init__(parent) + + self._theme_index = 0 + + self.setFrameStyle(QFrame.Box | QFrame.Raised) + self.setFixedHeight(80) + self._apply_bar_style() + + self._build_ui() + + self.timer = QTimer(self) + self.timer.setInterval(1000) + self.timer.timeout.connect(self._update) + self.timer.start() + + self._update() + + def _apply_bar_style(self): + theme = THEMES[self._theme_index] + self.setStyleSheet(f""" + QFrame {{ + background-color: {theme['bg2']}; + border: 2px solid {theme['border']}; + border-radius: 6px; + }} + """) + + def _build_ui(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(20, 8, 20, 8) + layout.setSpacing(12) + + # Theme cycle button + self.theme_btn = QPushButton("◈") + self.theme_btn.setFixedSize(40, 40) + self.theme_btn.setFont(QFont("Arial", 16, QFont.Bold)) + self.theme_btn.setToolTip("Cycle colour theme") + self.theme_btn.clicked.connect(self._cycle_theme) + self._style_theme_btn() + layout.addWidget(self.theme_btn) + + # Station name + self.station_label = QLabel("RADIO PANEL") + self.station_label.setFont(QFont("Arial", 16, QFont.Bold)) + self.station_label.setStyleSheet( + "color: #444444; background: transparent; border: none;" + ) + self.station_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + layout.addWidget(self.station_label) + + layout.addStretch() + + # Date + self.date_label = QLabel("") + self.date_label.setFont(QFont("Arial", 20, QFont.Bold)) + self.date_label.setStyleSheet( + "color: #aaaaaa; background: transparent; border: none;" + ) + self.date_label.setAlignment(Qt.AlignCenter | Qt.AlignVCenter) + layout.addWidget(self.date_label) + + layout.addStretch() + + # Clock + self.clock_label = QLabel("") + self.clock_label.setFont(QFont("Courier New", 42, QFont.Bold)) + self.clock_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.clock_label.setMinimumWidth(220) + self._style_clock() + layout.addWidget(self.clock_label) + + def _style_theme_btn(self): + theme = THEMES[self._theme_index] + self.theme_btn.setStyleSheet(f""" + QPushButton {{ + background-color: transparent; + color: {theme['accent']}; + border: 2px solid {theme['accent']}; + border-radius: 20px; + }} + QPushButton:hover {{ + background-color: {theme['accent']}; + color: #000000; + }} + QPushButton:pressed {{ + background-color: {theme['accent_dim']}; + }} + """) + + def _style_clock(self): + theme = THEMES[self._theme_index] + self.clock_label.setStyleSheet( + f"color: {theme['accent']}; background: transparent; border: none;" + ) + + def _cycle_theme(self): + self._theme_index = (self._theme_index + 1) % len(THEMES) + self._apply_bar_style() + self._style_theme_btn() + self._style_clock() + self.theme_changed.emit(THEMES[self._theme_index]) + + def _update(self): + now = datetime.now() + self.clock_label.setText(now.strftime("%H:%M:%S")) + self.date_label.setText(now.strftime("%A %d %B %Y")) + + @property + def current_theme(self) -> dict: + return THEMES[self._theme_index] \ No newline at end of file