Tired and Poor and Old - Radio Panel v0.1.1

This commit is contained in:
2026-04-30 00:30:23 +10:00
parent a9249d395f
commit 0c8900ac36
18 changed files with 491 additions and 189 deletions
+138 -22
View File
@@ -4,14 +4,96 @@ from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFrame, QSizePolicy
)
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QFont, QColor
from PySide6.QtCore import Qt, QTimer, Signal, QRect
from PySide6.QtGui import QFont, QColor, QPainter, QPen
import numpy as np
import os
# ─────────────────────────────────────────
# LCD DISPLAY WIDGET
# ─────────────────────────────────────────
class WaveformWidget(QFrame):
"""Displays downsampled waveform with playhead position."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(60)
self.setStyleSheet("""
QFrame {
background-color: #050505;
border: 1px solid #1a1a1a;
border-radius: 3px;
}
""")
self.waveform_data = None # Shape: (width,) - RMS per pixel
self.position_pct = 0.0 # 0.0 to 1.0
def set_waveform(self, audio: np.ndarray):
"""
Downsample audio to fit widget width.
audio shape: (2, samples) stereo
"""
if audio is None or audio.shape[1] == 0:
self.waveform_data = None
self.update()
return
# Mix to mono
mono = np.mean(audio, axis=0)
# Downsample to widget width
width = self.width()
if width < 10:
width = 500 # default before widget is shown
samples_per_pixel = max(1, len(mono) // width)
num_pixels = len(mono) // samples_per_pixel
self.waveform_data = np.array([
np.sqrt(np.mean(mono[i*samples_per_pixel:(i+1)*samples_per_pixel]**2))
for i in range(num_pixels)
], dtype=np.float32)
self.update()
def set_position(self, position_pct: float):
"""Update playhead position (0.0 to 1.0)."""
self.position_pct = max(0.0, min(1.0, position_pct))
self.update()
def paintEvent(self, event):
super().paintEvent(event)
if self.waveform_data is None or len(self.waveform_data) == 0:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect()
width = rect.width()
height = rect.height()
# Scale waveform to fit height
max_val = np.max(self.waveform_data) if np.max(self.waveform_data) > 0 else 1.0
scaled = (self.waveform_data / max_val) * (height * 0.8)
# Draw waveform bars
pen = QPen(QColor("#00ff41"), 1)
painter.setPen(pen)
x_scale = width / len(self.waveform_data)
for i, rms in enumerate(self.waveform_data):
x = int(i * x_scale)
bar_h = int(scaled[i])
y_top = (height - bar_h) // 2
painter.drawLine(x, y_top, x, y_top + bar_h)
# Draw playhead
playhead_x = int(self.position_pct * width)
pen = QPen(QColor("#ffffff"), 2)
painter.setPen(pen)
painter.drawLine(playhead_x, 0, playhead_x, height)
class LCDDisplay(QFrame):
def __init__(self, parent=None):
@@ -107,10 +189,6 @@ class LCDDisplay(QFrame):
)
# ─────────────────────────────────────────
# TRANSPORT BUTTON HELPER
# ─────────────────────────────────────────
def make_transport_button(text: str,
color: str = "#3a3a3a",
text_color: str = "white",
@@ -137,12 +215,8 @@ def make_transport_button(text: str,
return btn
# ─────────────────────────────────────────
# DECK WIDGET
# ─────────────────────────────────────────
class DeckWidget(QWidget):
track_started = Signal(str) # emits filepath when playback begins
track_started = Signal(str)
def __init__(self, deck_key: str, label: str, accent_color: str,
engine, parent=None):
@@ -150,6 +224,7 @@ class DeckWidget(QWidget):
self.deck_key = deck_key
self.engine = engine
self.accent_color = accent_color
self.current_theme = None
self._build_ui(label)
self._connect_signals()
@@ -186,6 +261,10 @@ class DeckWidget(QWidget):
self.lcd = LCDDisplay()
root.addWidget(self.lcd)
# Waveform display
self.waveform = WaveformWidget()
root.addWidget(self.waveform)
transport = QHBoxLayout()
transport.setSpacing(6)
@@ -206,6 +285,31 @@ class DeckWidget(QWidget):
root.addLayout(transport)
def apply_theme(self, theme: dict):
"""Apply theme colours to deck widget borders and LCD."""
self.current_theme = theme
self.setStyleSheet(f"""
QWidget {{
background-color: {theme['bg2']};
border: 2px solid {theme['border']};
border-radius: 6px;
}}
""")
self.lcd.setStyleSheet(f"""
QFrame {{
background-color: {theme['bg']};
border: 2px solid {theme['border']};
border-radius: 4px;
}}
""")
self.waveform.setStyleSheet(f"""
QFrame {{
background-color: {theme['bg']};
border: 1px solid {theme['border']};
border-radius: 3px;
}}
""")
def _connect_signals(self):
self.btn_play.clicked.connect(self._toggle_play)
self.btn_stop.clicked.connect(self._stop_eject)
@@ -214,18 +318,18 @@ class DeckWidget(QWidget):
self.btn_back_15.clicked.connect(lambda: self._scrub(-15))
self.btn_fwd_15.clicked.connect(lambda: self._scrub(15))
# ── PUBLIC — called by playlist ───────────────────────────────────────
def set_track_name(self, name: str):
self.lcd.update_track(name)
self.lcd.update_status("LOADED")
self.lcd.update_time("00:00", "-00:00")
# Build waveform when track loads
deck = self.engine.decks[self.deck_key]
if deck.track:
self.waveform.set_waveform(deck.track.audio)
def set_queue_name(self, name: str):
self.lcd.update_queue(name)
# ── PUBLIC MIDI METHODS ───────────────────────────────────────────────
def toggle_play(self):
self._toggle_play()
@@ -252,8 +356,6 @@ class DeckWidget(QWidget):
def scrub(self, delta: float):
self._scrub(delta)
# ── ACTIONS ───────────────────────────────────────────────────────────
def _toggle_play(self):
deck = self.engine.decks[self.deck_key]
if deck.track is None:
@@ -306,6 +408,7 @@ class DeckWidget(QWidget):
self.lcd.update_time("00:00", "-00:00")
self.lcd.update_status("STOPPED")
self.lcd.set_playing(False)
self.waveform.set_waveform(None)
def _go_to_start(self):
self.engine.decks[self.deck_key].go_to_start()
@@ -316,11 +419,21 @@ class DeckWidget(QWidget):
def _scrub(self, seconds: float):
self.engine.decks[self.deck_key].skip_seconds(seconds)
# ── DISPLAY REFRESH ───────────────────────────────────────────────────
def _refresh_display(self):
deck = self.engine.decks[self.deck_key]
# Auto-load queued track when current finishes
if not deck.playing and deck.track is not None:
if deck.position >= deck.track.num_samples - 1:
if deck.queued:
deck.load(deck.queued)
self.lcd.update_track(os.path.splitext(os.path.basename(deck.queued.filepath))[0])
self.waveform.set_waveform(deck.track.audio)
deck.queued = None
self.lcd.update_queue("")
self.lcd.update_status("LOADED")
# Sync button if stopped manually
if not deck.playing and self.btn_play.text() == "":
self.btn_play.setText("")
self.btn_play.setStyleSheet("""
@@ -340,6 +453,9 @@ class DeckWidget(QWidget):
deck.get_elapsed(),
deck.get_remaining()
)
# Update waveform position
position_pct = deck.position / deck.track.num_samples
self.waveform.set_position(position_pct)
if deck.queued:
self.lcd.update_queue(