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
+219 -72
View File
@@ -3,6 +3,10 @@
import csv
import os
import threading
import time
from datetime import datetime
LIVE_CSV_PATH = os.path.expanduser("~/.gti_radiostudio/live_songs.csv")
import musicbrainzngs
from mutagen import File as MutagenFile
@@ -12,8 +16,8 @@ from PySide6.QtWidgets import (
QLineEdit, QFormLayout, QDialogButtonBox,
QFileDialog
)
from PySide6.QtCore import Qt, Signal, QSize
from PySide6.QtGui import QFont
from PySide6.QtCore import Qt, QTimer, Signal, QSize
from PySide6.QtGui import QFont, QColor
from icons import icon_plus, icon_delete, icon_export
@@ -63,12 +67,12 @@ def _lookup_musicbrainz(artist: str, title: str) -> dict:
class HistoryEntry(QFrame):
deleted = Signal(object)
def __init__(self, index: int, filepath: str, metadata: dict,
def __init__(self, index: int, entry_type: str, entry_data: tuple,
parent=None):
super().__init__(parent)
self._index = index
self._filepath = filepath
self._metadata = metadata
self._index = index
self._entry_type = entry_type
self._entry_data = entry_data
self.setFrameStyle(QFrame.Box)
self.setStyleSheet("""
@@ -86,29 +90,17 @@ class HistoryEntry(QFrame):
text_col = QVBoxLayout()
text_col.setSpacing(2)
artist = metadata.get('artist') or 'Unknown Artist'
title = metadata.get('title') or self._filename(filepath)
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]
if parts:
sub = QLabel(" " + " · ".join(parts))
sub.setFont(QFont("Arial", 9))
sub.setStyleSheet(
"color: #888888; background: transparent; border: none;"
)
sub.setWordWrap(True)
text_col.addWidget(sub)
if entry_type == 'song':
filepath, metadata = entry_data
artist = metadata.get('artist') or 'Unknown Artist'
title = metadata.get('title') or self._filename(filepath)
self._render_song(text_col, artist, title, metadata)
elif entry_type == 'cart':
track_name, = entry_data
self._render_cart(text_col, track_name)
elif entry_type == 'talk':
duration, = entry_data
self._render_talk(text_col, duration)
root.addLayout(text_col, stretch=1)
@@ -134,13 +126,57 @@ class HistoryEntry(QFrame):
name = os.path.basename(filepath)
return os.path.splitext(name)[0]
def _render_song(self, layout, artist: str, title: str, metadata: dict):
top = QLabel(f"[SONG] {artist}{title}")
top.setFont(QFont("Arial", 11, QFont.Bold))
top.setStyleSheet("color: #00ff41; background: transparent; border: none;")
top.setWordWrap(True)
layout.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]
if parts:
sub = QLabel(" " + " · ".join(parts))
sub.setFont(QFont("Arial", 9))
sub.setStyleSheet("color: #888888; background: transparent; border: none;")
sub.setWordWrap(True)
layout.addWidget(sub)
def _render_cart(self, layout, track_name: str):
top = QLabel(f"[CART] {track_name}")
top.setFont(QFont("Arial", 11, QFont.Bold))
top.setStyleSheet("color: #ffaa00; background: transparent; border: none;")
top.setWordWrap(True)
layout.addWidget(top)
def _render_talk(self, layout, duration: float):
dur_str = f"{int(duration//60):02d}:{int(duration%60):02d}"
top = QLabel(f"[TALK] {dur_str}")
top.setFont(QFont("Arial", 11, QFont.Bold))
top.setStyleSheet("color: #4488ff; background: transparent; border: none;")
top.setWordWrap(True)
layout.addWidget(top)
@property
def filepath(self) -> str:
return self._filepath
if self._entry_type == 'song':
return self._entry_data[0]
return ""
@property
def metadata(self) -> dict:
return self._metadata
if self._entry_type == 'song':
return self._entry_data[1]
return {}
@property
def entry_type(self) -> str:
return self._entry_type
@property
def entry_data(self) -> tuple:
return self._entry_data
class AddEntryDialog(QDialog):
@@ -205,14 +241,13 @@ class AddEntryDialog(QDialog):
}
class HistoryWidget(QFrame):
_metadata_ready = Signal(str, dict)
class ShowHistoryWidget(QFrame):
def __init__(self, parent=None):
super().__init__(parent)
self._history = []
self._pending = {}
self._entry_index = 0
self._session_start = None
self._mic_was_on = False
self.setFrameStyle(QFrame.Box | QFrame.Raised)
self.setStyleSheet("""
@@ -226,13 +261,19 @@ class HistoryWidget(QFrame):
self._build_ui()
self._metadata_ready.connect(self._on_metadata_ready)
self._mic_timer = QTimer(self)
self._mic_timer.setInterval(500)
self._mic_timer.timeout.connect(self._poll_mic)
self._mic_timer.start()
_metadata_ready = Signal(str, dict)
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(6)
# Header
header = QLabel("PLAY HISTORY")
header = QLabel("SHOW LOG")
header.setFont(QFont("Arial", 11, QFont.Bold))
header.setStyleSheet(
"color: #336633; background: transparent; border: none; padding: 2px;"
@@ -240,7 +281,6 @@ class HistoryWidget(QFrame):
header.setAlignment(Qt.AlignCenter)
root.addWidget(header)
# Toolbar
bar = QHBoxLayout()
bar.setSpacing(4)
@@ -262,10 +302,25 @@ class HistoryWidget(QFrame):
self._add_btn.clicked.connect(self._add_manual_entry)
bar.addWidget(self._add_btn)
self._reset_btn = QPushButton("RESET LIVE")
self._reset_btn.setFixedHeight(28)
self._reset_btn.setFont(QFont("Arial", 9, QFont.Bold))
self._reset_btn.setStyleSheet("""
QPushButton {
background-color: #3a1a1a;
color: #ff6666;
border: 1px solid #553333;
border-radius: 3px;
padding: 0 10px;
}
QPushButton:hover { background-color: #5a2a2a; }
""")
self._reset_btn.clicked.connect(self._reset_live_csv)
bar.addWidget(self._reset_btn)
bar.addStretch()
root.addLayout(bar)
# Scroll area
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
@@ -295,7 +350,6 @@ class HistoryWidget(QFrame):
self._scroll.setWidget(self._list_widget)
root.addWidget(self._scroll)
# Export bar
export_bar = QHBoxLayout()
export_bar.setSpacing(4)
@@ -347,13 +401,28 @@ class HistoryWidget(QFrame):
}}
""")
def add_track(self, filepath: str):
def _elapsed_str(self, ts: datetime = None) -> str:
now = datetime.now()
if self._session_start is None:
self._session_start = now
delta = (ts or now) - self._session_start
total_s = int(delta.total_seconds())
return f"{total_s//3600:02d}:{(total_s%3600)//60:02d}:{total_s%60:02d}"
def add_song(self, filepath: str):
idx = self._entry_index + 1
metadata = _read_file_tags(filepath)
self._history.append((filepath, metadata))
self._history.append(('song', (filepath, metadata)))
self._entry_index = idx
entry = self._insert_entry(idx, filepath, metadata)
ts = self._elapsed_str()
self._insert_entry(idx, 'song', (filepath, metadata), ts=ts)
self._append_live_csv(
metadata.get('artist') or 'Unknown Artist',
metadata.get('title') or (os.path.splitext(os.path.basename(filepath))[0] if filepath else 'Unknown'),
metadata.get('album') or '',
metadata.get('year') or '',
)
if not metadata.get('artist') or not metadata.get('album'):
self._pending[filepath] = idx
@@ -363,6 +432,38 @@ class HistoryWidget(QFrame):
daemon=True
).start()
def add_cart(self, track_name: str):
idx = self._entry_index + 1
self._history.append(('cart', (track_name,)))
self._entry_index = idx
ts = self._elapsed_str()
self._insert_entry(idx, 'cart', (track_name,), ts)
def add_talk(self, duration: float):
if duration < 1:
return
idx = self._entry_index + 1
self._history.append(('talk', (duration,)))
self._entry_index = idx
ts = self._elapsed_str()
self._insert_entry(idx, 'talk', (duration,), ts)
def _poll_mic(self):
if not hasattr(self, '_engine'):
return
channels = self._engine.mixer['channels']
any_on = any(ch.mic_on for ch in channels[:2])
if any_on and not self._mic_was_on:
pass
elif not any_on and self._mic_was_on:
dur = self._engine.get_mic_duration()
if dur > 1:
self.add_talk(dur)
self._mic_was_on = any_on
def set_engine(self, engine):
self._engine = engine
def _add_manual_entry(self):
dlg = AddEntryDialog(self)
if dlg.exec() != QDialog.Accepted:
@@ -379,13 +480,16 @@ class HistoryWidget(QFrame):
'album': None,
'year': None,
}
self._history.append(("", metadata))
self._history.append(('song', ("", metadata)))
self._entry_index = idx
self._insert_entry(idx, "", metadata)
ts = self._elapsed_str()
self._insert_entry(idx, 'song', ("", metadata), ts)
def _insert_entry(self, index: int, filepath: str,
metadata: dict) -> HistoryEntry:
entry = HistoryEntry(index, filepath, metadata)
_pending = {}
def _insert_entry(self, index: int, entry_type: str,
entry_data: tuple, ts: str = "") -> HistoryEntry:
entry = HistoryEntry(index, entry_type, entry_data)
entry.deleted.connect(self._delete_entry)
self._list_layout.insertWidget(0, entry)
self._scroll.verticalScrollBar().setValue(0)
@@ -397,6 +501,8 @@ class HistoryWidget(QFrame):
self._history.pop(idx)
entry.deleteLater()
self._rebuild_list()
if entry.entry_type == 'song':
self._rewrite_live_csv()
def _enrich_metadata(self, filepath: str, existing: dict):
artist = existing.get('artist')
@@ -414,9 +520,9 @@ class HistoryWidget(QFrame):
self._metadata_ready.emit(filepath, merged)
def _on_metadata_ready(self, filepath: str, metadata: dict):
for i, (fp, _) in enumerate(self._history):
if fp == filepath:
self._history[i] = (fp, metadata)
for i, (entry_type, entry_data) in enumerate(self._history):
if entry_type == 'song' and entry_data[0] == filepath:
self._history[i] = ('song', (filepath, metadata))
break
self._rebuild_list()
@@ -426,52 +532,93 @@ class HistoryWidget(QFrame):
if item.widget():
item.widget().deleteLater()
for i, (filepath, metadata) in enumerate(reversed(self._history)):
for i, (entry_type, entry_data) in enumerate(reversed(self._history)):
index = len(self._history) - i
entry = HistoryEntry(index, filepath, metadata)
entry = HistoryEntry(index, entry_type, entry_data)
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
for entry_type, entry_data in reversed(self._history):
if entry_type == 'song':
fp, md = entry_data
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 ('SONG', f"{artist}{title}", album, year)
elif entry_type == 'cart':
track_name, = entry_data
yield ('CART', track_name, '', '')
elif entry_type == 'talk':
duration, = entry_data
dur_str = f"{int(duration//60):02d}:{int(duration%60):02d}"
yield ('TALK', dur_str, '', '')
def _append_live_csv(self, artist: str, title: str,
album: str, year: str):
os.makedirs(os.path.dirname(LIVE_CSV_PATH), exist_ok=True)
write_header = not os.path.exists(LIVE_CSV_PATH)
with open(LIVE_CSV_PATH, 'a', newline='') as f:
w = csv.writer(f)
if write_header:
w.writerow(["Artist", "Title", "Album", "Year"])
w.writerow([artist, title, album, year])
def _rewrite_live_csv(self):
if not os.path.exists(LIVE_CSV_PATH):
return
with open(LIVE_CSV_PATH, 'w', newline='') as f:
w = csv.writer(f)
w.writerow(["Artist", "Title", "Album", "Year"])
for entry_type, entry_data in self._history:
if entry_type == 'song':
fp, md = entry_data
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 ''
w.writerow([artist, title, album, year])
def _reset_live_csv(self):
try:
if os.path.exists(LIVE_CSV_PATH):
os.remove(LIVE_CSV_PATH)
except Exception:
pass
def _export_txt(self):
path, _ = QFileDialog.getSaveFileName(
self, "Export History (TXT)",
os.path.expanduser("~/play_history.txt"),
self, "Export Show Log",
os.path.expanduser("~/show_log.txt"),
"Text Files (*.txt)"
)
if not path:
return
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(path, 'w') as f:
for idx, artist, title, album, year in self._iter_entries():
f.write(f"{idx}. {artist}{title}")
f.write(f"=== GTI Radio Studio — Show Log ===\n")
f.write(f"Exported: {now}\n\n")
for tag, text, album, year in self._iter_entries():
f.write(f"[{tag}] {text}")
if album or year:
parts = [p for p in [album,
year[:4] if year and len(year) >= 4 else year]
if p]
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"),
self, "Export Show Log (CSV)",
os.path.expanduser("~/show_log.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])
w.writerow(["#", "Type", "Text", "Album", "Year"])
for i, (tag, text, album, year) in enumerate(self._iter_entries()):
w.writerow([i + 1, tag, text, album, year])