Initial commit - Radio Panel v0.1
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+306
@@ -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()
|
||||
@@ -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])
|
||||
+295
@@ -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)
|
||||
@@ -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()
|
||||
+347
@@ -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)
|
||||
)
|
||||
@@ -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;")
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
+206
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,9 @@
|
||||
PySide6
|
||||
numpy
|
||||
soundfile
|
||||
requests
|
||||
mutagen
|
||||
musicbrainzngs
|
||||
python-rtmidi
|
||||
PyJACK
|
||||
librosa
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user