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
+1
View File
@@ -1,3 +1,4 @@
__pycache__/ __pycache__/
*.pyc *.pyc
venv/ venv/
obs_overlays/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

+164 -34
View File
@@ -6,7 +6,9 @@ import jack
import threading import threading
import subprocess import subprocess
import os import os
import time
import pyloudnorm as pyln import pyloudnorm as pyln
from datetime import datetime
LUFS_TARGET = -14.0 LUFS_TARGET = -14.0
@@ -195,12 +197,12 @@ class AudioEngine:
self.client.outports.register("cart_L"), self.client.outports.register("cart_L"),
self.client.outports.register("cart_R"), self.client.outports.register("cart_R"),
), ),
'preview': (
self.client.outports.register("preview_out_L"),
self.client.outports.register("preview_out_R"),
),
} }
# LUFS metering input (external processed signal)
self.lufs_in_L = self.client.inports.register("lufs_in_L")
self.lufs_in_R = self.client.inports.register("lufs_in_R")
# Mixer channels: 8 stereo input ports # Mixer channels: 8 stereo input ports
self.mixer = { self.mixer = {
'channels': [], 'channels': [],
@@ -230,6 +232,12 @@ class AudioEngine:
self.client.outports.register("studio_out_R"), self.client.outports.register("studio_out_R"),
) )
# Music mix output (aux channels 3-8 only, no mics)
self.music_mix_out = (
self.client.outports.register("music_mix_L"),
self.client.outports.register("music_mix_R"),
)
# PFL output ports # PFL output ports
self.pfl_out = ( self.pfl_out = (
self.client.outports.register("pfl_out_L"), self.client.outports.register("pfl_out_L"),
@@ -240,13 +248,22 @@ class AudioEngine:
self.decks = { self.decks = {
'deck1': DeckState(), 'deck1': DeckState(),
'deck2': DeckState(), 'deck2': DeckState(),
'deck3': DeckState(),
'deck4': DeckState(),
'cart1': DeckState(), 'cart1': DeckState(),
'cart2': DeckState(), 'preview': DeckState(),
'cart3': DeckState(),
'cart4': DeckState(),
} }
self.cart_queue = []
self.cart_continue = False
self.logger = None
self.mic_active_start = None
self._mic_duration = 0.0
self._mic_was_on = False
self.recording = False
self._record_buf = []
self._record_fade_samples = int(self.sr * 0.01)
self._record_fade_in_count = 0
self._record_fade_out_pending = False
self.client.set_process_callback(self._process) self.client.set_process_callback(self._process)
self.client.activate() self.client.activate()
@@ -319,33 +336,40 @@ class AudioEngine:
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames) self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames) self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
# ── Mix carts ─────────────────────────────────────────────────── # ── Preview deck ─────────────────────────────────────────────
self._fill_port(*self.ports['preview'], self.decks['preview'], frames)
# ── Mix cart ───────────────────────────────────────────────────
buf_L = self.ports['cart'][0].get_array() buf_L = self.ports['cart'][0].get_array()
buf_R = self.ports['cart'][1].get_array() buf_R = self.ports['cart'][1].get_array()
buf_L.fill(0.0) buf_L.fill(0.0)
buf_R.fill(0.0) buf_R.fill(0.0)
for cart_key in ('cart1', 'cart2', 'cart3', 'cart4'): cart_deck = self.decks['cart1']
deck = self.decks[cart_key] with cart_deck.lock:
with deck.lock: playing = cart_deck.playing
playing = deck.playing track = cart_deck.track
track = deck.track pos = cart_deck.position
pos = deck.position if playing and track is not None:
if not playing or track is None:
continue
end = min(pos + frames, track.num_samples) end = min(pos + frames, track.num_samples)
n = end - pos n = end - pos
if n <= 0: if n > 0:
with deck.lock: buf_L[:n] = track.audio[0, pos:end]
deck.playing = False buf_R[:n] = track.audio[1, pos:end]
continue with cart_deck.lock:
buf_L[:n] += track.audio[0, pos:end] cart_deck.position += max(n, 0)
buf_R[:n] += track.audio[1, pos:end] if cart_deck.position >= track.num_samples:
with deck.lock: cart_deck.playing = False
deck.position += n cart_deck.position = track.num_samples - 1
if deck.position >= track.num_samples: if self.cart_continue and self.cart_queue:
deck.playing = False next_track = self.cart_queue.pop(0)
deck.position = track.num_samples - 1 cart_deck.track = next_track
cart_deck.position = 0
cart_deck.playing = True
if self.logger:
self.logger.log_cart_trigger(next_track.filename)
elif not self.cart_queue:
self.cart_continue = False
# ── Mixer ─────────────────────────────────────────────────────── # ── Mixer ───────────────────────────────────────────────────────
dt = frames / self.sr dt = frames / self.sr
@@ -372,11 +396,33 @@ class AudioEngine:
pfl_buf_L.fill(0.0) pfl_buf_L.fill(0.0)
pfl_buf_R.fill(0.0) pfl_buf_R.fill(0.0)
music_buf_L = self.music_mix_out[0].get_array()
music_buf_R = self.music_mix_out[1].get_array()
music_buf_L.fill(0.0)
music_buf_R.fill(0.0)
channels = self.mixer['channels'] channels = self.mixer['channels']
any_solo = any(ch.solo for ch in channels) any_solo = any(ch.solo for ch in channels)
any_mic_on = any(ch.mic_on for ch in channels[:2]) any_mic_on = any(ch.mic_on for ch in channels[:2])
any_pfl = any(ch.pfl for ch in channels) any_pfl = any(ch.pfl for ch in channels)
now = time.time()
if any_mic_on and not self._mic_was_on:
self.mic_active_start = now
if self.logger:
active_idx = next((ch.index for ch in channels[:2] if ch.mic_on), 0)
self.logger.log_mic_on(active_idx)
self._mic_duration = 0.0
elif not any_mic_on and self._mic_was_on and self.mic_active_start is not None:
duration = now - self.mic_active_start
self._mic_duration = 0.0
self.mic_active_start = None
if self.logger:
self.logger.log_mic_off(0, duration)
elif any_mic_on and self.mic_active_start is not None:
self._mic_duration = now - self.mic_active_start
self._mic_was_on = any_mic_on
for ch in channels: for ch in channels:
in_L = ch.in_port_L.get_array() in_L = ch.in_port_L.get_array()
in_R = ch.in_port_R.get_array() in_R = ch.in_port_R.get_array()
@@ -405,6 +451,10 @@ class AudioEngine:
headphone_buf_L[:] += in_L[:] * factor headphone_buf_L[:] += in_L[:] * factor
headphone_buf_R[:] += in_R[:] * factor headphone_buf_R[:] += in_R[:] * factor
if ch.index >= 2:
music_buf_L[:] += in_L[:] * factor
music_buf_R[:] += in_R[:] * factor
peak = max(np.max(np.abs(in_L)), np.max(np.abs(in_R))) * factor peak = max(np.max(np.abs(in_L)), np.max(np.abs(in_R))) * factor
ch._vu_smooth = ch._vu_smooth * vu_decay + peak * (1 - vu_decay) ch._vu_smooth = ch._vu_smooth * vu_decay + peak * (1 - vu_decay)
ch.vu_peak = ch._vu_smooth ch.vu_peak = ch._vu_smooth
@@ -440,14 +490,12 @@ class AudioEngine:
master_buf_L[:] *= vol master_buf_L[:] *= vol
master_buf_R[:] *= vol master_buf_R[:] *= vol
# ── LUFS metering from external processing input ──────────────── # ── LUFS metering from master mix ──────────────────────────
master._lufs_accum += frames master._lufs_accum += frames
if master._lufs_accum >= 512: if master._lufs_accum >= 512:
lufs_dt = master._lufs_accum / self.sr lufs_dt = 512.0 / self.sr
master._lufs_accum -= 512 master._lufs_accum -= 512
lufs_L = self.lufs_in_L.get_array() ms = (np.mean(master_buf_L[:] ** 2) + np.mean(master_buf_R[:] ** 2)) / 2.0
lufs_R = self.lufs_in_R.get_array()
ms = (np.mean(lufs_L ** 2) + np.mean(lufs_R ** 2)) / 2.0
if ms < 1e-12: if ms < 1e-12:
master.lufs_current = -70.0 master.lufs_current = -70.0
else: else:
@@ -457,6 +505,28 @@ class AudioEngine:
master.lufs_short = master.lufs_short * decay_short + master.lufs_current * (1 - decay_short) master.lufs_short = master.lufs_short * decay_short + master.lufs_current * (1 - decay_short)
master.lufs_long = master.lufs_long * decay_long + master.lufs_current * (1 - decay_long) master.lufs_long = master.lufs_long * decay_long + master.lufs_current * (1 - decay_long)
# ── Recording ─────────────────────────────────────────────────
if self.recording or self._record_fade_out_pending:
chunk_L = master_buf_L[:].copy()
chunk_R = master_buf_R[:].copy()
if self._record_fade_in_count < self._record_fade_samples:
n = min(frames, self._record_fade_samples - self._record_fade_in_count)
ramp = np.linspace(0, 1, n)
chunk_L[:n] *= ramp
chunk_R[:n] *= ramp
self._record_fade_in_count += n
if self._record_fade_out_pending:
fade_total = self._record_fade_samples
ramp = np.linspace(1, 0, frames)
chunk_L *= ramp
chunk_R *= ramp
self._record_buf.append(np.vstack([chunk_L, chunk_R]))
self._finish_recording()
else:
self._record_buf.append(np.vstack([chunk_L, chunk_R]))
def load_track(self, deck_key: str, filepath: str): def load_track(self, deck_key: str, filepath: str):
def _load(): def _load():
audio = load_audio(filepath, self.sr) audio = load_audio(filepath, self.sr)
@@ -479,6 +549,66 @@ class AudioEngine:
self.decks[deck_key].load_queue(track) self.decks[deck_key].load_queue(track)
threading.Thread(target=_load, daemon=True).start() threading.Thread(target=_load, daemon=True).start()
def add_to_cart_queue(self, filepath: str):
if len(self.cart_queue) >= 3:
return
def _load():
audio = load_audio(filepath, self.sr)
audio = normalize_lufs(audio, self.sr)
track = Track(filepath, audio, self.sr)
self.cart_queue.append(track)
threading.Thread(target=_load, daemon=True).start()
def clear_cart_queue(self):
self.cart_queue.clear()
def log_cart_trigger(self, track_name: str):
if self.logger:
self.logger.log_cart_trigger(track_name)
def get_mic_duration(self) -> float:
if self.mic_active_start is not None:
return time.time() - self.mic_active_start
return self._mic_duration
def start_recording(self):
self.recording = True
self._record_buf = []
self._record_fade_in_count = 0
self._record_fade_out_pending = False
if self.logger:
self.logger._write_raw({
"event": "recording_start",
"timestamp": time.time(),
"iso": datetime.now().isoformat(),
})
def stop_recording(self):
if not self.recording:
return
self.recording = False
self._record_fade_out_pending = True
def _finish_recording(self):
self._record_fade_out_pending = False
if not self._record_buf:
return
filename = datetime.now().strftime("RadioPanel_%Y-%m-%d_%H%M%S.wav")
filepath = os.path.join(os.path.expanduser("~"), filename)
try:
full = np.concatenate(self._record_buf, axis=1)
sf.write(filepath, full.T, self.sr, subtype='PCM_16')
if self.logger:
self.logger._write_raw({
"event": "recording_stop",
"timestamp": time.time(),
"iso": datetime.now().isoformat(),
"filepath": filepath,
"duration_sec": round(full.shape[1] / self.sr, 3),
})
except Exception:
pass
def shutdown(self): def shutdown(self):
self.client.deactivate() self.client.deactivate()
self.client.close() self.client.close()
+196 -127
View File
@@ -2,35 +2,27 @@
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFrame, QSizePolicy, QGridLayout QPushButton, QFrame, QSizePolicy, QCheckBox, QScrollArea
) )
from PySide6.QtCore import Qt, QTimer, QSize from PySide6.QtCore import Qt, QTimer, QSize, Signal
from PySide6.QtGui import QFont from PySide6.QtGui import QFont
import os import os
from icons import icon_play, icon_pause, icon_eject from icons import icon_play, icon_pause, icon_eject, icon_delete
class CartSlot(QFrame): class CartPlayerWidget(QFrame):
def __init__(self, cart_key: str, slot_number: int, engine, parent=None): """Single active cart player with visible 3-item autoload queue."""
cart_triggered = Signal(str)
def __init__(self, engine, parent=None):
super().__init__(parent) super().__init__(parent)
self.cart_key = cart_key
self.slot_number = slot_number
self.engine = engine self.engine = engine
self._playing_icon = False self._playing_icon = False
self.setFrameStyle(QFrame.Box | QFrame.Raised) self.setFrameStyle(QFrame.Box | QFrame.Raised)
self.setLineWidth(1)
self.setStyleSheet("""
QFrame {
background-color: #1e1e1e;
border: 1px solid #444;
border-radius: 5px;
}
""")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setFixedHeight(80) self.setFixedHeight(160)
self._build_ui() self._build_ui()
self._connect_signals() self._connect_signals()
@@ -39,23 +31,78 @@ class CartSlot(QFrame):
self.timer.timeout.connect(self._refresh_display) self.timer.timeout.connect(self._refresh_display)
self.timer.start() self.timer.start()
def _build_ui(self): self.queue_timer = QTimer(self)
root = QHBoxLayout(self) self.queue_timer.setInterval(500)
root.setContentsMargins(6, 4, 6, 4) self.queue_timer.timeout.connect(self._refresh_queue)
root.setSpacing(6) self.queue_timer.start()
badge = QLabel(str(self.slot_number)) def _build_ui(self):
badge.setFixedSize(22, 22) self.setStyleSheet("""
QFrame {
background-color: #111;
border: 2px solid #cc3300;
border-radius: 6px;
}
""")
root = QHBoxLayout(self)
root.setContentsMargins(10, 8, 10, 8)
root.setSpacing(10)
# ── Left: Cart player box ──────────────────────────────────
player_frame = QFrame()
player_frame.setStyleSheet("""
QFrame {
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 5px;
}
""")
player_frame.setFixedWidth(320)
player_layout = QVBoxLayout(player_frame)
player_layout.setContentsMargins(10, 8, 10, 8)
player_layout.setSpacing(6)
header_row = QHBoxLayout()
badge = QLabel("1")
badge.setFixedSize(26, 26)
badge.setAlignment(Qt.AlignCenter) badge.setAlignment(Qt.AlignCenter)
badge.setFont(QFont("Arial", 10, QFont.Bold)) badge.setFont(QFont("Arial", 12, QFont.Bold))
badge.setStyleSheet(""" badge.setStyleSheet("""
background-color: #cc3300; background-color: #cc3300;
color: white; color: white;
border-radius: 14px; border-radius: 15px;
border: none; border: none;
""") """)
root.addWidget(badge, alignment=Qt.AlignVCenter) header_row.addWidget(badge)
cart_label = QLabel("CART")
cart_label.setFont(QFont("Arial", 12, QFont.Bold))
cart_label.setStyleSheet("color: #cc3300; background: transparent; border: none;")
header_row.addWidget(cart_label)
self.continue_toggle = QCheckBox("CONTINUE")
self.continue_toggle.setFont(QFont("Arial", 9, QFont.Bold))
self.continue_toggle.setStyleSheet("""
QCheckBox {
color: #888; background: transparent; border: none; spacing: 4px;
}
QCheckBox::indicator {
width: 16px; height: 16px;
border: 1px solid #555; border-radius: 3px;
background-color: #1a1a1a;
}
QCheckBox::indicator:checked {
background-color: #cc3300; border-color: #ff6644;
}
QCheckBox::indicator:hover { border-color: #888; }
""")
self.continue_toggle.setToolTip("Auto-play next track from queue after current finishes")
self.continue_toggle.stateChanged.connect(self._on_continue_toggled)
header_row.addWidget(self.continue_toggle)
header_row.addStretch()
player_layout.addLayout(header_row)
# LCD display
self.lcd_frame = QFrame() self.lcd_frame = QFrame()
self.lcd_frame.setStyleSheet(""" self.lcd_frame.setStyleSheet("""
QFrame { QFrame {
@@ -64,32 +111,26 @@ class CartSlot(QFrame):
border-radius: 3px; border-radius: 3px;
} }
""") """)
self.lcd_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) lcd_layout = QHBoxLayout(self.lcd_frame)
lcd_layout.setContentsMargins(8, 4, 8, 4)
lcd_layout.setSpacing(6)
lcd_layout = QVBoxLayout(self.lcd_frame) lcd_left = QVBoxLayout()
lcd_layout.setContentsMargins(4, 2, 4, 2) lcd_left.setSpacing(2)
lcd_layout.setSpacing(1)
self.track_label = QLabel("EMPTY") self.track_label = QLabel("EMPTY")
self.track_label.setFont(QFont("Courier New", 8, QFont.Bold)) self.track_label.setFont(QFont("Courier New", 9, QFont.Bold))
self.track_label.setStyleSheet( self.track_label.setStyleSheet("color: #00ff41; background: transparent; border: none;")
"color: #00ff41; background: transparent; border: none;" self.track_label.setMinimumWidth(180)
)
self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
time_row = QHBoxLayout() time_row = QHBoxLayout()
self.elapsed_label = QLabel("00:00") self.elapsed_label = QLabel("00:00")
self.elapsed_label.setFont(QFont("Courier New", 11, QFont.Bold)) self.elapsed_label.setFont(QFont("Courier New", 18, QFont.Bold))
self.elapsed_label.setStyleSheet( self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent; border: none;")
"color: #00ff41; background: transparent; border: none;"
)
self.remaining_label = QLabel("-00:00") self.remaining_label = QLabel("-00:00")
self.remaining_label.setFont(QFont("Courier New", 8)) self.remaining_label.setFont(QFont("Courier New", 10, QFont.Bold))
self.remaining_label.setStyleSheet( self.remaining_label.setStyleSheet("color: #00cc33; background: transparent; border: none;")
"color: #00cc33; background: transparent; border: none;"
)
self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
time_row.addWidget(self.elapsed_label) time_row.addWidget(self.elapsed_label)
@@ -98,22 +139,19 @@ class CartSlot(QFrame):
self.status_label = QLabel("STOPPED") self.status_label = QLabel("STOPPED")
self.status_label.setFont(QFont("Courier New", 8)) self.status_label.setFont(QFont("Courier New", 8))
self.status_label.setStyleSheet( self.status_label.setStyleSheet("color: #008822; background: transparent; border: none;")
"color: #008822; background: transparent; border: none;"
)
lcd_layout.addWidget(self.track_label) lcd_left.addWidget(self.track_label)
lcd_layout.addLayout(time_row) lcd_left.addLayout(time_row)
lcd_layout.addWidget(self.status_label) lcd_left.addWidget(self.status_label)
lcd_layout.addLayout(lcd_left)
root.addWidget(self.lcd_frame)
btn_col = QVBoxLayout() btn_col = QVBoxLayout()
btn_col.setSpacing(3) btn_col.setSpacing(3)
self.btn_play = QPushButton() self.btn_play = QPushButton()
self.btn_play.setFixedSize(50, 32) self.btn_play.setFixedSize(56, 36)
self.btn_play.setIconSize(QSize(18, 18)) self.btn_play.setIconSize(QSize(22, 22))
self.btn_play.setIcon(icon_play("#00ff41")) self.btn_play.setIcon(icon_play("#00ff41"))
self.btn_play.setStyleSheet(""" self.btn_play.setStyleSheet("""
QPushButton { QPushButton {
@@ -126,8 +164,8 @@ class CartSlot(QFrame):
""") """)
self.btn_eject = QPushButton() self.btn_eject = QPushButton()
self.btn_eject.setFixedSize(50, 24) self.btn_eject.setFixedSize(56, 28)
self.btn_eject.setIconSize(QSize(14, 14)) self.btn_eject.setIconSize(QSize(16, 16))
self.btn_eject.setIcon(icon_eject("#ff4444")) self.btn_eject.setIcon(icon_eject("#ff4444"))
self.btn_eject.setStyleSheet(""" self.btn_eject.setStyleSheet("""
QPushButton { QPushButton {
@@ -141,36 +179,85 @@ class CartSlot(QFrame):
btn_col.addWidget(self.btn_play) btn_col.addWidget(self.btn_play)
btn_col.addWidget(self.btn_eject) btn_col.addWidget(self.btn_eject)
lcd_layout.addLayout(btn_col)
root.addLayout(btn_col) player_layout.addWidget(self.lcd_frame)
root.addWidget(player_frame)
def apply_theme(self, theme: dict): # ── Right: Queue panel ──────────────────────────────────────
self.lcd_frame.setStyleSheet(f""" queue_frame = QFrame()
QFrame {{ queue_frame.setStyleSheet("""
background-color: {theme['bg']}; QFrame {
border: 1px solid {theme['border']}; background-color: #111;
border-radius: 3px; border: 1px solid #2a2010;
}} border-radius: 5px;
}
""") """)
queue_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
queue_layout = QVBoxLayout(queue_frame)
queue_layout.setContentsMargins(8, 6, 8, 6)
queue_layout.setSpacing(2)
queue_header = QHBoxLayout()
ql = QLabel("AUTOLOAD QUEUE")
ql.setFont(QFont("Arial", 9, QFont.Bold))
ql.setStyleSheet("color: #ffaa00; background: transparent; border: none;")
queue_header.addWidget(ql)
queue_header.addStretch()
self.queue_count = QLabel("0/3")
self.queue_count.setFont(QFont("Courier New", 9, QFont.Bold))
self.queue_count.setStyleSheet("color: #555; background: transparent; border: none;")
queue_header.addWidget(self.queue_count)
clear_btn = QPushButton("CLR")
clear_btn.setFixedSize(40, 20)
clear_btn.setFont(QFont("Arial", 7, QFont.Bold))
clear_btn.setStyleSheet("""
QPushButton {
background-color: #3a1a1a;
color: #ff6666;
border: 1px solid #555;
border-radius: 3px;
}
QPushButton:hover { background-color: #5a2a2a; }
""")
clear_btn.clicked.connect(self.engine.clear_cart_queue)
queue_header.addWidget(clear_btn)
queue_layout.addLayout(queue_header)
self.queue_items = []
for i in range(3):
item = QLabel(f"")
item.setFont(QFont("Courier New", 9))
item.setStyleSheet("color: #555; background: transparent; border: none; padding: 2px 4px;")
item.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
queue_layout.addWidget(item)
self.queue_items.append(item)
queue_layout.addStretch()
root.addWidget(queue_frame)
def _connect_signals(self): def _connect_signals(self):
self.btn_play.clicked.connect(self._toggle_play) self.btn_play.clicked.connect(self._toggle_play)
self.btn_eject.clicked.connect(self._eject) self.btn_eject.clicked.connect(self._eject)
def _on_continue_toggled(self, state):
self.engine.cart_continue = bool(state)
def load_track(self, filepath: str): def load_track(self, filepath: str):
self.engine.load_cart_track(self.cart_key, filepath) self.engine.load_cart_track("cart1", filepath)
name = os.path.basename(filepath) name = os.path.basename(filepath)
short = name[:28] + "..." if len(name) > 28 else name short = name[:32] + "..." if len(name) > 32 else name
self.track_label.setText(short) self.track_label.setText(short)
self.status_label.setText("LOADED") self.status_label.setText("LOADED")
self.elapsed_label.setText("00:00") self.elapsed_label.setText("00:00")
self.remaining_label.setText("-00:00") self.remaining_label.setText("-00:00")
def toggle(self): def toggle(self):
deck = self.engine.decks[self.cart_key] deck = self.engine.decks["cart1"]
if deck.track is None: if deck.track is None:
return return
if deck.playing: if deck.playing:
deck.stop() deck.stop()
deck.go_to_start() deck.go_to_start()
@@ -179,42 +266,36 @@ class CartSlot(QFrame):
else: else:
deck.play() deck.play()
self._set_ui_playing() self._set_ui_playing()
self.engine.log_cart_trigger(deck.track.filename)
self.cart_triggered.emit(deck.track.filename)
def _set_ui_playing(self): def _set_ui_playing(self):
self.btn_play.setIcon(icon_pause("#ffffff")) self.btn_play.setIcon(icon_pause("#ffffff"))
self._playing_icon = True self._playing_icon = True
self.btn_play.setStyleSheet(""" self.btn_play.setStyleSheet("""
QPushButton { QPushButton {
background-color: #00aa33; background-color: #00aa33; border: 1px solid #00ff41; border-radius: 3px;
border: 1px solid #00ff41;
border-radius: 3px;
} }
QPushButton:hover { background-color: #00cc44; } QPushButton:hover { background-color: #00cc44; }
""") """)
self.status_label.setText("PLAYING") self.status_label.setText("PLAYING")
self.elapsed_label.setStyleSheet( self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent; border: none;")
"color: #00ff41; background: transparent; border: none;"
)
def _set_ui_stopped(self): def _set_ui_stopped(self):
self.btn_play.setIcon(icon_play("#00ff41")) self.btn_play.setIcon(icon_play("#00ff41"))
self._playing_icon = False self._playing_icon = False
self.btn_play.setStyleSheet(""" self.btn_play.setStyleSheet("""
QPushButton { QPushButton {
background-color: #1a6b3a; background-color: #1a6b3a; border: 1px solid #555; border-radius: 3px;
border: 1px solid #555;
border-radius: 3px;
} }
QPushButton:hover { background-color: #2a7b4a; } QPushButton:hover { background-color: #2a7b4a; }
""") """)
self.elapsed_label.setText("00:00") self.elapsed_label.setText("00:00")
self.remaining_label.setText("-00:00") self.remaining_label.setText("-00:00")
self.elapsed_label.setStyleSheet( self.elapsed_label.setStyleSheet("color: #00aa2a; background: transparent; border: none;")
"color: #00aa2a; background: transparent; border: none;"
)
def _toggle_play(self): def _toggle_play(self):
deck = self.engine.decks[self.cart_key] deck = self.engine.decks["cart1"]
if deck.track is None: if deck.track is None:
return return
if deck.playing: if deck.playing:
@@ -224,16 +305,18 @@ class CartSlot(QFrame):
else: else:
deck.play() deck.play()
self._set_ui_playing() self._set_ui_playing()
self.engine.log_cart_trigger(deck.track.filename)
self.cart_triggered.emit(deck.track.filename)
def _eject(self): def _eject(self):
deck = self.engine.decks[self.cart_key] deck = self.engine.decks["cart1"]
deck.stop() deck.stop()
self._set_ui_stopped() self._set_ui_stopped()
self.track_label.setText("EMPTY") self.track_label.setText("EMPTY")
self.status_label.setText("STOPPED") self.status_label.setText("STOPPED")
def _refresh_display(self): def _refresh_display(self):
deck = self.engine.decks[self.cart_key] deck = self.engine.decks["cart1"]
track = deck.track track = deck.track
playing = deck.playing playing = deck.playing
@@ -245,51 +328,37 @@ class CartSlot(QFrame):
self.elapsed_label.setText(deck.get_elapsed()) self.elapsed_label.setText(deck.get_elapsed())
self.remaining_label.setText(deck.get_remaining()) self.remaining_label.setText(deck.get_remaining())
def _refresh_queue(self):
q = self.engine.cart_queue
qlen = len(q)
self.queue_count.setText(f"{qlen}/3")
if qlen == 0:
self.queue_count.setStyleSheet("color: #555; background: transparent; border: none;")
elif qlen < 3:
self.queue_count.setStyleSheet("color: #ffaa00; background: transparent; border: none;")
else:
self.queue_count.setStyleSheet("color: #ff4444; background: transparent; border: none;")
class CartBankWidget(QWidget): for i in range(3):
def __init__(self, engine, parent=None): if i < qlen:
super().__init__(parent) name = q[i].filename
self.engine = engine name = name[:35] + "..." if len(name) > 35 else name
self._build_ui() self.queue_items[i].setText(f" {i+1}. {name}")
self.queue_items[i].setStyleSheet("color: #ffaa00; background: transparent; border: none; padding: 2px 4px;")
else:
self.queue_items[i].setText("")
self.queue_items[i].setStyleSheet("color: #333; background: transparent; border: none; padding: 2px 4px;")
def _build_ui(self): if not self.engine.cart_continue and self.continue_toggle.isChecked():
self.setStyleSheet(""" self.continue_toggle.blockSignals(True)
QWidget { self.continue_toggle.setChecked(False)
background-color: #111; self.continue_toggle.blockSignals(False)
border: 2px solid #cc3300;
border-radius: 6px;
}
""")
root = QVBoxLayout(self)
root.setContentsMargins(8, 6, 8, 6)
root.setSpacing(6)
header = QLabel("CARTS")
header.setFont(QFont("Arial", 11, QFont.Bold))
header.setStyleSheet("""
color: #cc3300;
background: transparent;
border: none;
padding: 2px;
""")
header.setAlignment(Qt.AlignCenter)
root.addWidget(header)
grid = QGridLayout()
grid.setSpacing(6)
self.slots = []
for i in range(4):
cart_key = f"cart{i + 1}"
slot = CartSlot(cart_key, i + 1, self.engine)
row = i // 2
col = i % 2
grid.addWidget(slot, row, col)
self.slots.append(slot)
root.addLayout(grid)
def apply_theme(self, theme: dict): def apply_theme(self, theme: dict):
for slot in self.slots: self.lcd_frame.setStyleSheet(f"""
slot.apply_theme(theme) QFrame {{
background-color: {theme['bg']};
border: 1px solid {theme['border']};
border-radius: 3px;
}}
""")
+72
View File
@@ -0,0 +1,72 @@
# event_logger.py
import json
import os
import threading
import time
from datetime import datetime
LOG_DIR = os.path.expanduser("~/.gti_radiostudio/sessions")
class SessionLogger:
def __init__(self):
self._lock = threading.Lock()
self._log_path = None
self._start_new_session()
def _start_new_session(self):
os.makedirs(LOG_DIR, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
self._log_path = os.path.join(LOG_DIR, f"{ts}.jsonl")
self._write_raw({
"event": "session_start",
"timestamp": time.time(),
"iso": datetime.now().isoformat(),
})
def _write_raw(self, entry: dict):
with self._lock:
try:
with open(self._log_path, 'a') as f:
f.write(json.dumps(entry) + "\n")
except Exception:
pass
def log_cart_trigger(self, track_name: str):
self._write_raw({
"event": "cart_trigger",
"timestamp": time.time(),
"iso": datetime.now().isoformat(),
"track": track_name,
})
def log_mic_on(self, channel: int):
self._write_raw({
"event": "mic_on",
"timestamp": time.time(),
"iso": datetime.now().isoformat(),
"channel": channel,
})
def log_mic_off(self, channel: int, duration_sec: float):
self._write_raw({
"event": "mic_off",
"timestamp": time.time(),
"iso": datetime.now().isoformat(),
"channel": channel,
"duration_sec": round(duration_sec, 3),
})
def log_deck_play(self, deck_key: str, track_name: str):
self._write_raw({
"event": "deck_play",
"timestamp": time.time(),
"iso": datetime.now().isoformat(),
"deck": deck_key,
"track": track_name,
})
@property
def log_path(self):
return self._log_path
+212 -65
View File
@@ -3,6 +3,10 @@
import csv import csv
import os import os
import threading import threading
import time
from datetime import datetime
LIVE_CSV_PATH = os.path.expanduser("~/.gti_radiostudio/live_songs.csv")
import musicbrainzngs import musicbrainzngs
from mutagen import File as MutagenFile from mutagen import File as MutagenFile
@@ -12,8 +16,8 @@ from PySide6.QtWidgets import (
QLineEdit, QFormLayout, QDialogButtonBox, QLineEdit, QFormLayout, QDialogButtonBox,
QFileDialog QFileDialog
) )
from PySide6.QtCore import Qt, Signal, QSize from PySide6.QtCore import Qt, QTimer, Signal, QSize
from PySide6.QtGui import QFont from PySide6.QtGui import QFont, QColor
from icons import icon_plus, icon_delete, icon_export from icons import icon_plus, icon_delete, icon_export
@@ -63,12 +67,12 @@ def _lookup_musicbrainz(artist: str, title: str) -> dict:
class HistoryEntry(QFrame): class HistoryEntry(QFrame):
deleted = Signal(object) 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): parent=None):
super().__init__(parent) super().__init__(parent)
self._index = index self._index = index
self._filepath = filepath self._entry_type = entry_type
self._metadata = metadata self._entry_data = entry_data
self.setFrameStyle(QFrame.Box) self.setFrameStyle(QFrame.Box)
self.setStyleSheet(""" self.setStyleSheet("""
@@ -86,29 +90,17 @@ class HistoryEntry(QFrame):
text_col = QVBoxLayout() text_col = QVBoxLayout()
text_col.setSpacing(2) text_col.setSpacing(2)
if entry_type == 'song':
filepath, metadata = entry_data
artist = metadata.get('artist') or 'Unknown Artist' artist = metadata.get('artist') or 'Unknown Artist'
title = metadata.get('title') or self._filename(filepath) title = metadata.get('title') or self._filename(filepath)
self._render_song(text_col, artist, title, metadata)
top = QLabel(f"{index}. {artist}{title}") elif entry_type == 'cart':
top.setFont(QFont("Arial", 11, QFont.Bold)) track_name, = entry_data
top.setStyleSheet( self._render_cart(text_col, track_name)
"color: #00ff41; background: transparent; border: none;" elif entry_type == 'talk':
) duration, = entry_data
top.setWordWrap(True) self._render_talk(text_col, duration)
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)
root.addLayout(text_col, stretch=1) root.addLayout(text_col, stretch=1)
@@ -134,13 +126,57 @@ class HistoryEntry(QFrame):
name = os.path.basename(filepath) name = os.path.basename(filepath)
return os.path.splitext(name)[0] 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 @property
def filepath(self) -> str: def filepath(self) -> str:
return self._filepath if self._entry_type == 'song':
return self._entry_data[0]
return ""
@property @property
def metadata(self) -> dict: 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): class AddEntryDialog(QDialog):
@@ -205,14 +241,13 @@ class AddEntryDialog(QDialog):
} }
class HistoryWidget(QFrame): class ShowHistoryWidget(QFrame):
_metadata_ready = Signal(str, dict)
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._history = [] self._history = []
self._pending = {}
self._entry_index = 0 self._entry_index = 0
self._session_start = None
self._mic_was_on = False
self.setFrameStyle(QFrame.Box | QFrame.Raised) self.setFrameStyle(QFrame.Box | QFrame.Raised)
self.setStyleSheet(""" self.setStyleSheet("""
@@ -226,13 +261,19 @@ class HistoryWidget(QFrame):
self._build_ui() self._build_ui()
self._metadata_ready.connect(self._on_metadata_ready) 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): def _build_ui(self):
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(8, 8, 8, 8) root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(6) root.setSpacing(6)
# Header header = QLabel("SHOW LOG")
header = QLabel("PLAY HISTORY")
header.setFont(QFont("Arial", 11, QFont.Bold)) header.setFont(QFont("Arial", 11, QFont.Bold))
header.setStyleSheet( header.setStyleSheet(
"color: #336633; background: transparent; border: none; padding: 2px;" "color: #336633; background: transparent; border: none; padding: 2px;"
@@ -240,7 +281,6 @@ class HistoryWidget(QFrame):
header.setAlignment(Qt.AlignCenter) header.setAlignment(Qt.AlignCenter)
root.addWidget(header) root.addWidget(header)
# Toolbar
bar = QHBoxLayout() bar = QHBoxLayout()
bar.setSpacing(4) bar.setSpacing(4)
@@ -262,10 +302,25 @@ class HistoryWidget(QFrame):
self._add_btn.clicked.connect(self._add_manual_entry) self._add_btn.clicked.connect(self._add_manual_entry)
bar.addWidget(self._add_btn) 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() bar.addStretch()
root.addLayout(bar) root.addLayout(bar)
# Scroll area
self._scroll = QScrollArea() self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True) self._scroll.setWidgetResizable(True)
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
@@ -295,7 +350,6 @@ class HistoryWidget(QFrame):
self._scroll.setWidget(self._list_widget) self._scroll.setWidget(self._list_widget)
root.addWidget(self._scroll) root.addWidget(self._scroll)
# Export bar
export_bar = QHBoxLayout() export_bar = QHBoxLayout()
export_bar.setSpacing(4) 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 idx = self._entry_index + 1
metadata = _read_file_tags(filepath) metadata = _read_file_tags(filepath)
self._history.append((filepath, metadata)) self._history.append(('song', (filepath, metadata)))
self._entry_index = idx 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'): if not metadata.get('artist') or not metadata.get('album'):
self._pending[filepath] = idx self._pending[filepath] = idx
@@ -363,6 +432,38 @@ class HistoryWidget(QFrame):
daemon=True daemon=True
).start() ).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): def _add_manual_entry(self):
dlg = AddEntryDialog(self) dlg = AddEntryDialog(self)
if dlg.exec() != QDialog.Accepted: if dlg.exec() != QDialog.Accepted:
@@ -379,13 +480,16 @@ class HistoryWidget(QFrame):
'album': None, 'album': None,
'year': None, 'year': None,
} }
self._history.append(("", metadata)) self._history.append(('song', ("", metadata)))
self._entry_index = idx 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, _pending = {}
metadata: dict) -> HistoryEntry:
entry = HistoryEntry(index, filepath, metadata) 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) entry.deleted.connect(self._delete_entry)
self._list_layout.insertWidget(0, entry) self._list_layout.insertWidget(0, entry)
self._scroll.verticalScrollBar().setValue(0) self._scroll.verticalScrollBar().setValue(0)
@@ -397,6 +501,8 @@ class HistoryWidget(QFrame):
self._history.pop(idx) self._history.pop(idx)
entry.deleteLater() entry.deleteLater()
self._rebuild_list() self._rebuild_list()
if entry.entry_type == 'song':
self._rewrite_live_csv()
def _enrich_metadata(self, filepath: str, existing: dict): def _enrich_metadata(self, filepath: str, existing: dict):
artist = existing.get('artist') artist = existing.get('artist')
@@ -414,9 +520,9 @@ class HistoryWidget(QFrame):
self._metadata_ready.emit(filepath, merged) self._metadata_ready.emit(filepath, merged)
def _on_metadata_ready(self, filepath: str, metadata: dict): def _on_metadata_ready(self, filepath: str, metadata: dict):
for i, (fp, _) in enumerate(self._history): for i, (entry_type, entry_data) in enumerate(self._history):
if fp == filepath: if entry_type == 'song' and entry_data[0] == filepath:
self._history[i] = (fp, metadata) self._history[i] = ('song', (filepath, metadata))
break break
self._rebuild_list() self._rebuild_list()
@@ -426,52 +532,93 @@ class HistoryWidget(QFrame):
if item.widget(): if item.widget():
item.widget().deleteLater() 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 index = len(self._history) - i
entry = HistoryEntry(index, filepath, metadata) entry = HistoryEntry(index, entry_type, entry_data)
entry.deleted.connect(self._delete_entry) entry.deleted.connect(self._delete_entry)
self._list_layout.insertWidget( self._list_layout.insertWidget(
self._list_layout.count() - 1, entry self._list_layout.count() - 1, entry
) )
def _iter_entries(self): def _iter_entries(self):
for i, (fp, md) in enumerate(reversed(self._history)): for entry_type, entry_data in reversed(self._history):
idx = len(self._history) - i if entry_type == 'song':
fp, md = entry_data
artist = md.get('artist') or 'Unknown Artist' artist = md.get('artist') or 'Unknown Artist'
title = md.get('title') or (os.path.splitext(os.path.basename(fp))[0] if fp else 'Unknown') title = md.get('title') or (os.path.splitext(os.path.basename(fp))[0] if fp else 'Unknown')
album = md.get('album') or '' album = md.get('album') or ''
year = md.get('year') or '' year = md.get('year') or ''
yield idx, artist, title, album, year 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): def _export_txt(self):
path, _ = QFileDialog.getSaveFileName( path, _ = QFileDialog.getSaveFileName(
self, "Export History (TXT)", self, "Export Show Log",
os.path.expanduser("~/play_history.txt"), os.path.expanduser("~/show_log.txt"),
"Text Files (*.txt)" "Text Files (*.txt)"
) )
if not path: if not path:
return return
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(path, 'w') as f: with open(path, 'w') as f:
for idx, artist, title, album, year in self._iter_entries(): f.write(f"=== GTI Radio Studio — Show Log ===\n")
f.write(f"{idx}. {artist}{title}") 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: if album or year:
parts = [p for p in [album, parts = [p for p in [album, year[:4] if year and len(year) >= 4 else year] if p]
year[:4] if year and len(year) >= 4 else year]
if p]
if parts: if parts:
f.write(f"\n {' · '.join(parts)}") f.write(f"\n {' · '.join(parts)}")
f.write("\n") f.write("\n")
def _export_csv(self): def _export_csv(self):
path, _ = QFileDialog.getSaveFileName( path, _ = QFileDialog.getSaveFileName(
self, "Export History (CSV)", self, "Export Show Log (CSV)",
os.path.expanduser("~/play_history.csv"), os.path.expanduser("~/show_log.csv"),
"CSV Files (*.csv)" "CSV Files (*.csv)"
) )
if not path: if not path:
return return
with open(path, 'w', newline='') as f: with open(path, 'w', newline='') as f:
w = csv.writer(f) w = csv.writer(f)
w.writerow(["#", "Artist", "Title", "Album", "Year"]) w.writerow(["#", "Type", "Text", "Album", "Year"])
for idx, artist, title, album, year in self._iter_entries(): for i, (tag, text, album, year) in enumerate(self._iter_entries()):
w.writerow([idx, artist, title, album, year]) w.writerow([i + 1, tag, text, album, year])
+38 -29
View File
@@ -16,12 +16,13 @@ from PySide6.QtGui import QFont, QColor, QPalette
from audio_engine import AudioEngine from audio_engine import AudioEngine
from deck_widget import DeckWidget from deck_widget import DeckWidget
from cart_widget import CartBankWidget from cart_widget import CartPlayerWidget
from playlist_widget import PlaylistWidget from playlist_widget import PlaylistWidget
from midi_engine import MidiEngine from midi_engine import MidiEngine
from top_bar_widget import TopBarWidget, THEMES from top_bar_widget import TopBarWidget, THEMES
from history_widget import HistoryWidget from history_widget import ShowHistoryWidget
from mixer_widget import MixerWidget from mixer_widget import MixerWidget, MicTimerWidget, LUFSExpandedSection
from event_logger import SessionLogger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,6 +32,8 @@ class RadioPanelWindow(QMainWindow):
super().__init__() super().__init__()
self.engine = AudioEngine() self.engine = AudioEngine()
self.session_logger = SessionLogger()
self.engine.logger = self.session_logger
self.setWindowTitle(f"Getting To It v{__version__}") self.setWindowTitle(f"Getting To It v{__version__}")
self.setMinimumSize(1400, 860) self.setMinimumSize(1400, 860)
@@ -50,17 +53,19 @@ class RadioPanelWindow(QMainWindow):
self.top_bar.ratio_changed.connect(self._apply_ratio) self.top_bar.ratio_changed.connect(self._apply_ratio)
self.top_bar.save_session.connect(self._save_session) self.top_bar.save_session.connect(self._save_session)
self.top_bar.load_session.connect(self._load_session) self.top_bar.load_session.connect(self._load_session)
self.top_bar.recording_toggled.connect(self._on_recording_toggled)
outer.addWidget(self.top_bar) outer.addWidget(self.top_bar)
# ── Build widgets ───────────────────────────────────────────────── # ── Build widgets ─────────────────────────────────────────────────
self.deck1 = DeckWidget("deck1", "DECK_001", "#00b894", self.engine, mode='deck') self.deck1 = DeckWidget("deck1", "DECK_001", "#00b894", self.engine, mode='deck')
self.deck2 = DeckWidget("deck2", "DECK_002", "#6c5ce7", self.engine, mode='deck') self.deck2 = DeckWidget("deck2", "DECK_002", "#6c5ce7", self.engine, mode='deck')
self.deck3 = DeckWidget("deck3", "TURN_001", "#fdcb6e", self.engine, mode='turn')
self.deck4 = DeckWidget("deck4", "TURN_002", "#e17055", self.engine, mode='turn')
self.cart_bank = CartBankWidget(self.engine) self.cart_player = CartPlayerWidget(self.engine)
self.cart_bank.setFixedHeight(150) self.cart_player.setFixedHeight(160)
self.mic_timer = MicTimerWidget(self.engine)
self.lufs_section = LUFSExpandedSection(self.engine)
self.playlist_widget = PlaylistWidget( self.playlist_widget = PlaylistWidget(
engine=self.engine, engine=self.engine,
@@ -68,10 +73,11 @@ class RadioPanelWindow(QMainWindow):
"deck1": self.deck1, "deck1": self.deck1,
"deck2": self.deck2, "deck2": self.deck2,
}, },
cart_slots=self.cart_bank.slots cart_player=self.cart_player
) )
self.history_widget = HistoryWidget() self.history_widget = ShowHistoryWidget()
self.history_widget.set_engine(self.engine)
self.mixer = MixerWidget(self.engine) self.mixer = MixerWidget(self.engine)
@@ -79,7 +85,7 @@ class RadioPanelWindow(QMainWindow):
centre_col = QVBoxLayout() centre_col = QVBoxLayout()
centre_col.setSpacing(6) centre_col.setSpacing(6)
# Top: 4 decks in 2x2 grid + carts # Top: 2 decks + cart
deck_area = QWidget() deck_area = QWidget()
deck_area.setStyleSheet("background: transparent; border: none;") deck_area.setStyleSheet("background: transparent; border: none;")
deck_layout = QVBoxLayout(deck_area) deck_layout = QVBoxLayout(deck_area)
@@ -92,13 +98,15 @@ class RadioPanelWindow(QMainWindow):
deck_row1.addWidget(self.deck2) deck_row1.addWidget(self.deck2)
deck_layout.addLayout(deck_row1) deck_layout.addLayout(deck_row1)
deck_row2 = QHBoxLayout() cart_row = QHBoxLayout()
deck_row2.setSpacing(4) cart_row.setSpacing(4)
deck_row2.addWidget(self.deck3) cart_row.addWidget(self.cart_player)
deck_row2.addWidget(self.deck4) mic_lufs_row = QHBoxLayout()
deck_layout.addLayout(deck_row2) mic_lufs_row.setSpacing(4)
mic_lufs_row.addWidget(self.mic_timer, stretch=1)
deck_layout.addWidget(self.cart_bank) mic_lufs_row.addWidget(self.lufs_section, stretch=1)
cart_row.addLayout(mic_lufs_row)
deck_layout.addLayout(cart_row)
# Mixer fills remaining space below decks # Mixer fills remaining space below decks
centre_col.addWidget(deck_area) centre_col.addWidget(deck_area)
@@ -121,10 +129,9 @@ class RadioPanelWindow(QMainWindow):
outer.addWidget(self.splitter) outer.addWidget(self.splitter)
# ── Wire signals ───────────────────────────────────────────────── # ── Wire signals ─────────────────────────────────────────────────
self.deck1.track_started.connect(self.history_widget.add_track) self.deck1.track_started.connect(self.history_widget.add_song)
self.deck2.track_started.connect(self.history_widget.add_track) self.deck2.track_started.connect(self.history_widget.add_song)
self.deck3.track_started.connect(self.history_widget.add_track) self.cart_player.cart_triggered.connect(self.history_widget.add_cart)
self.deck4.track_started.connect(self.history_widget.add_track)
# ── MIDI ────────────────────────────────────────────────────────── # ── MIDI ──────────────────────────────────────────────────────────
self._setup_midi() self._setup_midi()
@@ -148,11 +155,11 @@ class RadioPanelWindow(QMainWindow):
self._style_splitter(theme) self._style_splitter(theme)
self.deck1.apply_theme(theme) self.deck1.apply_theme(theme)
self.deck2.apply_theme(theme) self.deck2.apply_theme(theme)
self.deck3.apply_theme(theme) self.cart_player.apply_theme(theme)
self.deck4.apply_theme(theme)
self.cart_bank.apply_theme(theme)
self.history_widget.apply_theme(theme) self.history_widget.apply_theme(theme)
self.mixer.apply_theme(theme) self.mixer.apply_theme(theme)
self.mic_timer.apply_theme(theme)
self.lufs_section.apply_theme(theme)
def _apply_ratio(self, w, h): def _apply_ratio(self, w, h):
self.resize(w, h) self.resize(w, h)
@@ -265,10 +272,6 @@ class RadioPanelWindow(QMainWindow):
self.deck1.toggle_play() self.deck1.toggle_play()
elif deck == 'deck2': elif deck == 'deck2':
self.deck2.toggle_play() self.deck2.toggle_play()
elif deck == 'deck3':
self.deck3.toggle_play()
elif deck == 'deck4':
self.deck4.toggle_play()
def _on_midi_cue(self, deck: str): def _on_midi_cue(self, deck: str):
if deck == 'deck1': if deck == 'deck1':
@@ -283,7 +286,7 @@ class RadioPanelWindow(QMainWindow):
self.deck2.scrub(delta) self.deck2.scrub(delta)
def _on_midi_cart(self, slot: int): def _on_midi_cart(self, slot: int):
self.cart_bank.slots[slot].toggle() self.cart_player.toggle()
def _on_midi_mixer_volume(self, channel: int, value: float): def _on_midi_mixer_volume(self, channel: int, value: float):
self.engine.set_channel_volume(channel, value) self.engine.set_channel_volume(channel, value)
@@ -320,6 +323,12 @@ class RadioPanelWindow(QMainWindow):
self.engine.shutdown() self.engine.shutdown()
event.accept() event.accept()
def _on_recording_toggled(self, checked):
if checked:
self.engine.start_recording()
else:
self.engine.stop_recording()
def main(): def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
+1 -1
View File
@@ -354,7 +354,7 @@ class MidiEngine(QObject):
def _handle_note_on(self, note: int): def _handle_note_on(self, note: int):
# Hardcoded Numark DJ2Go mapping (legacy) # Hardcoded Numark DJ2Go mapping (legacy)
NUMARK_CART = {0x44: 0, 0x43: 1, 0x46: 2, 0x45: 3} NUMARK_CART = {0x44: 0}
if note == 0x3B: if note == 0x3B:
self.deck_play_pause.emit('deck1') self.deck_play_pause.emit('deck1')
+66 -6
View File
@@ -410,8 +410,8 @@ class LUFSExpandedSection(QFrame):
self.engine = engine self.engine = engine
self._master = engine.mixer['master'] self._master = engine.mixer['master']
self.setFixedWidth(200) self.setMinimumWidth(160)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setStyleSheet(""" self.setStyleSheet("""
LUFSExpandedSection { LUFSExpandedSection {
background-color: #181a1e; background-color: #181a1e;
@@ -484,6 +484,70 @@ class LUFSExpandedSection(QFrame):
pass pass
class MicTimerWidget(QFrame):
def __init__(self, engine, parent=None):
super().__init__(parent)
self.engine = engine
self.setMinimumWidth(120)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setStyleSheet("""
MicTimerWidget {
background-color: #181a1e;
border: 1px solid #282a30;
border-radius: 6px;
}
""")
root = QVBoxLayout(self)
root.setContentsMargins(8, 12, 8, 12)
root.setSpacing(4)
header = QLabel("MIC ON TIME")
header.setFont(QFont("Segoe UI", 9, QFont.Bold))
header.setAlignment(Qt.AlignCenter)
header.setStyleSheet("color: #556670; background: transparent; border: none;")
root.addWidget(header)
self.duration_label = QLabel("00:00")
self.duration_label.setFont(QFont("Courier New", 28, QFont.Bold))
self.duration_label.setAlignment(Qt.AlignCenter)
self.duration_label.setStyleSheet("color: #00cc66; background: transparent; border: none;")
root.addWidget(self.duration_label, stretch=1)
self.status_label = QLabel("MIC OFF")
self.status_label.setFont(QFont("Segoe UI", 10, QFont.Bold))
self.status_label.setAlignment(Qt.AlignCenter)
self.status_label.setStyleSheet("color: #444; background: transparent; border: none;")
root.addWidget(self.status_label)
self.timer = QTimer(self)
self.timer.setInterval(100)
self.timer.timeout.connect(self._refresh)
self.timer.start()
def _refresh(self):
dur = self.engine.get_mic_duration()
channels = self.engine.mixer['channels']
any_on = any(ch.mic_on for ch in channels[:2])
if any_on:
mins = int(dur // 60)
secs = int(dur % 60)
self.duration_label.setText(f"{mins:02d}:{secs:02d}")
self.duration_label.setStyleSheet("color: #00ff41; background: transparent; border: none;")
self.status_label.setText("MIC LIVE")
self.status_label.setStyleSheet("color: #ff4444; font-weight: bold; background: transparent; border: none;")
else:
self.duration_label.setText("00:00")
self.duration_label.setStyleSheet("color: #333; background: transparent; border: none;")
self.status_label.setText("MIC OFF")
self.status_label.setStyleSheet("color: #444; background: transparent; border: none;")
def apply_theme(self, theme: dict):
pass
class MixerWidget(QFrame): class MixerWidget(QFrame):
def __init__(self, engine, parent=None): def __init__(self, engine, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -548,9 +612,6 @@ class MixerWidget(QFrame):
self.master_pre = MasterPreSection(self.engine) self.master_pre = MasterPreSection(self.engine)
channels_layout.addWidget(self.master_pre) channels_layout.addWidget(self.master_pre)
self.lufs_section = LUFSExpandedSection(self.engine)
channels_layout.addWidget(self.lufs_section)
scroll.setWidget(scroll_content) scroll.setWidget(scroll_content)
root.addWidget(scroll) root.addWidget(scroll)
@@ -558,7 +619,6 @@ class MixerWidget(QFrame):
for strip in self._strips: for strip in self._strips:
strip.apply_theme(theme) strip.apply_theme(theme)
self.master_pre.apply_theme(theme) self.master_pre.apply_theme(theme)
self.lufs_section.apply_theme(theme)
def refresh_strips(self): def refresh_strips(self):
for strip in self._strips: for strip in self._strips:
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

+220 -44
View File
@@ -1,23 +1,21 @@
# playlist_widget.py # playlist_widget.py
import json
import os
from datetime import datetime, timedelta
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFileDialog, QFrame, QSizePolicy, QPushButton, QFileDialog, QFrame, QSizePolicy,
QListWidget, QListWidgetItem, QAbstractItemView, 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 from PySide6.QtGui import QFont, QColor, QAction
import json
import os
from icons import icon_save, icon_load from icons import icon_save, icon_load
# ─────────────────────────────────────────
# PLAYLIST ITEM
# ─────────────────────────────────────────
class PlaylistItem(QListWidgetItem): class PlaylistItem(QListWidgetItem):
def __init__(self, filepath: str): def __init__(self, filepath: str):
self.filepath = filepath self.filepath = filepath
@@ -29,10 +27,6 @@ class PlaylistItem(QListWidgetItem):
self.setToolTip(filepath) self.setToolTip(filepath)
# ─────────────────────────────────────────
# HOUR DISPLAY + CLOCK
# ─────────────────────────────────────────
class HourDisplay(QFrame): class HourDisplay(QFrame):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -78,10 +72,6 @@ class HourDisplay(QFrame):
self.hour_label.setText(f"HOUR {now.strftime('%H:00')}") self.hour_label.setText(f"HOUR {now.strftime('%H:00')}")
# ─────────────────────────────────────────
# DECK SELECTOR
# ─────────────────────────────────────────
class DeckSelector(QFrame): class DeckSelector(QFrame):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -152,6 +142,11 @@ class DeckSelector(QFrame):
return self._selected return self._selected
# ─────────────────────────────────────────
# SHOW LOG PANEL
# ─────────────────────────────────────────
# ───────────────────────────────────────── # ─────────────────────────────────────────
# BASE PLAYLIST PANEL # BASE PLAYLIST PANEL
# ───────────────────────────────────────── # ─────────────────────────────────────────
@@ -276,6 +271,130 @@ class BasePlaylistPanel(QWidget):
return paths 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) header.setAlignment(Qt.AlignCenter)
root.addWidget(header) root.addWidget(header)
self.preview_bar = PreviewBar(self.engine)
root.addWidget(self.preview_bar)
self.hour_display = HourDisplay() self.hour_display = HourDisplay()
root.addWidget(self.hour_display) root.addWidget(self.hour_display)
self.deck_selector = DeckSelector() self.deck_selector = DeckSelector()
root.addWidget(self.deck_selector) 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 = self._make_list()
self.playlist.itemDoubleClicked.connect(self._send_to_deck) self.playlist.itemDoubleClicked.connect(self._send_to_deck)
self.playlist.customContextMenuRequested.connect(self._context_menu) self.playlist.customContextMenuRequested.connect(self._context_menu)
self.preview_bar.set_playlist(self.playlist)
root.addWidget(self.playlist) root.addWidget(self.playlist)
row1 = QHBoxLayout() row1 = QHBoxLayout()
@@ -364,6 +503,13 @@ class TrackPlaylistPanel(BasePlaylistPanel):
self.btn_remove.clicked.connect(self._remove_selected) self.btn_remove.clicked.connect(self._remove_selected)
self.btn_clear.clicked.connect(self._clear_all) 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): def _send_to_deck(self, item=None):
if item is None: if item is None:
item = self.playlist.currentItem() item = self.playlist.currentItem()
@@ -456,10 +602,10 @@ class TrackPlaylistPanel(BasePlaylistPanel):
# ───────────────────────────────────────── # ─────────────────────────────────────────
class CartPlaylistPanel(BasePlaylistPanel): class CartPlaylistPanel(BasePlaylistPanel):
def __init__(self, engine, cart_slots: list, parent=None): def __init__(self, engine, cart_player, parent=None):
super().__init__(parent) super().__init__(parent)
self.engine = engine self.engine = engine
self.cart_slots = cart_slots self.cart_player = cart_player
self._build_ui() self._build_ui()
def _build_ui(self): def _build_ui(self):
@@ -483,8 +629,24 @@ class CartPlaylistPanel(BasePlaylistPanel):
header.setAlignment(Qt.AlignCenter) header.setAlignment(Qt.AlignCenter)
root.addWidget(header) 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 = 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) self.playlist.customContextMenuRequested.connect(self._context_menu)
root.addWidget(self.playlist) root.addWidget(self.playlist)
@@ -498,11 +660,19 @@ class CartPlaylistPanel(BasePlaylistPanel):
row2 = QHBoxLayout() row2 = QHBoxLayout()
row2.setSpacing(4) 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_remove = self._make_btn("REMOVE", "#5a1a1a")
self.btn_clear = self._make_btn("CLEAR ALL", "#3a1a1a") self.btn_clear = self._make_btn("CLEAR ALL", "#3a1a1a")
row2.addWidget(self.btn_remove) row3.addWidget(self.btn_remove)
row2.addWidget(self.btn_clear) row3.addWidget(self.btn_clear)
root.addLayout(row2) root.addLayout(row3)
self.count_label = QLabel("0 tracks") self.count_label = QLabel("0 tracks")
self.count_label.setFont(QFont("Courier New", 9)) self.count_label.setFont(QFont("Courier New", 9))
@@ -518,27 +688,33 @@ class CartPlaylistPanel(BasePlaylistPanel):
self.btn_add_folder.clicked.connect( self.btn_add_folder.clicked.connect(
lambda: self._add_folder_to(self.playlist) 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_remove.clicked.connect(self._remove_selected)
self.btn_clear.clicked.connect(self._clear_all) self.btn_clear.clicked.connect(self._clear_all)
def _send_to_cart(self, item: PlaylistItem, cart_index: int): def _filter_list(self):
if not isinstance(item, PlaylistItem): query = self.search_box.text().lower()
return for i in range(self.playlist.count()):
slot = self.cart_slots[cart_index] item = self.playlist.item(i)
slot.load_track(item.filepath) if isinstance(item, PlaylistItem):
self._highlight(item, active=True) 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: if item is None:
item = self.playlist.currentItem() item = self.playlist.currentItem()
if not isinstance(item, PlaylistItem): if not isinstance(item, PlaylistItem):
return return
for i in range(4): self.cart_player.load_track(item.filepath)
cart_key = f"cart{i + 1}" self._highlight(item, active=True)
if self.engine.decks[cart_key].track is None:
self._send_to_cart(item, i) def _send_to_cart_queue(self, item=None):
if item is None:
item = self.playlist.currentItem()
if not isinstance(item, PlaylistItem):
return return
self._send_to_cart(item, 0) self.engine.add_to_cart_queue(item.filepath)
self._highlight(item, queued=True)
def _remove_selected(self): def _remove_selected(self):
row = self.playlist.currentRow() row = self.playlist.currentRow()
@@ -565,12 +741,13 @@ class CartPlaylistPanel(BasePlaylistPanel):
QMenu::item:selected { background-color: #1a3a5a; } QMenu::item:selected { background-color: #1a3a5a; }
""") """)
for i in range(4): a1 = QAction("Load → Cart", self)
action = QAction(f"Load → Cart {i + 1}", self) a1.triggered.connect(lambda: self._send_to_cart(item))
action.triggered.connect( menu.addAction(a1)
lambda checked, idx=i: self._send_to_cart(item, idx)
) a2 = QAction("Queue → Cart", self)
menu.addAction(action) a2.triggered.connect(lambda: self._send_to_cart_queue(item))
menu.addAction(a2)
menu.addSeparator() menu.addSeparator()
ar = QAction("Remove", self) ar = QAction("Remove", self)
@@ -590,7 +767,7 @@ class PlaylistWidget(QWidget):
PLAYLIST_JSON_FILTER = "JSON Files (*.json)" PLAYLIST_JSON_FILTER = "JSON Files (*.json)"
def __init__(self, engine, deck_widgets: dict, def __init__(self, engine, deck_widgets: dict,
cart_slots: list, parent=None): cart_player, parent=None):
super().__init__(parent) super().__init__(parent)
self.engine = engine self.engine = engine
@@ -601,7 +778,6 @@ class PlaylistWidget(QWidget):
root.setContentsMargins(0, 0, 0, 0) root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(4) root.setSpacing(4)
# Toolbar
bar = QHBoxLayout() bar = QHBoxLayout()
bar.setSpacing(4) bar.setSpacing(4)
@@ -653,11 +829,11 @@ class PlaylistWidget(QWidget):
""") """)
self.track_panel = TrackPlaylistPanel(engine, deck_widgets) 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.track_panel)
splitter.addWidget(self.cart_panel) splitter.addWidget(self.cart_panel)
splitter.setSizes([600, 400]) splitter.setSizes([400, 400])
root.addWidget(splitter) root.addWidget(splitter)
+31 -6
View File
@@ -47,6 +47,7 @@ class TopBarWidget(QFrame):
ratio_changed = Signal(int, int) ratio_changed = Signal(int, int)
save_session = Signal() save_session = Signal()
load_session = Signal() load_session = Signal()
recording_toggled = Signal(bool)
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -98,6 +99,32 @@ class TopBarWidget(QFrame):
self.station_label.setStyleSheet("color: #555555; background: transparent; border: none;") self.station_label.setStyleSheet("color: #555555; background: transparent; border: none;")
layout.addWidget(self.station_label) layout.addWidget(self.station_label)
layout.addStretch()
self.record_btn = QPushButton("● REC")
self.record_btn.setCheckable(True)
self.record_btn.setFixedSize(70, 30)
self.record_btn.setFont(QFont("Arial", 8, QFont.Bold))
self.record_btn.setToolTip("Record master output to WAV")
self.record_btn.setStyleSheet("""
QPushButton {
background-color: #2a1a1a;
color: #664444;
border: 1px solid #442222;
border-radius: 3px;
padding: 0 6px;
}
QPushButton:checked {
background-color: #4a1a1a;
color: #ff4444;
border: 1px solid #ff4444;
}
QPushButton:hover { color: #ff8888; }
QPushButton:checked:hover { background-color: #5a2020; }
""")
self.record_btn.toggled.connect(self._on_record_toggled)
layout.addWidget(self.record_btn)
self.settings_btn = QPushButton() self.settings_btn = QPushButton()
self.settings_btn.setFixedSize(24, 24) self.settings_btn.setFixedSize(24, 24)
self.settings_btn.setIconSize(QSize(14, 14)) self.settings_btn.setIconSize(QSize(14, 14))
@@ -132,6 +159,9 @@ class TopBarWidget(QFrame):
# ── Settings popup ───────────────────────────────────────────── # ── Settings popup ─────────────────────────────────────────────
def _on_record_toggled(self, checked):
self.recording_toggled.emit(checked)
def _toggle_settings_popup(self): def _toggle_settings_popup(self):
self._popup = SettingsPopup(self._midi_engine, self) self._popup = SettingsPopup(self._midi_engine, self)
self._popup.save_session.connect(self.save_session.emit) self._popup.save_session.connect(self.save_session.emit)
@@ -346,12 +376,7 @@ class SettingsPopup(QFrame):
("Deck 1 Cue", "deck_cue", "deck1"), ("Deck 1 Cue", "deck_cue", "deck1"),
("Deck 2 Play/Pause", "deck_play_pause", "deck2"), ("Deck 2 Play/Pause", "deck_play_pause", "deck2"),
("Deck 2 Cue", "deck_cue", "deck2"), ("Deck 2 Cue", "deck_cue", "deck2"),
("Deck 3 Play/Pause", "deck_play_pause", "deck3"), ("Cart Trigger", "cart_trigger", 0),
("Deck 4 Play/Pause", "deck_play_pause", "deck4"),
("Cart 1", "cart_trigger", 0),
("Cart 2", "cart_trigger", 1),
("Cart 3", "cart_trigger", 2),
("Cart 4", "cart_trigger", 3),
("Ch 1 Volume", "mixer_volume", 0), ("Ch 1 Volume", "mixer_volume", 0),
("Ch 2 Volume", "mixer_volume", 1), ("Ch 2 Volume", "mixer_volume", 1),
("Ch 3 Volume", "mixer_volume", 2), ("Ch 3 Volume", "mixer_volume", 2),