Overhaul: 5-task implementation

- Remove TURN decks (3/4), clean up MIDI, layout
- Single cart player with 3-item autoload queue + continue toggle
- Event logging (SessionLogger), mic tracking, large mic timer widget
- LUFS metering moved from external ports to internal master mix
- Master output WAV recording with top-bar REC toggle
- Music mix output (ch 3-8 only, no mics)
- Preview bar for playlist auditioning
- Merged show log with [SONG][CART][TALK] prefixes
- Live CSV writer for OBS song display (~/live_songs.csv)
- Misc fixes: preview race condition, datetime import for recording
This commit is contained in:
2026-07-17 20:44:15 +10:00
parent da8240a9e2
commit 0277089b2d
13 changed files with 1015 additions and 326 deletions
+223 -47
View File
@@ -1,23 +1,21 @@
# playlist_widget.py
import json
import os
from datetime import datetime, timedelta
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFileDialog, QFrame, QSizePolicy,
QListWidget, QListWidgetItem, QAbstractItemView,
QMenu, QSplitter
QMenu, QSplitter, QTextEdit, QLineEdit
)
from PySide6.QtCore import Qt, QTimer, QSize
from PySide6.QtCore import Qt, QTimer, QSize, Signal
from PySide6.QtGui import QFont, QColor, QAction
import json
import os
from icons import icon_save, icon_load
# ─────────────────────────────────────────
# PLAYLIST ITEM
# ─────────────────────────────────────────
class PlaylistItem(QListWidgetItem):
def __init__(self, filepath: str):
self.filepath = filepath
@@ -29,10 +27,6 @@ class PlaylistItem(QListWidgetItem):
self.setToolTip(filepath)
# ─────────────────────────────────────────
# HOUR DISPLAY + CLOCK
# ─────────────────────────────────────────
class HourDisplay(QFrame):
def __init__(self, parent=None):
super().__init__(parent)
@@ -78,10 +72,6 @@ class HourDisplay(QFrame):
self.hour_label.setText(f"HOUR {now.strftime('%H:00')}")
# ─────────────────────────────────────────
# DECK SELECTOR
# ─────────────────────────────────────────
class DeckSelector(QFrame):
def __init__(self, parent=None):
super().__init__(parent)
@@ -152,6 +142,11 @@ class DeckSelector(QFrame):
return self._selected
# ─────────────────────────────────────────
# SHOW LOG PANEL
# ─────────────────────────────────────────
# ─────────────────────────────────────────
# BASE PLAYLIST PANEL
# ─────────────────────────────────────────
@@ -275,7 +270,131 @@ class BasePlaylistPanel(QWidget):
paths.append(item.filepath)
return paths
# ─────────────────────────────────────────
# PREVIEW BAR
# ─────────────────────────────────────────
class PreviewBar(QFrame):
def __init__(self, engine, parent=None):
super().__init__(parent)
self.engine = engine
self.setFixedHeight(40)
self.setStyleSheet("""
QFrame {
background-color: #0a0a12;
border: 1px solid #222;
border-radius: 3px;
}
""")
layout = QHBoxLayout(self)
layout.setContentsMargins(6, 2, 6, 2)
layout.setSpacing(6)
self.track_label = QLabel("NO TRACK")
self.track_label.setFont(QFont("Courier New", 9, QFont.Bold))
self.track_label.setStyleSheet("color: #4488ff; background: transparent; border: none;")
self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
layout.addWidget(self.track_label, stretch=1)
self.time_label = QLabel("--:--")
self.time_label.setFont(QFont("Courier New", 12, QFont.Bold))
self.time_label.setStyleSheet("color: #4488ff; background: transparent; border: none;")
layout.addWidget(self.time_label)
self.btn_play = QPushButton("\u25B6")
self.btn_play.setFixedSize(30, 26)
self.btn_play.setFont(QFont("Arial", 10, QFont.Bold))
self.btn_play.setStyleSheet("""
QPushButton {
background-color: #1a3a5a; color: #4488ff;
border: 1px solid #4488ff; border-radius: 3px;
}
QPushButton:hover { background-color: #2a4a6a; }
QPushButton:checked { background-color: #1a5a3a; color: #00ff41; border-color: #00ff41; }
""")
self.btn_play.setCheckable(True)
layout.addWidget(self.btn_play)
self.btn_forward = QPushButton("+10s")
self.btn_forward.setFixedSize(40, 26)
self.btn_forward.setFont(QFont("Arial", 8, QFont.Bold))
self.btn_forward.setStyleSheet("""
QPushButton {
background-color: #2a1a0a; color: #aa6622;
border: 1px solid #553311; border-radius: 3px;
}
QPushButton:hover { background-color: #3a2a1a; }
""")
layout.addWidget(self.btn_forward)
self.btn_play.clicked.connect(self._toggle_preview)
self.btn_forward.clicked.connect(self._skip_forward)
self.timer = QTimer(self)
self.timer.setInterval(100)
self.timer.timeout.connect(self._refresh)
self._playing = False
self._pending_play = False
def set_playlist(self, playlist):
self._playlist = playlist
def _selected_item(self):
item = self._playlist.currentItem()
if isinstance(item, PlaylistItem):
return item
return None
def _toggle_preview(self):
item = self._selected_item()
if item is None:
self.btn_play.setChecked(False)
return
deck = self.engine.decks['preview']
if self._playing:
deck.stop()
self._playing = False
self._pending_play = False
self.btn_play.setChecked(False)
self.track_label.setText("NO TRACK")
self.time_label.setText("--:--")
else:
self.engine.load_track('preview', item.filepath)
self.track_label.setText(item.text()[:30])
self.time_label.setText("00:00")
self._pending_play = True
def _skip_forward(self):
if not self._playing:
return
self.engine.decks['preview'].skip_seconds(10)
def _refresh(self):
deck = self.engine.decks['preview']
with deck.lock:
playing = deck.playing
track = deck.track
pos = deck.position
if self._pending_play and track is not None:
self._pending_play = False
deck.play()
self._playing = True
if playing and track:
s = pos / track.sr
self.time_label.setText(f"{int(s//60):02d}:{int(s%60):02d}")
if pos >= track.num_samples - 1:
self._playing = False
self.btn_play.setChecked(False)
self.track_label.setText("NO TRACK")
self.time_label.setText("--:--")
elif not playing and self._playing:
self._playing = False
self.btn_play.setChecked(False)
# ─────────────────────────────────────────
@@ -310,15 +429,35 @@ class TrackPlaylistPanel(BasePlaylistPanel):
header.setAlignment(Qt.AlignCenter)
root.addWidget(header)
self.preview_bar = PreviewBar(self.engine)
root.addWidget(self.preview_bar)
self.hour_display = HourDisplay()
root.addWidget(self.hour_display)
self.deck_selector = DeckSelector()
root.addWidget(self.deck_selector)
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("search...")
self.search_box.setFont(QFont("Courier New", 9))
self.search_box.setStyleSheet("""
QLineEdit {
background-color: #0d0d0d;
color: #00ff41;
border: 1px solid #333;
border-radius: 3px;
padding: 3px 6px;
}
QLineEdit:focus { border-color: #00ff41; }
""")
self.search_box.textChanged.connect(self._filter_list)
root.addWidget(self.search_box)
self.playlist = self._make_list()
self.playlist.itemDoubleClicked.connect(self._send_to_deck)
self.playlist.customContextMenuRequested.connect(self._context_menu)
self.preview_bar.set_playlist(self.playlist)
root.addWidget(self.playlist)
row1 = QHBoxLayout()
@@ -364,6 +503,13 @@ class TrackPlaylistPanel(BasePlaylistPanel):
self.btn_remove.clicked.connect(self._remove_selected)
self.btn_clear.clicked.connect(self._clear_all)
def _filter_list(self):
query = self.search_box.text().lower()
for i in range(self.playlist.count()):
item = self.playlist.item(i)
if isinstance(item, PlaylistItem):
item.setHidden(bool(query and query not in item.filename.lower()))
def _send_to_deck(self, item=None):
if item is None:
item = self.playlist.currentItem()
@@ -456,10 +602,10 @@ class TrackPlaylistPanel(BasePlaylistPanel):
# ─────────────────────────────────────────
class CartPlaylistPanel(BasePlaylistPanel):
def __init__(self, engine, cart_slots: list, parent=None):
def __init__(self, engine, cart_player, parent=None):
super().__init__(parent)
self.engine = engine
self.cart_slots = cart_slots
self.engine = engine
self.cart_player = cart_player
self._build_ui()
def _build_ui(self):
@@ -483,8 +629,24 @@ class CartPlaylistPanel(BasePlaylistPanel):
header.setAlignment(Qt.AlignCenter)
root.addWidget(header)
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("search...")
self.search_box.setFont(QFont("Courier New", 9))
self.search_box.setStyleSheet("""
QLineEdit {
background-color: #0d0d0d;
color: #ffaa00;
border: 1px solid #333;
border-radius: 3px;
padding: 3px 6px;
}
QLineEdit:focus { border-color: #ffaa00; }
""")
self.search_box.textChanged.connect(self._filter_list)
root.addWidget(self.search_box)
self.playlist = self._make_list()
self.playlist.itemDoubleClicked.connect(self._send_to_next_free_cart)
self.playlist.itemDoubleClicked.connect(self._send_to_cart)
self.playlist.customContextMenuRequested.connect(self._context_menu)
root.addWidget(self.playlist)
@@ -498,11 +660,19 @@ class CartPlaylistPanel(BasePlaylistPanel):
row2 = QHBoxLayout()
row2.setSpacing(4)
self.btn_send_cart = self._make_btn("→ LOAD CART", "#3a5a1a")
self.btn_send_queue = self._make_btn("→ QUEUE", "#4a3a00")
row2.addWidget(self.btn_send_cart)
row2.addWidget(self.btn_send_queue)
root.addLayout(row2)
row3 = QHBoxLayout()
row3.setSpacing(4)
self.btn_remove = self._make_btn("REMOVE", "#5a1a1a")
self.btn_clear = self._make_btn("CLEAR ALL", "#3a1a1a")
row2.addWidget(self.btn_remove)
row2.addWidget(self.btn_clear)
root.addLayout(row2)
row3.addWidget(self.btn_remove)
row3.addWidget(self.btn_clear)
root.addLayout(row3)
self.count_label = QLabel("0 tracks")
self.count_label.setFont(QFont("Courier New", 9))
@@ -518,27 +688,33 @@ class CartPlaylistPanel(BasePlaylistPanel):
self.btn_add_folder.clicked.connect(
lambda: self._add_folder_to(self.playlist)
)
self.btn_send_cart.clicked.connect(self._send_to_cart)
self.btn_send_queue.clicked.connect(self._send_to_cart_queue)
self.btn_remove.clicked.connect(self._remove_selected)
self.btn_clear.clicked.connect(self._clear_all)
def _send_to_cart(self, item: PlaylistItem, cart_index: int):
if not isinstance(item, PlaylistItem):
return
slot = self.cart_slots[cart_index]
slot.load_track(item.filepath)
self._highlight(item, active=True)
def _filter_list(self):
query = self.search_box.text().lower()
for i in range(self.playlist.count()):
item = self.playlist.item(i)
if isinstance(item, PlaylistItem):
item.setHidden(bool(query and query not in item.filename.lower()))
def _send_to_next_free_cart(self, item=None):
def _send_to_cart(self, item=None):
if item is None:
item = self.playlist.currentItem()
if not isinstance(item, PlaylistItem):
return
for i in range(4):
cart_key = f"cart{i + 1}"
if self.engine.decks[cart_key].track is None:
self._send_to_cart(item, i)
return
self._send_to_cart(item, 0)
self.cart_player.load_track(item.filepath)
self._highlight(item, active=True)
def _send_to_cart_queue(self, item=None):
if item is None:
item = self.playlist.currentItem()
if not isinstance(item, PlaylistItem):
return
self.engine.add_to_cart_queue(item.filepath)
self._highlight(item, queued=True)
def _remove_selected(self):
row = self.playlist.currentRow()
@@ -565,12 +741,13 @@ class CartPlaylistPanel(BasePlaylistPanel):
QMenu::item:selected { background-color: #1a3a5a; }
""")
for i in range(4):
action = QAction(f"Load → Cart {i + 1}", self)
action.triggered.connect(
lambda checked, idx=i: self._send_to_cart(item, idx)
)
menu.addAction(action)
a1 = QAction("Load → Cart", self)
a1.triggered.connect(lambda: self._send_to_cart(item))
menu.addAction(a1)
a2 = QAction("Queue → Cart", self)
a2.triggered.connect(lambda: self._send_to_cart_queue(item))
menu.addAction(a2)
menu.addSeparator()
ar = QAction("Remove", self)
@@ -590,7 +767,7 @@ class PlaylistWidget(QWidget):
PLAYLIST_JSON_FILTER = "JSON Files (*.json)"
def __init__(self, engine, deck_widgets: dict,
cart_slots: list, parent=None):
cart_player, parent=None):
super().__init__(parent)
self.engine = engine
@@ -601,7 +778,6 @@ class PlaylistWidget(QWidget):
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(4)
# Toolbar
bar = QHBoxLayout()
bar.setSpacing(4)
@@ -653,11 +829,11 @@ class PlaylistWidget(QWidget):
""")
self.track_panel = TrackPlaylistPanel(engine, deck_widgets)
self.cart_panel = CartPlaylistPanel(engine, cart_slots)
self.cart_panel = CartPlaylistPanel(engine, cart_player)
splitter.addWidget(self.track_panel)
splitter.addWidget(self.cart_panel)
splitter.setSizes([600, 400])
splitter.setSizes([400, 400])
root.addWidget(splitter)