Files
gti_radiostudio/deck_widget.py
T

463 lines
15 KiB
Python

# deck_widget.py
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFrame, QSizePolicy
)
from PySide6.QtCore import Qt, QTimer, Signal, QRect
from PySide6.QtGui import QFont, QColor, QPainter, QPen
import numpy as np
import os
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):
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;"
)
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
class DeckWidget(QWidget):
track_started = Signal(str)
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.current_theme = None
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)
# Waveform display
self.waveform = WaveformWidget()
root.addWidget(self.waveform)
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 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)
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))
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)
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)
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)
self.waveform.set_waveform(None)
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)
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("""
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()
)
# Update waveform position
position_pct = deck.position / deck.track.num_samples
self.waveform.set_position(position_pct)
if deck.queued:
self.lcd.update_queue(
os.path.basename(deck.queued.filepath)
)