v0.2 - drop fingerprinting, add LUFS cart normalisation, playlist save/load, editable history with TXT+CSV export
This commit is contained in:
+113
-47
@@ -4,7 +4,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QFrame, QSizePolicy
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, QRect
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QFont, QColor, QPainter, QPen
|
||||
import numpy as np
|
||||
import os
|
||||
@@ -24,26 +24,18 @@ class WaveformWidget(QFrame):
|
||||
}
|
||||
""")
|
||||
|
||||
self.waveform_data = None # Shape: (width,) - RMS per pixel
|
||||
self.position_pct = 0.0 # 0.0 to 1.0
|
||||
self.waveform_data = None
|
||||
self.position_pct = 0.0
|
||||
self.warning_flash = False
|
||||
|
||||
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
|
||||
mono = np.mean(audio, axis=0)
|
||||
width = max(500, self.width())
|
||||
|
||||
samples_per_pixel = max(1, len(mono) // width)
|
||||
num_pixels = len(mono) // samples_per_pixel
|
||||
@@ -55,9 +47,9 @@ class WaveformWidget(QFrame):
|
||||
|
||||
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))
|
||||
def set_position(self, position_pct: float, warning: bool = False):
|
||||
self.position_pct = max(0.0, min(1.0, position_pct))
|
||||
self.warning_flash = warning
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
@@ -69,30 +61,39 @@ class WaveformWidget(QFrame):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
rect = self.rect()
|
||||
rect = self.rect().adjusted(2, 2, -2, -2)
|
||||
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
|
||||
x = rect.x() + int(i * x_scale)
|
||||
bar_h = int(scaled[i])
|
||||
y_top = rect.y() + (height - bar_h) // 2
|
||||
|
||||
pct = i / len(self.waveform_data)
|
||||
|
||||
if pct < self.position_pct:
|
||||
color = QColor("#004400")
|
||||
elif pct > 0.85:
|
||||
color = QColor("#ff0000") if self.warning_flash else QColor("#aa0000")
|
||||
elif pct > 0.70:
|
||||
color = QColor("#ffaa00")
|
||||
else:
|
||||
color = QColor("#00ff41")
|
||||
|
||||
pen = QPen(color, 1)
|
||||
painter.setPen(pen)
|
||||
painter.drawLine(x, y_top, x, y_top + bar_h)
|
||||
|
||||
# Draw playhead
|
||||
playhead_x = int(self.position_pct * width)
|
||||
playhead_x = rect.x() + int(self.position_pct * width)
|
||||
pen = QPen(QColor("#ffffff"), 2)
|
||||
painter.setPen(pen)
|
||||
painter.drawLine(playhead_x, 0, playhead_x, height)
|
||||
painter.drawLine(playhead_x, rect.y(), playhead_x, rect.y() + height)
|
||||
|
||||
|
||||
class LCDDisplay(QFrame):
|
||||
@@ -188,6 +189,23 @@ class LCDDisplay(QFrame):
|
||||
f"color: {colour}; background: transparent;"
|
||||
)
|
||||
|
||||
def flash_warning(self, active: bool):
|
||||
"""Flash all time displays red when in warning zone."""
|
||||
if active:
|
||||
self.elapsed_label.setStyleSheet(
|
||||
"color: #ff4444; background: transparent;"
|
||||
)
|
||||
self.remaining_label.setStyleSheet(
|
||||
"color: #ff4444; background: transparent;"
|
||||
)
|
||||
else:
|
||||
self.elapsed_label.setStyleSheet(
|
||||
"color: #00ff41; background: transparent;"
|
||||
)
|
||||
self.remaining_label.setStyleSheet(
|
||||
"color: #00cc33; background: transparent;"
|
||||
)
|
||||
|
||||
|
||||
def make_transport_button(text: str,
|
||||
color: str = "#3a3a3a",
|
||||
@@ -225,8 +243,13 @@ class DeckWidget(QWidget):
|
||||
self.engine = engine
|
||||
self.accent_color = accent_color
|
||||
self.current_theme = None
|
||||
self.flash_state = False
|
||||
|
||||
self._build_ui(label)
|
||||
# Convert "DECK 1" to "DECK_001"
|
||||
deck_num = label.split()[-1]
|
||||
self.deck_label = f"DECK_{int(deck_num):03d}"
|
||||
|
||||
self._build_ui()
|
||||
self._connect_signals()
|
||||
|
||||
self.timer = QTimer(self)
|
||||
@@ -234,7 +257,12 @@ class DeckWidget(QWidget):
|
||||
self.timer.timeout.connect(self._refresh_display)
|
||||
self.timer.start()
|
||||
|
||||
def _build_ui(self, label: str):
|
||||
self.flash_timer = QTimer(self)
|
||||
self.flash_timer.setInterval(500)
|
||||
self.flash_timer.timeout.connect(self._toggle_flash)
|
||||
self.flash_timer.start()
|
||||
|
||||
def _build_ui(self):
|
||||
self.setStyleSheet(f"""
|
||||
QWidget {{
|
||||
background-color: #1c1c1c;
|
||||
@@ -247,21 +275,20 @@ class DeckWidget(QWidget):
|
||||
root.setContentsMargins(10, 10, 10, 10)
|
||||
root.setSpacing(8)
|
||||
|
||||
header = QLabel(label)
|
||||
header.setFont(QFont("Arial", 13, QFont.Bold))
|
||||
header.setStyleSheet(f"""
|
||||
self.header = QLabel(self.deck_label)
|
||||
self.header.setFont(QFont("Courier New", 24, QFont.Bold))
|
||||
self.header.setStyleSheet(f"""
|
||||
color: {self.accent_color};
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
padding: 4px;
|
||||
""")
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(header)
|
||||
self.header.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(self.header)
|
||||
|
||||
self.lcd = LCDDisplay()
|
||||
root.addWidget(self.lcd)
|
||||
|
||||
# Waveform display
|
||||
self.waveform = WaveformWidget()
|
||||
root.addWidget(self.waveform)
|
||||
|
||||
@@ -286,30 +313,38 @@ 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
|
||||
|
||||
border_color = theme['border']
|
||||
bg_color = theme['bg2']
|
||||
|
||||
self.setStyleSheet(f"""
|
||||
QWidget {{
|
||||
background-color: {theme['bg2']};
|
||||
border: 2px solid {theme['border']};
|
||||
background-color: {bg_color};
|
||||
border: 2px solid {border_color};
|
||||
border-radius: 6px;
|
||||
}}
|
||||
""")
|
||||
|
||||
self.lcd.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background-color: {theme['bg']};
|
||||
border: 2px solid {theme['border']};
|
||||
border: 2px solid {border_color};
|
||||
border-radius: 4px;
|
||||
}}
|
||||
""")
|
||||
|
||||
self.waveform.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background-color: {theme['bg']};
|
||||
border: 1px solid {theme['border']};
|
||||
border: 1px solid {border_color};
|
||||
border-radius: 3px;
|
||||
}}
|
||||
""")
|
||||
|
||||
def _toggle_flash(self):
|
||||
self.flash_state = not self.flash_state
|
||||
|
||||
def _connect_signals(self):
|
||||
self.btn_play.clicked.connect(self._toggle_play)
|
||||
self.btn_stop.clicked.connect(self._stop_eject)
|
||||
@@ -322,7 +357,9 @@ class DeckWidget(QWidget):
|
||||
self.lcd.update_track(name)
|
||||
self.lcd.update_status("LOADED")
|
||||
self.lcd.update_time("00:00", "-00:00")
|
||||
# Build waveform when track loads
|
||||
QTimer.singleShot(200, self._update_waveform)
|
||||
|
||||
def _update_waveform(self):
|
||||
deck = self.engine.decks[self.deck_key]
|
||||
if deck.track:
|
||||
self.waveform.set_waveform(deck.track.audio)
|
||||
@@ -428,7 +465,7 @@ class DeckWidget(QWidget):
|
||||
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)
|
||||
QTimer.singleShot(200, self._update_waveform)
|
||||
deck.queued = None
|
||||
self.lcd.update_queue("")
|
||||
self.lcd.update_status("LOADED")
|
||||
@@ -453,9 +490,38 @@ 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)
|
||||
remaining_sec = deck.track.duration - (deck.position / deck.track.sr)
|
||||
|
||||
# Warning if < 20 seconds OR < 15% remaining
|
||||
in_warning = (remaining_sec < 20) or (position_pct > 0.85)
|
||||
|
||||
# Flash header AND all numbers if in warning zone and playing
|
||||
if deck.playing and in_warning:
|
||||
flash_color = '#ff4444' if self.flash_state else self.accent_color
|
||||
|
||||
# Flash header
|
||||
self.header.setStyleSheet(f"""
|
||||
color: {flash_color};
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
""")
|
||||
|
||||
# Flash LCD numbers
|
||||
self.lcd.flash_warning(self.flash_state)
|
||||
else:
|
||||
# Normal colors
|
||||
self.header.setStyleSheet(f"""
|
||||
color: {self.accent_color};
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
""")
|
||||
self.lcd.flash_warning(False)
|
||||
|
||||
self.waveform.set_position(position_pct, in_warning and self.flash_state)
|
||||
|
||||
if deck.queued:
|
||||
self.lcd.update_queue(
|
||||
|
||||
Reference in New Issue
Block a user