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__/
*.pyc
venv/
obs_overlays/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

+166 -36
View File
@@ -6,7 +6,9 @@ import jack
import threading
import subprocess
import os
import time
import pyloudnorm as pyln
from datetime import datetime
LUFS_TARGET = -14.0
@@ -195,12 +197,12 @@ class AudioEngine:
self.client.outports.register("cart_L"),
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
self.mixer = {
'channels': [],
@@ -230,6 +232,12 @@ class AudioEngine:
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
self.pfl_out = (
self.client.outports.register("pfl_out_L"),
@@ -240,13 +248,22 @@ class AudioEngine:
self.decks = {
'deck1': DeckState(),
'deck2': DeckState(),
'deck3': DeckState(),
'deck4': DeckState(),
'cart1': DeckState(),
'cart2': DeckState(),
'cart3': DeckState(),
'cart4': DeckState(),
'preview': 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.activate()
@@ -319,33 +336,40 @@ class AudioEngine:
self._fill_port(*self.ports['deck1'], self.decks['deck1'], 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_R = self.ports['cart'][1].get_array()
buf_L.fill(0.0)
buf_R.fill(0.0)
for cart_key in ('cart1', 'cart2', 'cart3', 'cart4'):
deck = self.decks[cart_key]
with deck.lock:
playing = deck.playing
track = deck.track
pos = deck.position
if not playing or track is None:
continue
end = min(pos + frames, track.num_samples)
n = end - pos
if n <= 0:
with deck.lock:
deck.playing = False
continue
buf_L[:n] += track.audio[0, pos:end]
buf_R[:n] += track.audio[1, pos:end]
with deck.lock:
deck.position += n
if deck.position >= track.num_samples:
deck.playing = False
deck.position = track.num_samples - 1
cart_deck = self.decks['cart1']
with cart_deck.lock:
playing = cart_deck.playing
track = cart_deck.track
pos = cart_deck.position
if playing and track is not None:
end = min(pos + frames, track.num_samples)
n = end - pos
if n > 0:
buf_L[:n] = track.audio[0, pos:end]
buf_R[:n] = track.audio[1, pos:end]
with cart_deck.lock:
cart_deck.position += max(n, 0)
if cart_deck.position >= track.num_samples:
cart_deck.playing = False
cart_deck.position = track.num_samples - 1
if self.cart_continue and self.cart_queue:
next_track = self.cart_queue.pop(0)
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 ───────────────────────────────────────────────────────
dt = frames / self.sr
@@ -372,11 +396,33 @@ class AudioEngine:
pfl_buf_L.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']
any_solo = any(ch.solo for ch in channels)
any_mic_on = any(ch.mic_on for ch in channels[:2])
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:
in_L = ch.in_port_L.get_array()
in_R = ch.in_port_R.get_array()
@@ -405,6 +451,10 @@ class AudioEngine:
headphone_buf_L[:] += in_L[:] * 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
ch._vu_smooth = ch._vu_smooth * vu_decay + peak * (1 - vu_decay)
ch.vu_peak = ch._vu_smooth
@@ -440,14 +490,12 @@ class AudioEngine:
master_buf_L[:] *= vol
master_buf_R[:] *= vol
# ── LUFS metering from external processing input ────────────────
# ── LUFS metering from master mix ──────────────────────────
master._lufs_accum += frames
if master._lufs_accum >= 512:
lufs_dt = master._lufs_accum / self.sr
lufs_dt = 512.0 / self.sr
master._lufs_accum -= 512
lufs_L = self.lufs_in_L.get_array()
lufs_R = self.lufs_in_R.get_array()
ms = (np.mean(lufs_L ** 2) + np.mean(lufs_R ** 2)) / 2.0
ms = (np.mean(master_buf_L[:] ** 2) + np.mean(master_buf_R[:] ** 2)) / 2.0
if ms < 1e-12:
master.lufs_current = -70.0
else:
@@ -457,6 +505,28 @@ class AudioEngine:
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)
# ── 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():
audio = load_audio(filepath, self.sr)
@@ -479,6 +549,66 @@ class AudioEngine:
self.decks[deck_key].load_queue(track)
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):
self.client.deactivate()
self.client.close()
+198 -129
View File
@@ -2,35 +2,27 @@
from PySide6.QtWidgets import (
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
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):
def __init__(self, cart_key: str, slot_number: int, engine, parent=None):
class CartPlayerWidget(QFrame):
"""Single active cart player with visible 3-item autoload queue."""
cart_triggered = Signal(str)
def __init__(self, engine, parent=None):
super().__init__(parent)
self.cart_key = cart_key
self.slot_number = slot_number
self.engine = engine
self.engine = engine
self._playing_icon = False
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.setFixedHeight(80)
self.setFixedHeight(160)
self._build_ui()
self._connect_signals()
@@ -39,23 +31,78 @@ class CartSlot(QFrame):
self.timer.timeout.connect(self._refresh_display)
self.timer.start()
def _build_ui(self):
root = QHBoxLayout(self)
root.setContentsMargins(6, 4, 6, 4)
root.setSpacing(6)
self.queue_timer = QTimer(self)
self.queue_timer.setInterval(500)
self.queue_timer.timeout.connect(self._refresh_queue)
self.queue_timer.start()
badge = QLabel(str(self.slot_number))
badge.setFixedSize(22, 22)
def _build_ui(self):
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.setFont(QFont("Arial", 10, QFont.Bold))
badge.setFont(QFont("Arial", 12, QFont.Bold))
badge.setStyleSheet("""
background-color: #cc3300;
color: white;
border-radius: 14px;
border-radius: 15px;
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.setStyleSheet("""
QFrame {
@@ -64,32 +111,26 @@ class CartSlot(QFrame):
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_layout.setContentsMargins(4, 2, 4, 2)
lcd_layout.setSpacing(1)
lcd_left = QVBoxLayout()
lcd_left.setSpacing(2)
self.track_label = QLabel("EMPTY")
self.track_label.setFont(QFont("Courier New", 8, QFont.Bold))
self.track_label.setStyleSheet(
"color: #00ff41; background: transparent; border: none;"
)
self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.track_label.setFont(QFont("Courier New", 9, QFont.Bold))
self.track_label.setStyleSheet("color: #00ff41; background: transparent; border: none;")
self.track_label.setMinimumWidth(180)
time_row = QHBoxLayout()
self.elapsed_label = QLabel("00:00")
self.elapsed_label.setFont(QFont("Courier New", 11, QFont.Bold))
self.elapsed_label.setStyleSheet(
"color: #00ff41; background: transparent; border: none;"
)
self.elapsed_label.setFont(QFont("Courier New", 18, QFont.Bold))
self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent; border: none;")
self.remaining_label = QLabel("-00:00")
self.remaining_label.setFont(QFont("Courier New", 8))
self.remaining_label.setStyleSheet(
"color: #00cc33; background: transparent; border: none;"
)
self.remaining_label.setFont(QFont("Courier New", 10, QFont.Bold))
self.remaining_label.setStyleSheet("color: #00cc33; background: transparent; border: none;")
self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
time_row.addWidget(self.elapsed_label)
@@ -98,22 +139,19 @@ class CartSlot(QFrame):
self.status_label = QLabel("STOPPED")
self.status_label.setFont(QFont("Courier New", 8))
self.status_label.setStyleSheet(
"color: #008822; background: transparent; border: none;"
)
self.status_label.setStyleSheet("color: #008822; background: transparent; border: none;")
lcd_layout.addWidget(self.track_label)
lcd_layout.addLayout(time_row)
lcd_layout.addWidget(self.status_label)
root.addWidget(self.lcd_frame)
lcd_left.addWidget(self.track_label)
lcd_left.addLayout(time_row)
lcd_left.addWidget(self.status_label)
lcd_layout.addLayout(lcd_left)
btn_col = QVBoxLayout()
btn_col.setSpacing(3)
self.btn_play = QPushButton()
self.btn_play.setFixedSize(50, 32)
self.btn_play.setIconSize(QSize(18, 18))
self.btn_play.setFixedSize(56, 36)
self.btn_play.setIconSize(QSize(22, 22))
self.btn_play.setIcon(icon_play("#00ff41"))
self.btn_play.setStyleSheet("""
QPushButton {
@@ -126,8 +164,8 @@ class CartSlot(QFrame):
""")
self.btn_eject = QPushButton()
self.btn_eject.setFixedSize(50, 24)
self.btn_eject.setIconSize(QSize(14, 14))
self.btn_eject.setFixedSize(56, 28)
self.btn_eject.setIconSize(QSize(16, 16))
self.btn_eject.setIcon(icon_eject("#ff4444"))
self.btn_eject.setStyleSheet("""
QPushButton {
@@ -141,36 +179,85 @@ class CartSlot(QFrame):
btn_col.addWidget(self.btn_play)
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):
self.lcd_frame.setStyleSheet(f"""
QFrame {{
background-color: {theme['bg']};
border: 1px solid {theme['border']};
border-radius: 3px;
}}
# ── Right: Queue panel ──────────────────────────────────────
queue_frame = QFrame()
queue_frame.setStyleSheet("""
QFrame {
background-color: #111;
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):
self.btn_play.clicked.connect(self._toggle_play)
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):
self.engine.load_cart_track(self.cart_key, filepath)
name = os.path.basename(filepath)
short = name[:28] + "..." if len(name) > 28 else name
self.engine.load_cart_track("cart1", filepath)
name = os.path.basename(filepath)
short = name[:32] + "..." if len(name) > 32 else name
self.track_label.setText(short)
self.status_label.setText("LOADED")
self.elapsed_label.setText("00:00")
self.remaining_label.setText("-00:00")
def toggle(self):
deck = self.engine.decks[self.cart_key]
deck = self.engine.decks["cart1"]
if deck.track is None:
return
if deck.playing:
deck.stop()
deck.go_to_start()
@@ -179,42 +266,36 @@ class CartSlot(QFrame):
else:
deck.play()
self._set_ui_playing()
self.engine.log_cart_trigger(deck.track.filename)
self.cart_triggered.emit(deck.track.filename)
def _set_ui_playing(self):
self.btn_play.setIcon(icon_pause("#ffffff"))
self._playing_icon = True
self.btn_play.setStyleSheet("""
QPushButton {
background-color: #00aa33;
border: 1px solid #00ff41;
border-radius: 3px;
background-color: #00aa33; border: 1px solid #00ff41; border-radius: 3px;
}
QPushButton:hover { background-color: #00cc44; }
""")
self.status_label.setText("PLAYING")
self.elapsed_label.setStyleSheet(
"color: #00ff41; background: transparent; border: none;"
)
self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent; border: none;")
def _set_ui_stopped(self):
self.btn_play.setIcon(icon_play("#00ff41"))
self._playing_icon = False
self.btn_play.setStyleSheet("""
QPushButton {
background-color: #1a6b3a;
border: 1px solid #555;
border-radius: 3px;
background-color: #1a6b3a; border: 1px solid #555; border-radius: 3px;
}
QPushButton:hover { background-color: #2a7b4a; }
""")
self.elapsed_label.setText("00:00")
self.remaining_label.setText("-00:00")
self.elapsed_label.setStyleSheet(
"color: #00aa2a; background: transparent; border: none;"
)
self.elapsed_label.setStyleSheet("color: #00aa2a; background: transparent; border: none;")
def _toggle_play(self):
deck = self.engine.decks[self.cart_key]
deck = self.engine.decks["cart1"]
if deck.track is None:
return
if deck.playing:
@@ -224,16 +305,18 @@ class CartSlot(QFrame):
else:
deck.play()
self._set_ui_playing()
self.engine.log_cart_trigger(deck.track.filename)
self.cart_triggered.emit(deck.track.filename)
def _eject(self):
deck = self.engine.decks[self.cart_key]
deck = self.engine.decks["cart1"]
deck.stop()
self._set_ui_stopped()
self.track_label.setText("EMPTY")
self.status_label.setText("STOPPED")
def _refresh_display(self):
deck = self.engine.decks[self.cart_key]
deck = self.engine.decks["cart1"]
track = deck.track
playing = deck.playing
@@ -245,51 +328,37 @@ class CartSlot(QFrame):
self.elapsed_label.setText(deck.get_elapsed())
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):
def __init__(self, engine, parent=None):
super().__init__(parent)
self.engine = engine
self._build_ui()
for i in range(3):
if i < qlen:
name = q[i].filename
name = name[:35] + "..." if len(name) > 35 else name
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):
self.setStyleSheet("""
QWidget {
background-color: #111;
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)
if not self.engine.cart_continue and self.continue_toggle.isChecked():
self.continue_toggle.blockSignals(True)
self.continue_toggle.setChecked(False)
self.continue_toggle.blockSignals(False)
def apply_theme(self, theme: dict):
for slot in self.slots:
slot.apply_theme(theme)
self.lcd_frame.setStyleSheet(f"""
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
+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])
+38 -29
View File
@@ -16,12 +16,13 @@ from PySide6.QtGui import QFont, QColor, QPalette
from audio_engine import AudioEngine
from deck_widget import DeckWidget
from cart_widget import CartBankWidget
from cart_widget import CartPlayerWidget
from playlist_widget import PlaylistWidget
from midi_engine import MidiEngine
from top_bar_widget import TopBarWidget, THEMES
from history_widget import HistoryWidget
from mixer_widget import MixerWidget
from history_widget import ShowHistoryWidget
from mixer_widget import MixerWidget, MicTimerWidget, LUFSExpandedSection
from event_logger import SessionLogger
logger = logging.getLogger(__name__)
@@ -31,6 +32,8 @@ class RadioPanelWindow(QMainWindow):
super().__init__()
self.engine = AudioEngine()
self.session_logger = SessionLogger()
self.engine.logger = self.session_logger
self.setWindowTitle(f"Getting To It v{__version__}")
self.setMinimumSize(1400, 860)
@@ -50,17 +53,19 @@ class RadioPanelWindow(QMainWindow):
self.top_bar.ratio_changed.connect(self._apply_ratio)
self.top_bar.save_session.connect(self._save_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)
# ── Build widgets ─────────────────────────────────────────────────
self.deck1 = DeckWidget("deck1", "DECK_001", "#00b894", 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_bank.setFixedHeight(150)
self.cart_player = CartPlayerWidget(self.engine)
self.cart_player.setFixedHeight(160)
self.mic_timer = MicTimerWidget(self.engine)
self.lufs_section = LUFSExpandedSection(self.engine)
self.playlist_widget = PlaylistWidget(
engine=self.engine,
@@ -68,10 +73,11 @@ class RadioPanelWindow(QMainWindow):
"deck1": self.deck1,
"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)
@@ -79,7 +85,7 @@ class RadioPanelWindow(QMainWindow):
centre_col = QVBoxLayout()
centre_col.setSpacing(6)
# Top: 4 decks in 2x2 grid + carts
# Top: 2 decks + cart
deck_area = QWidget()
deck_area.setStyleSheet("background: transparent; border: none;")
deck_layout = QVBoxLayout(deck_area)
@@ -92,13 +98,15 @@ class RadioPanelWindow(QMainWindow):
deck_row1.addWidget(self.deck2)
deck_layout.addLayout(deck_row1)
deck_row2 = QHBoxLayout()
deck_row2.setSpacing(4)
deck_row2.addWidget(self.deck3)
deck_row2.addWidget(self.deck4)
deck_layout.addLayout(deck_row2)
deck_layout.addWidget(self.cart_bank)
cart_row = QHBoxLayout()
cart_row.setSpacing(4)
cart_row.addWidget(self.cart_player)
mic_lufs_row = QHBoxLayout()
mic_lufs_row.setSpacing(4)
mic_lufs_row.addWidget(self.mic_timer, stretch=1)
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
centre_col.addWidget(deck_area)
@@ -121,10 +129,9 @@ class RadioPanelWindow(QMainWindow):
outer.addWidget(self.splitter)
# ── Wire signals ─────────────────────────────────────────────────
self.deck1.track_started.connect(self.history_widget.add_track)
self.deck2.track_started.connect(self.history_widget.add_track)
self.deck3.track_started.connect(self.history_widget.add_track)
self.deck4.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_song)
self.cart_player.cart_triggered.connect(self.history_widget.add_cart)
# ── MIDI ──────────────────────────────────────────────────────────
self._setup_midi()
@@ -148,11 +155,11 @@ class RadioPanelWindow(QMainWindow):
self._style_splitter(theme)
self.deck1.apply_theme(theme)
self.deck2.apply_theme(theme)
self.deck3.apply_theme(theme)
self.deck4.apply_theme(theme)
self.cart_bank.apply_theme(theme)
self.cart_player.apply_theme(theme)
self.history_widget.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):
self.resize(w, h)
@@ -265,10 +272,6 @@ class RadioPanelWindow(QMainWindow):
self.deck1.toggle_play()
elif deck == 'deck2':
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):
if deck == 'deck1':
@@ -283,7 +286,7 @@ class RadioPanelWindow(QMainWindow):
self.deck2.scrub(delta)
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):
self.engine.set_channel_volume(channel, value)
@@ -320,6 +323,12 @@ class RadioPanelWindow(QMainWindow):
self.engine.shutdown()
event.accept()
def _on_recording_toggled(self, checked):
if checked:
self.engine.start_recording()
else:
self.engine.stop_recording()
def main():
app = QApplication(sys.argv)
+1 -1
View File
@@ -354,7 +354,7 @@ class MidiEngine(QObject):
def _handle_note_on(self, note: int):
# Hardcoded Numark DJ2Go mapping (legacy)
NUMARK_CART = {0x44: 0, 0x43: 1, 0x46: 2, 0x45: 3}
NUMARK_CART = {0x44: 0}
if note == 0x3B:
self.deck_play_pause.emit('deck1')
+66 -6
View File
@@ -410,8 +410,8 @@ class LUFSExpandedSection(QFrame):
self.engine = engine
self._master = engine.mixer['master']
self.setFixedWidth(200)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.setMinimumWidth(160)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setStyleSheet("""
LUFSExpandedSection {
background-color: #181a1e;
@@ -484,6 +484,70 @@ class LUFSExpandedSection(QFrame):
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):
def __init__(self, engine, parent=None):
super().__init__(parent)
@@ -548,9 +612,6 @@ class MixerWidget(QFrame):
self.master_pre = MasterPreSection(self.engine)
channels_layout.addWidget(self.master_pre)
self.lufs_section = LUFSExpandedSection(self.engine)
channels_layout.addWidget(self.lufs_section)
scroll.setWidget(scroll_content)
root.addWidget(scroll)
@@ -558,7 +619,6 @@ class MixerWidget(QFrame):
for strip in self._strips:
strip.apply_theme(theme)
self.master_pre.apply_theme(theme)
self.lufs_section.apply_theme(theme)
def refresh_strips(self):
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

+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)
+31 -6
View File
@@ -47,6 +47,7 @@ class TopBarWidget(QFrame):
ratio_changed = Signal(int, int)
save_session = Signal()
load_session = Signal()
recording_toggled = Signal(bool)
def __init__(self, parent=None):
super().__init__(parent)
@@ -98,6 +99,32 @@ class TopBarWidget(QFrame):
self.station_label.setStyleSheet("color: #555555; background: transparent; border: none;")
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.setFixedSize(24, 24)
self.settings_btn.setIconSize(QSize(14, 14))
@@ -132,6 +159,9 @@ class TopBarWidget(QFrame):
# ── Settings popup ─────────────────────────────────────────────
def _on_record_toggled(self, checked):
self.recording_toggled.emit(checked)
def _toggle_settings_popup(self):
self._popup = SettingsPopup(self._midi_engine, self)
self._popup.save_session.connect(self.save_session.emit)
@@ -346,12 +376,7 @@ class SettingsPopup(QFrame):
("Deck 1 Cue", "deck_cue", "deck1"),
("Deck 2 Play/Pause", "deck_play_pause", "deck2"),
("Deck 2 Cue", "deck_cue", "deck2"),
("Deck 3 Play/Pause", "deck_play_pause", "deck3"),
("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),
("Cart Trigger", "cart_trigger", 0),
("Ch 1 Volume", "mixer_volume", 0),
("Ch 2 Volume", "mixer_volume", 1),
("Ch 3 Volume", "mixer_volume", 2),