v0.2 - drop fingerprinting, add LUFS cart normalisation, playlist save/load, editable history with TXT+CSV export
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -6,8 +6,10 @@ import jack
|
||||
import threading
|
||||
import subprocess
|
||||
import os
|
||||
import pyloudnorm as pyln
|
||||
|
||||
|
||||
LUFS_TARGET = -14.0
|
||||
SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'}
|
||||
FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'}
|
||||
|
||||
@@ -52,6 +54,18 @@ def load_audio(filepath: str, target_sr: int) -> np.ndarray:
|
||||
return data
|
||||
|
||||
|
||||
def normalize_lufs(audio: np.ndarray, sr: int,
|
||||
target: float = LUFS_TARGET) -> np.ndarray:
|
||||
meter = pyln.Meter(sr)
|
||||
loudness = meter.integrated_loudness(audio.T)
|
||||
if np.isinf(loudness):
|
||||
return audio
|
||||
gain_db = target - loudness
|
||||
gain_linear = 10 ** (gain_db / 20.0)
|
||||
normalized = audio * gain_linear
|
||||
return np.clip(normalized, -1.0, 1.0)
|
||||
|
||||
|
||||
class Track:
|
||||
def __init__(self, filepath: str, audio: np.ndarray, sr: int):
|
||||
self.filepath = filepath
|
||||
@@ -260,6 +274,14 @@ class AudioEngine:
|
||||
self.decks[deck_key].load(track)
|
||||
threading.Thread(target=_load, daemon=True).start()
|
||||
|
||||
def load_cart_track(self, cart_key: str, filepath: str):
|
||||
def _load():
|
||||
audio = load_audio(filepath, self.sr)
|
||||
audio = normalize_lufs(audio, self.sr)
|
||||
track = Track(filepath, audio, self.sr)
|
||||
self.decks[cart_key].load(track)
|
||||
threading.Thread(target=_load, daemon=True).start()
|
||||
|
||||
def load_queue(self, deck_key: str, filepath: str):
|
||||
def _load():
|
||||
audio = load_audio(filepath, self.sr)
|
||||
|
||||
+1
-1
@@ -156,7 +156,7 @@ class CartSlot(QFrame):
|
||||
self.btn_eject.clicked.connect(self._eject)
|
||||
|
||||
def load_track(self, filepath: str):
|
||||
self.engine.load_track(self.cart_key, filepath)
|
||||
self.engine.load_cart_track(self.cart_key, filepath)
|
||||
name = os.path.basename(filepath)
|
||||
short = name[:28] + "..." if len(name) > 28 else name
|
||||
self.track_label.setText(short)
|
||||
|
||||
+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(
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
# fingerprint_widget.py
|
||||
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
import subprocess
|
||||
import threading
|
||||
import requests
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QLabel,
|
||||
QPushButton, QProgressBar, QFrame
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
|
||||
class FingerprintWidget(QFrame):
|
||||
result_ready = Signal(object)
|
||||
error_ready = Signal(str)
|
||||
|
||||
def __init__(self, engine, api_key: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self.acoustid_key = api_key.strip()
|
||||
self.recording_seconds = 25
|
||||
self.last_signal_check = False
|
||||
|
||||
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #0d0d1a;
|
||||
border: 2px solid #4444aa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
""")
|
||||
|
||||
self._build_ui()
|
||||
self.result_ready.connect(self._show_result)
|
||||
self.error_ready.connect(self._show_error)
|
||||
|
||||
self.signal_timer = QTimer(self)
|
||||
self.signal_timer.setInterval(500)
|
||||
self.signal_timer.timeout.connect(self._check_signal)
|
||||
self.signal_timer.start()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(10, 8, 10, 8)
|
||||
root.setSpacing(6)
|
||||
|
||||
header = QLabel("AUDIO FINGERPRINT")
|
||||
header.setFont(QFont("Arial", 11, QFont.Bold))
|
||||
header.setStyleSheet("color: #6666cc; background: transparent; border: none;")
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(header)
|
||||
|
||||
port_info = QLabel("JACK: RadioPanel:fingerprint_L / R")
|
||||
port_info.setFont(QFont("Courier New", 8))
|
||||
port_info.setStyleSheet("color: #444488; background: transparent; border: none;")
|
||||
port_info.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(port_info)
|
||||
|
||||
self.signal_label = QLabel("● NO SIGNAL")
|
||||
self.signal_label.setFont(QFont("Courier New", 14, QFont.Bold))
|
||||
self.signal_label.setStyleSheet("color: #330000; background: transparent; border: none;")
|
||||
self.signal_label.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(self.signal_label)
|
||||
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, self.recording_seconds * 10)
|
||||
self.progress.setValue(0)
|
||||
self.progress.setTextVisible(False)
|
||||
self.progress.setFixedHeight(8)
|
||||
self.progress.setStyleSheet("""
|
||||
QProgressBar {
|
||||
border: 1px solid #4444aa;
|
||||
border-radius: 3px;
|
||||
background: #0a0a1a;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #6666cc;
|
||||
}
|
||||
""")
|
||||
self.progress.hide()
|
||||
root.addWidget(self.progress)
|
||||
|
||||
self.status_label = QLabel("READY")
|
||||
self.status_label.setFont(QFont("Courier New", 10, QFont.Bold))
|
||||
self.status_label.setStyleSheet("color: #444488; background: transparent; border: none;")
|
||||
self.status_label.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(self.status_label)
|
||||
|
||||
result_frame = QFrame()
|
||||
result_frame.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #0a0a1a;
|
||||
border: 1px solid #333366;
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
result_layout = QVBoxLayout(result_frame)
|
||||
result_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.result_artist = QLabel("Artist: -")
|
||||
self.result_artist.setFont(QFont("Courier New", 10, QFont.Bold))
|
||||
self.result_artist.setStyleSheet("color: #555588;")
|
||||
|
||||
self.result_title = QLabel("Title: -")
|
||||
self.result_title.setFont(QFont("Courier New", 11, QFont.Bold))
|
||||
self.result_title.setStyleSheet("color: #555588;")
|
||||
|
||||
result_layout.addWidget(self.result_artist)
|
||||
result_layout.addWidget(self.result_title)
|
||||
root.addWidget(result_frame)
|
||||
|
||||
self.btn_fingerprint = QPushButton("FINGERPRINT")
|
||||
self.btn_fingerprint.setFixedHeight(44)
|
||||
self.btn_fingerprint.setFont(QFont("Arial", 12, QFont.Bold))
|
||||
self.btn_fingerprint.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #4444aa;
|
||||
color: white;
|
||||
border: 1px solid #6666cc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QPushButton:hover { background-color: #5555bb; }
|
||||
QPushButton:pressed { background-color: #333399; }
|
||||
QPushButton:disabled {
|
||||
background-color: #222244;
|
||||
color: #666688;
|
||||
}
|
||||
""")
|
||||
self.btn_fingerprint.clicked.connect(self._start_fingerprint)
|
||||
root.addWidget(self.btn_fingerprint)
|
||||
|
||||
root.addStretch()
|
||||
|
||||
def _check_signal(self):
|
||||
if self.engine.recording:
|
||||
return
|
||||
levels = self.engine.fingerprint_levels
|
||||
has_signal = (levels[0] > 0.01 or levels[1] > 0.01)
|
||||
if has_signal != self.last_signal_check:
|
||||
self.last_signal_check = has_signal
|
||||
if has_signal:
|
||||
self.signal_label.setText("● SIGNAL PRESENT")
|
||||
self.signal_label.setStyleSheet("color: #00ff41; background: transparent;")
|
||||
else:
|
||||
self.signal_label.setText("● NO SIGNAL")
|
||||
self.signal_label.setStyleSheet("color: #330000; background: transparent;")
|
||||
|
||||
def _start_fingerprint(self):
|
||||
if not self.acoustid_key:
|
||||
self.error_ready.emit("Set your AcoustID key in main.py first!")
|
||||
return
|
||||
if self.engine.recording:
|
||||
return
|
||||
|
||||
self.btn_fingerprint.setEnabled(False)
|
||||
self.progress.show()
|
||||
self.progress.setValue(0)
|
||||
self.status_label.setText("LISTENING...")
|
||||
self.status_label.setStyleSheet("color: #ccaa00;")
|
||||
self.signal_label.setText("● RECORDING...")
|
||||
self.signal_label.setStyleSheet("color: #ccaa00;")
|
||||
self.result_artist.setText("Artist: -")
|
||||
self.result_title.setText("Title: -")
|
||||
|
||||
self.recording_timer = QTimer(self)
|
||||
self.recording_timer.setInterval(100)
|
||||
self.recording_timer.timeout.connect(self._update_progress)
|
||||
self.recording_timer.start()
|
||||
|
||||
self.engine.start_recording(self.recording_seconds, self._on_recording_finished)
|
||||
|
||||
def _update_progress(self):
|
||||
if not self.engine.recording:
|
||||
self.recording_timer.stop()
|
||||
self.progress.setValue(self.progress.maximum())
|
||||
return
|
||||
if self.engine.record_buffer is not None:
|
||||
progress = self.engine.record_position / self.engine.record_target_frames
|
||||
self.progress.setValue(int(progress * self.progress.maximum()))
|
||||
|
||||
def _on_recording_finished(self, audio_data: np.ndarray, sr: int):
|
||||
self.recording_timer.stop()
|
||||
self.status_label.setText("ANALYZING...")
|
||||
self.status_label.setStyleSheet("color: #4488ff;")
|
||||
self.signal_label.setText("● PROCESSING")
|
||||
self.signal_label.setStyleSheet("color: #4488ff;")
|
||||
threading.Thread(
|
||||
target=self._fingerprint_thread,
|
||||
args=(audio_data, sr),
|
||||
daemon=True
|
||||
).start()
|
||||
|
||||
def _fingerprint_thread(self, audio_data: np.ndarray, sr: int):
|
||||
try:
|
||||
result = self._identify_acoustid(audio_data, sr)
|
||||
self.result_ready.emit(result)
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
if "Invalid API key" in err_str:
|
||||
err_str = "Invalid API Key"
|
||||
elif "limit" in err_str.lower():
|
||||
err_str = "Daily limit reached"
|
||||
self.error_ready.emit(err_str)
|
||||
|
||||
def _identify_acoustid(self, audio_data: np.ndarray, sr: int) -> dict:
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
||||
temp_path = f.name
|
||||
try:
|
||||
sf.write(temp_path, audio_data.T, sr, subtype='PCM_16')
|
||||
|
||||
result = subprocess.run(
|
||||
['fpcalc', '-json', temp_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"fpcalc failed: {result.stderr}")
|
||||
|
||||
fp_data = json.loads(result.stdout)
|
||||
fingerprint = fp_data['fingerprint']
|
||||
duration = fp_data.get('duration', 0)
|
||||
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
url = 'https://api.acoustid.org/v2/lookup'
|
||||
params = {
|
||||
'client': self.acoustid_key,
|
||||
'fingerprint': fingerprint,
|
||||
'duration': int(duration),
|
||||
'meta': 'recordings'
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=15)
|
||||
data = response.json()
|
||||
|
||||
if data.get('status') != 'ok':
|
||||
err = data.get('error', {}).get('message', 'Unknown')
|
||||
raise RuntimeError(f"AcoustID: {err}")
|
||||
|
||||
results = data.get('results', [])
|
||||
if not results:
|
||||
return None
|
||||
|
||||
best = results[0]
|
||||
recordings = best.get('recordings', [])
|
||||
if not recordings:
|
||||
return None
|
||||
|
||||
rec = recordings[0]
|
||||
title = rec.get('title', 'Unknown')
|
||||
artists = rec.get('artists', [])
|
||||
artist = artists[0].get('name', 'Unknown') if artists else 'Unknown'
|
||||
|
||||
return {
|
||||
'artist': artist,
|
||||
'title': title,
|
||||
'score': best.get('score', 0)
|
||||
}
|
||||
|
||||
def _show_result(self, result):
|
||||
self.btn_fingerprint.setEnabled(True)
|
||||
self.progress.hide()
|
||||
self.signal_label.setText("● DONE")
|
||||
self.signal_label.setStyleSheet("color: #00aa33;")
|
||||
|
||||
if result is None:
|
||||
self.status_label.setText("NO MATCH")
|
||||
self.status_label.setStyleSheet("color: #aa4444;")
|
||||
else:
|
||||
self.status_label.setText(f"MATCH {result['score']:.0%}")
|
||||
self.status_label.setStyleSheet("color: #00ff41;")
|
||||
self.result_artist.setText(f"Artist: {result['artist']}")
|
||||
self.result_title.setText(f"Title: {result['title']}")
|
||||
self.result_artist.setStyleSheet("color: #cccccc;")
|
||||
self.result_title.setStyleSheet("color: #00ff41;")
|
||||
|
||||
def _show_error(self, error: str):
|
||||
self.btn_fingerprint.setEnabled(True)
|
||||
self.progress.hide()
|
||||
self.signal_label.setText("● ERROR")
|
||||
self.signal_label.setStyleSheet("color: #aa4444;")
|
||||
self.status_label.setText("ERROR")
|
||||
self.status_label.setStyleSheet("color: #ff4444;")
|
||||
self.result_artist.setText("Error:")
|
||||
self.result_title.setText(error[:60])
|
||||
self.result_artist.setStyleSheet("color: #aa4444;")
|
||||
self.result_title.setStyleSheet("color: #aa4444;")
|
||||
+298
-76
@@ -1,11 +1,17 @@
|
||||
# history_widget.py
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
|
||||
import musicbrainzngs
|
||||
from mutagen import File as MutagenFile
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame, QVBoxLayout, QLabel,
|
||||
QScrollArea, QWidget
|
||||
QFrame, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QScrollArea, QWidget, QPushButton, QDialog,
|
||||
QLineEdit, QFormLayout, QDialogButtonBox,
|
||||
QFileDialog
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
@@ -18,11 +24,9 @@ def _read_file_tags(filepath: str) -> dict:
|
||||
tags = MutagenFile(filepath, easy=True)
|
||||
if tags is None:
|
||||
return {}
|
||||
|
||||
def get(key):
|
||||
val = tags.get(key)
|
||||
return val[0] if val else None
|
||||
|
||||
return {
|
||||
'artist': get('artist'),
|
||||
'title': get('title'),
|
||||
@@ -36,35 +40,34 @@ def _read_file_tags(filepath: str) -> dict:
|
||||
def _lookup_musicbrainz(artist: str, title: str) -> dict:
|
||||
try:
|
||||
result = musicbrainzngs.search_recordings(
|
||||
recording=title,
|
||||
artist=artist,
|
||||
limit=1
|
||||
recording=title, artist=artist, limit=1
|
||||
)
|
||||
recordings = result.get('recording-list', [])
|
||||
if not recordings:
|
||||
return {}
|
||||
|
||||
rec = recordings[0]
|
||||
rec = recordings[0]
|
||||
releases = rec.get('release-list', [])
|
||||
if not releases:
|
||||
return {}
|
||||
|
||||
release = releases[0]
|
||||
album = release.get('title', None)
|
||||
year = None
|
||||
|
||||
date = release.get('date', '')
|
||||
if date:
|
||||
year = date[:4]
|
||||
|
||||
return {'album': album, 'year': year}
|
||||
return {
|
||||
'album': release.get('title'),
|
||||
'year': date[:4] if date else None,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
class HistoryEntry(QFrame):
|
||||
def __init__(self, index: int, filepath: str, metadata: dict, parent=None):
|
||||
deleted = Signal(object)
|
||||
|
||||
def __init__(self, index: int, filepath: str, metadata: dict,
|
||||
parent=None):
|
||||
super().__init__(parent)
|
||||
self._index = index
|
||||
self._filepath = filepath
|
||||
self._metadata = metadata
|
||||
|
||||
self.setFrameStyle(QFrame.Box)
|
||||
self.setStyleSheet("""
|
||||
@@ -75,51 +78,140 @@ class HistoryEntry(QFrame):
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setSpacing(2)
|
||||
root = QHBoxLayout(self)
|
||||
root.setContentsMargins(8, 6, 8, 6)
|
||||
root.setSpacing(8)
|
||||
|
||||
text_col = QVBoxLayout()
|
||||
text_col.setSpacing(2)
|
||||
|
||||
artist = metadata.get('artist') or 'Unknown Artist'
|
||||
title = metadata.get('title') or self._filename(filepath)
|
||||
|
||||
top_row = QLabel(f"{index}. {artist} — {title}")
|
||||
top_row.setFont(QFont("Arial", 11, QFont.Bold))
|
||||
top_row.setStyleSheet("color: #00ff41; background: transparent; border: none;")
|
||||
top_row.setWordWrap(True)
|
||||
layout.addWidget(top_row)
|
||||
top = QLabel(f"{index}. {artist} — {title}")
|
||||
top.setFont(QFont("Arial", 11, QFont.Bold))
|
||||
top.setStyleSheet(
|
||||
"color: #00ff41; background: transparent; border: none;"
|
||||
)
|
||||
top.setWordWrap(True)
|
||||
text_col.addWidget(top)
|
||||
|
||||
album = metadata.get('album') or ''
|
||||
year = metadata.get('year') or ''
|
||||
parts = [p for p in [album, year[:4] if year and len(year) >= 4 else year] if p]
|
||||
|
||||
sub_parts = []
|
||||
if album:
|
||||
sub_parts.append(album)
|
||||
if year:
|
||||
sub_parts.append(year[:4])
|
||||
|
||||
if sub_parts:
|
||||
sub_row = QLabel(" " + " · ".join(sub_parts))
|
||||
sub_row.setFont(QFont("Arial", 9))
|
||||
sub_row.setStyleSheet(
|
||||
if parts:
|
||||
sub = QLabel(" " + " · ".join(parts))
|
||||
sub.setFont(QFont("Arial", 9))
|
||||
sub.setStyleSheet(
|
||||
"color: #888888; background: transparent; border: none;"
|
||||
)
|
||||
sub_row.setWordWrap(True)
|
||||
layout.addWidget(sub_row)
|
||||
sub.setWordWrap(True)
|
||||
text_col.addWidget(sub)
|
||||
|
||||
root.addLayout(text_col, stretch=1)
|
||||
|
||||
self._del_btn = QPushButton("✕")
|
||||
self._del_btn.setFixedSize(24, 24)
|
||||
self._del_btn.setFont(QFont("Arial", 10, QFont.Bold))
|
||||
self._del_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #5a1a1a;
|
||||
color: #ff4444;
|
||||
border: 1px solid #444;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QPushButton:hover { background-color: #7a2a2a; }
|
||||
""")
|
||||
self._del_btn.setToolTip("Remove from history")
|
||||
self._del_btn.clicked.connect(lambda: self.deleted.emit(self))
|
||||
root.addWidget(self._del_btn, alignment=Qt.AlignTop)
|
||||
|
||||
def _filename(self, filepath: str) -> str:
|
||||
import os
|
||||
if not filepath:
|
||||
return "Unknown"
|
||||
name = os.path.basename(filepath)
|
||||
return os.path.splitext(name)[0]
|
||||
|
||||
@property
|
||||
def filepath(self) -> str:
|
||||
return self._filepath
|
||||
|
||||
@property
|
||||
def metadata(self) -> dict:
|
||||
return self._metadata
|
||||
|
||||
|
||||
class AddEntryDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Add to History")
|
||||
self.setFixedWidth(380)
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #1e1e1e;
|
||||
color: #cccccc;
|
||||
border: 2px solid #444;
|
||||
border-radius: 6px;
|
||||
}
|
||||
QLabel {
|
||||
color: #aaaaaa;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
QLineEdit {
|
||||
background-color: #111;
|
||||
color: #00ff41;
|
||||
border: 1px solid #333;
|
||||
border-radius: 3px;
|
||||
padding: 6px;
|
||||
font-family: Courier New;
|
||||
font-size: 12px;
|
||||
}
|
||||
QPushButton {
|
||||
background-color: #2a2a2a;
|
||||
color: #cccccc;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
padding: 6px 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover { background-color: #444; }
|
||||
""")
|
||||
|
||||
layout = QFormLayout(self)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.setSpacing(10)
|
||||
|
||||
self._artist = QLineEdit()
|
||||
self._artist.setPlaceholderText("e.g. The Beatles")
|
||||
layout.addRow("Artist:", self._artist)
|
||||
|
||||
self._title = QLineEdit()
|
||||
self._title.setPlaceholderText("e.g. Hey Jude")
|
||||
layout.addRow("Title:", self._title)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.button(QDialogButtonBox.Ok).setText("Add")
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addRow(buttons)
|
||||
|
||||
def values(self) -> dict:
|
||||
return {
|
||||
'artist': self._artist.text().strip(),
|
||||
'title': self._title.text().strip(),
|
||||
}
|
||||
|
||||
|
||||
class HistoryWidget(QFrame):
|
||||
_metadata_ready = Signal(str, dict)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._history = []
|
||||
self._pending = {}
|
||||
self._history = []
|
||||
self._pending = {}
|
||||
self._entry_index = 0
|
||||
|
||||
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||
self.setStyleSheet("""
|
||||
@@ -138,21 +230,43 @@ class HistoryWidget(QFrame):
|
||||
root.setContentsMargins(8, 8, 8, 8)
|
||||
root.setSpacing(6)
|
||||
|
||||
# Header
|
||||
header = QLabel("PLAY HISTORY")
|
||||
header.setFont(QFont("Arial", 11, QFont.Bold))
|
||||
header.setStyleSheet("""
|
||||
color: #336633;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
""")
|
||||
header.setStyleSheet(
|
||||
"color: #336633; background: transparent; border: none; padding: 2px;"
|
||||
)
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(header)
|
||||
|
||||
self.scroll = QScrollArea()
|
||||
self.scroll.setWidgetResizable(True)
|
||||
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.scroll.setStyleSheet("""
|
||||
# Toolbar
|
||||
bar = QHBoxLayout()
|
||||
bar.setSpacing(4)
|
||||
|
||||
self._add_btn = QPushButton("+ ADD")
|
||||
self._add_btn.setFixedHeight(28)
|
||||
self._add_btn.setFont(QFont("Arial", 9, QFont.Bold))
|
||||
self._add_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2a4a2a;
|
||||
color: #00ff41;
|
||||
border: 1px solid #336633;
|
||||
border-radius: 3px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
QPushButton:hover { background-color: #3a5a3a; }
|
||||
""")
|
||||
self._add_btn.clicked.connect(self._add_manual_entry)
|
||||
bar.addWidget(self._add_btn)
|
||||
|
||||
bar.addStretch()
|
||||
root.addLayout(bar)
|
||||
|
||||
# Scroll area
|
||||
self._scroll = QScrollArea()
|
||||
self._scroll.setWidgetResizable(True)
|
||||
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self._scroll.setStyleSheet("""
|
||||
QScrollArea {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -168,18 +282,56 @@ class HistoryWidget(QFrame):
|
||||
}
|
||||
""")
|
||||
|
||||
self.list_widget = QWidget()
|
||||
self.list_widget.setStyleSheet("background: transparent;")
|
||||
self.list_layout = QVBoxLayout(self.list_widget)
|
||||
self.list_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.list_layout.setSpacing(0)
|
||||
self.list_layout.addStretch()
|
||||
self._list_widget = QWidget()
|
||||
self._list_widget.setStyleSheet("background: transparent;")
|
||||
self._list_layout = QVBoxLayout(self._list_widget)
|
||||
self._list_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._list_layout.setSpacing(0)
|
||||
self._list_layout.addStretch()
|
||||
|
||||
self.scroll.setWidget(self.list_widget)
|
||||
root.addWidget(self.scroll)
|
||||
self._scroll.setWidget(self._list_widget)
|
||||
root.addWidget(self._scroll)
|
||||
|
||||
# Export bar
|
||||
export_bar = QHBoxLayout()
|
||||
export_bar.setSpacing(4)
|
||||
|
||||
self._export_txt_btn = QPushButton("EXPORT TXT")
|
||||
self._export_txt_btn.setFixedHeight(28)
|
||||
self._export_txt_btn.setFont(QFont("Arial", 9, QFont.Bold))
|
||||
self._export_txt_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2a3a2a;
|
||||
color: #aaaaaa;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
QPushButton:hover { background-color: #3a4a3a; }
|
||||
""")
|
||||
self._export_txt_btn.clicked.connect(self._export_txt)
|
||||
|
||||
self._export_csv_btn = QPushButton("EXPORT CSV")
|
||||
self._export_csv_btn.setFixedHeight(28)
|
||||
self._export_csv_btn.setFont(QFont("Arial", 9, QFont.Bold))
|
||||
self._export_csv_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2a3a2a;
|
||||
color: #aaaaaa;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
QPushButton:hover { background-color: #3a4a3a; }
|
||||
""")
|
||||
self._export_csv_btn.clicked.connect(self._export_csv)
|
||||
|
||||
export_bar.addWidget(self._export_txt_btn)
|
||||
export_bar.addWidget(self._export_csv_btn)
|
||||
export_bar.addStretch()
|
||||
root.addLayout(export_bar)
|
||||
|
||||
def apply_theme(self, theme: dict):
|
||||
"""Apply theme to history border."""
|
||||
self.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background-color: {theme['bg2']};
|
||||
@@ -189,41 +341,69 @@ class HistoryWidget(QFrame):
|
||||
""")
|
||||
|
||||
def add_track(self, filepath: str):
|
||||
index = len(self._history) + 1
|
||||
idx = self._entry_index + 1
|
||||
metadata = _read_file_tags(filepath)
|
||||
self._history.append((filepath, metadata))
|
||||
self._insert_entry(index, filepath, metadata)
|
||||
self._entry_index = idx
|
||||
|
||||
entry = self._insert_entry(idx, filepath, metadata)
|
||||
|
||||
if not metadata.get('artist') or not metadata.get('album'):
|
||||
self._pending[filepath] = index
|
||||
self._pending[filepath] = idx
|
||||
threading.Thread(
|
||||
target=self._enrich_metadata,
|
||||
args=(filepath, metadata),
|
||||
daemon=True
|
||||
).start()
|
||||
|
||||
def _insert_entry(self, index: int, filepath: str, metadata: dict):
|
||||
def _add_manual_entry(self):
|
||||
dlg = AddEntryDialog(self)
|
||||
if dlg.exec() != QDialog.Accepted:
|
||||
return
|
||||
|
||||
vals = dlg.values()
|
||||
if not vals['artist'] and not vals['title']:
|
||||
return
|
||||
|
||||
idx = self._entry_index + 1
|
||||
metadata = {
|
||||
'artist': vals['artist'] or 'Unknown Artist',
|
||||
'title': vals['title'] or 'Unknown Title',
|
||||
'album': None,
|
||||
'year': None,
|
||||
}
|
||||
self._history.append(("", metadata))
|
||||
self._entry_index = idx
|
||||
self._insert_entry(idx, "", metadata)
|
||||
|
||||
def _insert_entry(self, index: int, filepath: str,
|
||||
metadata: dict) -> HistoryEntry:
|
||||
entry = HistoryEntry(index, filepath, metadata)
|
||||
self.list_layout.insertWidget(0, entry)
|
||||
self.scroll.verticalScrollBar().setValue(0)
|
||||
entry.deleted.connect(self._delete_entry)
|
||||
self._list_layout.insertWidget(0, entry)
|
||||
self._scroll.verticalScrollBar().setValue(0)
|
||||
return entry
|
||||
|
||||
def _delete_entry(self, entry: HistoryEntry):
|
||||
idx = entry._index - 1
|
||||
if 0 <= idx < len(self._history):
|
||||
self._history.pop(idx)
|
||||
entry.deleteLater()
|
||||
self._rebuild_list()
|
||||
|
||||
def _enrich_metadata(self, filepath: str, existing: dict):
|
||||
artist = existing.get('artist')
|
||||
title = existing.get('title')
|
||||
|
||||
if not artist or not title:
|
||||
return
|
||||
|
||||
mb_data = _lookup_musicbrainz(artist, title)
|
||||
if not mb_data:
|
||||
return
|
||||
|
||||
merged = dict(existing)
|
||||
if not merged.get('album') and mb_data.get('album'):
|
||||
merged['album'] = mb_data['album']
|
||||
if not merged.get('year') and mb_data.get('year'):
|
||||
merged['year'] = mb_data['year']
|
||||
|
||||
self._metadata_ready.emit(filepath, merged)
|
||||
|
||||
def _on_metadata_ready(self, filepath: str, metadata: dict):
|
||||
@@ -234,15 +414,57 @@ class HistoryWidget(QFrame):
|
||||
self._rebuild_list()
|
||||
|
||||
def _rebuild_list(self):
|
||||
while self.list_layout.count() > 1:
|
||||
item = self.list_layout.takeAt(0)
|
||||
while self._list_layout.count() > 1:
|
||||
item = self._list_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
for i, (filepath, metadata) in enumerate(reversed(self._history)):
|
||||
index = len(self._history) - i
|
||||
entry = HistoryEntry(index, filepath, metadata)
|
||||
self.list_layout.insertWidget(
|
||||
self.list_layout.count() - 1,
|
||||
entry
|
||||
entry.deleted.connect(self._delete_entry)
|
||||
self._list_layout.insertWidget(
|
||||
self._list_layout.count() - 1, entry
|
||||
)
|
||||
|
||||
def _iter_entries(self):
|
||||
for i, (fp, md) in enumerate(reversed(self._history)):
|
||||
idx = len(self._history) - i
|
||||
artist = md.get('artist') or 'Unknown Artist'
|
||||
title = md.get('title') or (os.path.splitext(os.path.basename(fp))[0] if fp else 'Unknown')
|
||||
album = md.get('album') or ''
|
||||
year = md.get('year') or ''
|
||||
yield idx, artist, title, album, year
|
||||
|
||||
def _export_txt(self):
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export History (TXT)",
|
||||
os.path.expanduser("~/play_history.txt"),
|
||||
"Text Files (*.txt)"
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
with open(path, 'w') as f:
|
||||
for idx, artist, title, album, year in self._iter_entries():
|
||||
f.write(f"{idx}. {artist} — {title}")
|
||||
if album or year:
|
||||
parts = [p for p in [album,
|
||||
year[:4] if year and len(year) >= 4 else year]
|
||||
if p]
|
||||
if parts:
|
||||
f.write(f"\n {' · '.join(parts)}")
|
||||
f.write("\n")
|
||||
|
||||
def _export_csv(self):
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export History (CSV)",
|
||||
os.path.expanduser("~/play_history.csv"),
|
||||
"CSV Files (*.csv)"
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
with open(path, 'w', newline='') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["#", "Artist", "Title", "Album", "Year"])
|
||||
for idx, artist, title, album, year in self._iter_entries():
|
||||
w.writerow([idx, artist, title, album, year])
|
||||
|
||||
+72
-48
@@ -1,15 +1,12 @@
|
||||
# lufs_meter_widget.py
|
||||
|
||||
from PySide6.QtWidgets import QFrame, QVBoxLayout, QLabel
|
||||
from PySide6.QtCore import Qt, QTimer, QRect
|
||||
from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient
|
||||
from PySide6.QtWidgets import QFrame, QVBoxLayout, QLabel, QWidget
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen
|
||||
|
||||
|
||||
class LUFSMeterWidget(QFrame):
|
||||
"""
|
||||
Vertical LUFS meter for broadcast monitoring.
|
||||
EBU R128 standard: Target -23 LUFS ±1 LU (Loudness Units).
|
||||
"""
|
||||
"""Vertical LUFS meter with -14 LUFS target for streaming/broadcast."""
|
||||
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -29,7 +26,7 @@ class LUFSMeterWidget(QFrame):
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setInterval(100)
|
||||
self.timer.timeout.connect(self.update)
|
||||
self.timer.timeout.connect(self._update_meter)
|
||||
self.timer.start()
|
||||
|
||||
def _build_ui(self):
|
||||
@@ -42,74 +39,101 @@ class LUFSMeterWidget(QFrame):
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(header)
|
||||
|
||||
# Meter canvas
|
||||
self.meter_canvas = QFrame()
|
||||
self.meter_canvas.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #000000;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
self.meter_canvas = MeterCanvas(self.engine)
|
||||
layout.addWidget(self.meter_canvas, stretch=1)
|
||||
|
||||
# Numeric readout
|
||||
self.readout = QLabel("-70.0")
|
||||
self.readout.setFont(QFont("Courier New", 12, QFont.Bold))
|
||||
self.readout.setStyleSheet("color: #00ff41; background: transparent; border: none;")
|
||||
self.readout.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.readout)
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
|
||||
def _update_meter(self):
|
||||
lufs = self.engine.lufs_current
|
||||
self.readout.setText(f"{lufs:.1f}")
|
||||
self.meter_canvas.update()
|
||||
|
||||
painter = QPainter(self.meter_canvas)
|
||||
rect = self.meter_canvas.rect().adjusted(2, 2, -2, -2)
|
||||
|
||||
# LUFS scale: -70 (bottom) to 0 (top)
|
||||
# Target zone: -24 to -22 (±1 LU around -23)
|
||||
lufs_range = 70.0 # -70 to 0
|
||||
class MeterCanvas(QWidget):
|
||||
"""Custom painted LUFS bar with -14 LUFS target."""
|
||||
|
||||
if lufs < -70:
|
||||
lufs = -70
|
||||
if lufs > 0:
|
||||
lufs = 0
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self.setStyleSheet("""
|
||||
QWidget {
|
||||
background-color: #000000;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
self.setMinimumHeight(200)
|
||||
|
||||
fill_pct = (lufs + 70) / lufs_range
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
rect = self.rect().adjusted(3, 3, -3, -3)
|
||||
lufs = self.engine.lufs_current
|
||||
|
||||
# Scale: -50 LUFS (bottom) to 0 (top) - makes -14 more readable
|
||||
lufs_min = -50
|
||||
lufs_max = 0
|
||||
lufs_range = lufs_max - lufs_min
|
||||
|
||||
lufs = max(lufs_min, min(lufs_max, lufs))
|
||||
fill_pct = (lufs - lufs_min) / lufs_range
|
||||
fill_h = int(rect.height() * fill_pct)
|
||||
|
||||
# Draw gradient meter
|
||||
gradient = QLinearGradient(0, rect.height(), 0, 0)
|
||||
# Color zones optimized for -14 LUFS target
|
||||
gradient = QLinearGradient(0, rect.bottom(), 0, rect.top())
|
||||
|
||||
# Color zones (broadcast standard)
|
||||
if lufs < -30:
|
||||
gradient.setColorAt(0, QColor("#003300")) # Very quiet - dark green
|
||||
gradient.setColorAt(1, QColor("#005500"))
|
||||
elif lufs < -24:
|
||||
gradient.setColorAt(0, QColor("#00aa00")) # Quiet - green
|
||||
# Very quiet - dark green
|
||||
gradient.setColorAt(0, QColor("#002200"))
|
||||
gradient.setColorAt(1, QColor("#004400"))
|
||||
elif lufs < -18:
|
||||
# Quiet - green
|
||||
gradient.setColorAt(0, QColor("#00aa00"))
|
||||
gradient.setColorAt(1, QColor("#00dd00"))
|
||||
elif lufs < -22:
|
||||
gradient.setColorAt(0, QColor("#00ff00")) # Target zone - bright green
|
||||
elif lufs < -12:
|
||||
# Target zone -18 to -12 LUFS (±4 LU around -14) - bright green
|
||||
gradient.setColorAt(0, QColor("#00ff00"))
|
||||
gradient.setColorAt(1, QColor("#00ff00"))
|
||||
elif lufs < -9:
|
||||
gradient.setColorAt(0, QColor("#ffcc00")) # Getting hot - yellow
|
||||
elif lufs < -6:
|
||||
# Getting hot - yellow
|
||||
gradient.setColorAt(0, QColor("#ffcc00"))
|
||||
gradient.setColorAt(1, QColor("#ffaa00"))
|
||||
else:
|
||||
gradient.setColorAt(0, QColor("#ff0000")) # Too hot - red
|
||||
# Too hot - red
|
||||
gradient.setColorAt(0, QColor("#ff0000"))
|
||||
gradient.setColorAt(1, QColor("#ff0000"))
|
||||
|
||||
painter.fillRect(
|
||||
rect.x(),
|
||||
rect.y() + rect.height() - fill_h,
|
||||
rect.bottom() - fill_h,
|
||||
rect.width(),
|
||||
fill_h,
|
||||
gradient
|
||||
)
|
||||
|
||||
# Draw target line at -23 LUFS
|
||||
target_y = rect.y() + int(rect.height() * (1.0 - ((-23 + 70) / lufs_range)))
|
||||
painter.setPen(QColor("#ffffff"))
|
||||
painter.drawLine(rect.x(), target_y, rect.x() + rect.width(), target_y)
|
||||
# Draw reference lines
|
||||
painter.setPen(QPen(QColor("#ffffff"), 1))
|
||||
|
||||
# Target line at -14 LUFS (main target)
|
||||
target_pct = ((-14 - lufs_min) / lufs_range)
|
||||
target_y = rect.bottom() - int(rect.height() * target_pct)
|
||||
painter.drawLine(rect.x(), target_y, rect.right(), target_y)
|
||||
|
||||
# Upper boundary at -12 LUFS (dotted)
|
||||
painter.setPen(QPen(QColor("#888888"), 1, Qt.DotLine))
|
||||
upper_pct = ((-12 - lufs_min) / lufs_range)
|
||||
upper_y = rect.bottom() - int(rect.height() * upper_pct)
|
||||
painter.drawLine(rect.x(), upper_y, rect.right(), upper_y)
|
||||
|
||||
# Lower boundary at -16 LUFS (dotted)
|
||||
lower_pct = ((-16 - lufs_min) / lufs_range)
|
||||
lower_y = rect.bottom() - int(rect.height() * lower_pct)
|
||||
painter.drawLine(rect.x(), lower_y, rect.right(), lower_y)
|
||||
|
||||
painter.end()
|
||||
+101
-2
@@ -8,6 +8,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QFont, QColor, QAction
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
@@ -264,6 +265,17 @@ class BasePlaylistPanel(QWidget):
|
||||
else:
|
||||
item.setForeground(QColor("#cccccc"))
|
||||
|
||||
def get_filepaths(self, lst: QListWidget) -> list:
|
||||
paths = []
|
||||
for i in range(lst.count()):
|
||||
item = lst.item(i)
|
||||
if isinstance(item, PlaylistItem):
|
||||
paths.append(item.filepath)
|
||||
return paths
|
||||
|
||||
def clear_list(self, lst: QListWidget):
|
||||
lst.clear()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# TRACK PLAYLIST PANEL
|
||||
@@ -574,18 +586,58 @@ class CartPlaylistPanel(BasePlaylistPanel):
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
class PlaylistWidget(QWidget):
|
||||
PLAYLIST_JSON_FILTER = "JSON Files (*.json)"
|
||||
|
||||
def __init__(self, engine, deck_widgets: dict,
|
||||
cart_slots: list, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
|
||||
# Expandable - no fixed width
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.setMinimumWidth(200)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
root.setSpacing(4)
|
||||
|
||||
# Toolbar
|
||||
bar = QHBoxLayout()
|
||||
bar.setSpacing(4)
|
||||
|
||||
self._save_btn = QPushButton("SAVE")
|
||||
self._save_btn.setFixedHeight(28)
|
||||
self._save_btn.setFont(QFont("Arial", 9, QFont.Bold))
|
||||
self._save_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2a4a2a;
|
||||
color: #aaaaaa;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
QPushButton:hover { background-color: #3a5a3a; }
|
||||
""")
|
||||
self._save_btn.clicked.connect(self._save_playlist)
|
||||
|
||||
self._load_btn = QPushButton("LOAD")
|
||||
self._load_btn.setFixedHeight(28)
|
||||
self._load_btn.setFont(QFont("Arial", 9, QFont.Bold))
|
||||
self._load_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #3a2a2a;
|
||||
color: #aaaaaa;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
QPushButton:hover { background-color: #4a3a3a; }
|
||||
""")
|
||||
self._load_btn.clicked.connect(self._load_playlist)
|
||||
|
||||
bar.addWidget(self._save_btn)
|
||||
bar.addWidget(self._load_btn)
|
||||
bar.addStretch()
|
||||
root.addLayout(bar)
|
||||
|
||||
splitter = QSplitter(Qt.Vertical)
|
||||
splitter.setStyleSheet("""
|
||||
@@ -603,3 +655,50 @@ class PlaylistWidget(QWidget):
|
||||
splitter.setSizes([600, 400])
|
||||
|
||||
root.addWidget(splitter)
|
||||
|
||||
def _save_playlist(self):
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Save Playlist",
|
||||
os.path.expanduser("~/playlist.json"),
|
||||
self.PLAYLIST_JSON_FILTER
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
data = {
|
||||
"version": 1,
|
||||
"tracks": self.track_panel.get_filepaths(self.track_panel.playlist),
|
||||
"cart_tracks": self.cart_panel.get_filepaths(self.cart_panel.playlist),
|
||||
}
|
||||
with open(path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def _load_playlist(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Load Playlist",
|
||||
os.path.expanduser("~"),
|
||||
self.PLAYLIST_JSON_FILTER
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
tracks = data.get("tracks", [])
|
||||
cart = data.get("cart_tracks", [])
|
||||
|
||||
self.track_panel.playlist.clear()
|
||||
for fp in tracks:
|
||||
if os.path.exists(fp):
|
||||
self.track_panel._add_item_to(self.track_panel.playlist, fp)
|
||||
self.track_panel._update_count(self.track_panel.playlist,
|
||||
self.track_panel.count_label)
|
||||
|
||||
self.cart_panel.playlist.clear()
|
||||
for fp in cart:
|
||||
if os.path.exists(fp):
|
||||
self.cart_panel._add_item_to(self.cart_panel.playlist, fp)
|
||||
self.cart_panel._update_count(self.cart_panel.playlist,
|
||||
self.cart_panel.count_label)
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
PySide6
|
||||
numpy
|
||||
soundfile
|
||||
requests
|
||||
pyloudnorm
|
||||
mutagen
|
||||
musicbrainzngs
|
||||
python-rtmidi
|
||||
|
||||
+65
-9
@@ -7,7 +7,7 @@ from datetime import datetime
|
||||
|
||||
|
||||
THEMES = [
|
||||
# Original 4
|
||||
# Dark themes
|
||||
{
|
||||
'name': 'GREEN',
|
||||
'accent': '#00ff41',
|
||||
@@ -16,6 +16,7 @@ THEMES = [
|
||||
'bg2': '#111111',
|
||||
'border': '#1a3a1a',
|
||||
'text': '#cccccc',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'AMBER',
|
||||
@@ -25,6 +26,7 @@ THEMES = [
|
||||
'bg2': '#111100',
|
||||
'border': '#3a3000',
|
||||
'text': '#ddddcc',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'BLUE',
|
||||
@@ -34,6 +36,7 @@ THEMES = [
|
||||
'bg2': '#001122',
|
||||
'border': '#001a3a',
|
||||
'text': '#ccd8dd',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'RED',
|
||||
@@ -43,8 +46,8 @@ THEMES = [
|
||||
'bg2': '#110000',
|
||||
'border': '#3a0000',
|
||||
'text': '#ddcccc',
|
||||
'is_light': False,
|
||||
},
|
||||
# 10 New Bold Themes
|
||||
{
|
||||
'name': 'PURPLE',
|
||||
'accent': '#cc00ff',
|
||||
@@ -53,6 +56,7 @@ THEMES = [
|
||||
'bg2': '#1a001a',
|
||||
'border': '#3a003a',
|
||||
'text': '#ddccdd',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'CYAN',
|
||||
@@ -62,6 +66,7 @@ THEMES = [
|
||||
'bg2': '#001a1a',
|
||||
'border': '#003a3a',
|
||||
'text': '#ccdddd',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'ORANGE',
|
||||
@@ -71,6 +76,7 @@ THEMES = [
|
||||
'bg2': '#1a1100',
|
||||
'border': '#3a2200',
|
||||
'text': '#ddccbb',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'PINK',
|
||||
@@ -80,6 +86,7 @@ THEMES = [
|
||||
'bg2': '#1a000a',
|
||||
'border': '#3a0015',
|
||||
'text': '#ddccdd',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'LIME',
|
||||
@@ -89,6 +96,7 @@ THEMES = [
|
||||
'bg2': '#111a00',
|
||||
'border': '#223a00',
|
||||
'text': '#ddddcc',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'GOLD',
|
||||
@@ -98,6 +106,7 @@ THEMES = [
|
||||
'bg2': '#1a1a0a',
|
||||
'border': '#3a3a15',
|
||||
'text': '#ddddbb',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'VIOLET',
|
||||
@@ -107,6 +116,7 @@ THEMES = [
|
||||
'bg2': '#0a0015',
|
||||
'border': '#15002a',
|
||||
'text': '#ccbbdd',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'TURQUOISE',
|
||||
@@ -116,6 +126,7 @@ THEMES = [
|
||||
'bg2': '#001a15',
|
||||
'border': '#00382d',
|
||||
'text': '#ccddda',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'CRIMSON',
|
||||
@@ -125,6 +136,7 @@ THEMES = [
|
||||
'bg2': '#1a0005',
|
||||
'border': '#3a000a',
|
||||
'text': '#ddcccc',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'CHARTREUSE',
|
||||
@@ -134,6 +146,48 @@ THEMES = [
|
||||
'bg2': '#0a1a00',
|
||||
'border': '#153a00',
|
||||
'text': '#ddddcc',
|
||||
'is_light': False,
|
||||
},
|
||||
# Light themes
|
||||
{
|
||||
'name': 'LIGHT BLUE',
|
||||
'accent': '#0066cc',
|
||||
'accent_dim': '#0044aa',
|
||||
'bg': '#f5f5f5',
|
||||
'bg2': '#e8e8e8',
|
||||
'border': '#bbbbbb',
|
||||
'text': '#222222',
|
||||
'is_light': True,
|
||||
},
|
||||
{
|
||||
'name': 'LIGHT GREEN',
|
||||
'accent': '#00aa44',
|
||||
'accent_dim': '#008833',
|
||||
'bg': '#f0f5f0',
|
||||
'bg2': '#e0e8e0',
|
||||
'border': '#aaccaa',
|
||||
'text': '#112211',
|
||||
'is_light': True,
|
||||
},
|
||||
{
|
||||
'name': 'LIGHT AMBER',
|
||||
'accent': '#cc8800',
|
||||
'accent_dim': '#aa6600',
|
||||
'bg': '#f5f3f0',
|
||||
'bg2': '#e8e5e0',
|
||||
'border': '#ccbb99',
|
||||
'text': '#221100',
|
||||
'is_light': True,
|
||||
},
|
||||
{
|
||||
'name': 'LIGHT PURPLE',
|
||||
'accent': '#8844cc',
|
||||
'accent_dim': '#6622aa',
|
||||
'bg': '#f3f0f5',
|
||||
'bg2': '#e5e0e8',
|
||||
'border': '#ccbbdd',
|
||||
'text': '#221122',
|
||||
'is_light': True,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -197,9 +251,6 @@ class TopBarWidget(QFrame):
|
||||
# Date
|
||||
self.date_label = QLabel("")
|
||||
self.date_label.setFont(QFont("Arial", 20, QFont.Bold))
|
||||
self.date_label.setStyleSheet(
|
||||
"color: #aaaaaa; background: transparent; border: none;"
|
||||
)
|
||||
self.date_label.setAlignment(Qt.AlignCenter | Qt.AlignVCenter)
|
||||
layout.addWidget(self.date_label)
|
||||
|
||||
@@ -210,7 +261,7 @@ class TopBarWidget(QFrame):
|
||||
self.clock_label.setFont(QFont("Courier New", 42, QFont.Bold))
|
||||
self.clock_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.clock_label.setMinimumWidth(220)
|
||||
self._style_clock()
|
||||
self._style_clock_and_date()
|
||||
layout.addWidget(self.clock_label)
|
||||
|
||||
def _style_theme_btn(self):
|
||||
@@ -224,24 +275,29 @@ class TopBarWidget(QFrame):
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {theme['accent']};
|
||||
color: #000000;
|
||||
color: {'#ffffff' if not theme['is_light'] else '#000000'};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background-color: {theme['accent_dim']};
|
||||
}}
|
||||
""")
|
||||
|
||||
def _style_clock(self):
|
||||
def _style_clock_and_date(self):
|
||||
theme = THEMES[self._theme_index]
|
||||
self.clock_label.setStyleSheet(
|
||||
f"color: {theme['accent']}; background: transparent; border: none;"
|
||||
)
|
||||
# Date color adapts to light/dark
|
||||
date_color = '#555555' if theme['is_light'] else '#aaaaaa'
|
||||
self.date_label.setStyleSheet(
|
||||
f"color: {date_color}; background: transparent; border: none;"
|
||||
)
|
||||
|
||||
def _cycle_theme(self):
|
||||
self._theme_index = (self._theme_index + 1) % len(THEMES)
|
||||
self._apply_bar_style()
|
||||
self._style_theme_btn()
|
||||
self._style_clock()
|
||||
self._style_clock_and_date()
|
||||
self.theme_changed.emit(THEMES[self._theme_index])
|
||||
|
||||
def _update(self):
|
||||
|
||||
Reference in New Issue
Block a user