From a9249d395f02236cd720f9851a791d41815db1fa Mon Sep 17 00:00:00 2001 From: njmb Date: Wed, 29 Apr 2026 16:21:44 +1000 Subject: [PATCH] Initial commit - Radio Panel v0.1 --- __pycache__/audio_engine.cpython-314.pyc | Bin 0 -> 19598 bytes __pycache__/cart_widget.cpython-314.pyc | Bin 0 -> 15661 bytes __pycache__/deck_widget.cpython-314.pyc | Bin 0 -> 20944 bytes .../fingerprint_widget.cpython-314.pyc | Bin 0 -> 18769 bytes __pycache__/history_widget.cpython-314.pyc | Bin 0 -> 13413 bytes __pycache__/midi_engine.cpython-314.pyc | Bin 0 -> 9847 bytes __pycache__/playlist_widget.cpython-314.pyc | Bin 0 -> 38221 bytes __pycache__/top_bar_widget.cpython-314.pyc | Bin 0 -> 8471 bytes audio_engine.py | 306 +++++++++ audio_test.py | 68 ++ cart_widget.py | 295 +++++++++ debug_audio.py | 26 + deck_widget.py | 347 ++++++++++ fingerprint_widget.py | 296 +++++++++ history_widget.py | 238 +++++++ main.py | 225 +++++++ midi_engine.py | 206 ++++++ playlist_widget.py | 605 ++++++++++++++++++ requirements.txt | 9 + top_bar_widget.py | 166 +++++ 20 files changed, 2787 insertions(+) create mode 100644 __pycache__/audio_engine.cpython-314.pyc create mode 100644 __pycache__/cart_widget.cpython-314.pyc create mode 100644 __pycache__/deck_widget.cpython-314.pyc create mode 100644 __pycache__/fingerprint_widget.cpython-314.pyc create mode 100644 __pycache__/history_widget.cpython-314.pyc create mode 100644 __pycache__/midi_engine.cpython-314.pyc create mode 100644 __pycache__/playlist_widget.cpython-314.pyc create mode 100644 __pycache__/top_bar_widget.cpython-314.pyc create mode 100644 audio_engine.py create mode 100644 audio_test.py create mode 100644 cart_widget.py create mode 100644 debug_audio.py create mode 100644 deck_widget.py create mode 100644 fingerprint_widget.py create mode 100644 history_widget.py create mode 100644 main.py create mode 100644 midi_engine.py create mode 100644 playlist_widget.py create mode 100644 requirements.txt create mode 100644 top_bar_widget.py diff --git a/__pycache__/audio_engine.cpython-314.pyc b/__pycache__/audio_engine.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..91cd2a4680dda9c714258b1caa7c7fcf095e3d80 GIT binary patch literal 19598 zcmeHvd2~}(n&*3xp7gZXl5JtU;oX1@#s-@u#vx&`!x9_kL75^rsDvehgDfjgGFycy z_jLD^q3B>zVK8;78mGDz`*eCBbE+rQOwUZxr@AUVXBI2ABiD=P9A>7@>8W$(4};T9 zy1HliefR0SOCrh&;&6Y}Yu{05B_EcJy#8+{(u3uBN&R!lq{m5DIGvl||1#g(^=8l`T z{Q<`XkXFLI!k^+Lz6Uv~lT1Cyo;i%FN1H3xMXfAx4QBLc`8AbS#J>JeFyfD$QtUCm zd@>mGMPLf-*9Sh zAlPuyFAs(S4Fl&-^oJW{f1rOb;tvNy4gMiSeZla_{%~+Zm)QA>Rekw6{oO%H=XO7C)Mxq4WE zos@XVB$>C;h~ds+jB<0gxr=78#6`?)e1{GFc(e@G&n)#LStJ2%StXk!N_NT7L#xPK zH?Pe`a!M{KOUjnq8?qxpk5?nEmg`v=~C@W54$S#xE9ma-o&Zq_f%^6UDaAl4>2!m*J}M+iy3OI z)zb7(%H5iTorta5*0oO0(PC1;ag%nAGwoiggySNPZhO~OL#1T&NQ<%V zk)2Y$1_zy{1K3wPSBe%Q4#OB9zuxAj8RKz&+gAZgfJ2-(egC&TI=%|-NYjQCv_#B? z(k+=vcbla`X=QWrbVe|mDz2RCS+A9~a)Fi9YeekPVL0!w$;CKgf9S0LeAKropa_B3`A86W zwr?O3JbA%X-;ZCPBJ?$F3I#9N>ixZgfncv)+RwDyG4xVqpYE zj7CEJvEaFwB7_Fd24yAZ(7~^C?rGoma+|OHpwzMR@FB$%?Ncmr&>v9v!%B90dq-E> zOIoH(s8(@AhfYM~!QNmrs+i@Wuwv=j*VU#tRFXxDqBy$!p`l=#EDy>uz?M_2SSe)4 zgqIaZpFB8lVyLeV^Djn%oJ*-TIT$_V$Aa72&h-W(vHroZVhi=3kO!lFMPO7<5oCY( zWKa>*f+A}82O=Tt(V1w>-+NlI^oIH)wN9CqiLHuarrBBo{+J n4l2oQ0N4uxVE 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 F{67SbVSfMs literal 0 HcmV?d00001 diff --git a/__pycache__/cart_widget.cpython-314.pyc b/__pycache__/cart_widget.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..05f67ed4e2af7b4ebf3a45f2a0259c84dc0f6fd2 GIT binary patch literal 15661 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 literal 0 HcmV?d00001 diff --git a/__pycache__/fingerprint_widget.cpython-314.pyc b/__pycache__/fingerprint_widget.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc55621a48cb7eb6b0b130417aa04ad9e7811f38 GIT binary patch literal 18769 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 literal 0 HcmV?d00001 diff --git a/__pycache__/top_bar_widget.cpython-314.pyc b/__pycache__/top_bar_widget.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f2c6240facf9a021571e231ed93569ac508771e GIT binary patch literal 8471 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 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