# 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) )