v0.2 - drop fingerprinting, add LUFS cart normalisation, playlist save/load, editable history with TXT+CSV export

This commit is contained in:
2026-05-08 17:19:11 +10:00
parent 0c8900ac36
commit 95f74d813f
19 changed files with 679 additions and 483 deletions
+113 -47
View File
@@ -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(