- 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
357 lines
12 KiB
Python
357 lines
12 KiB
Python
# main.py
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
|
|
__version__ = "0.6"
|
|
|
|
from PySide6.QtWidgets import (
|
|
QApplication, QMainWindow, QWidget, QFileDialog,
|
|
QVBoxLayout, QHBoxLayout, QSplitter
|
|
)
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtGui import QFont, QColor, QPalette
|
|
|
|
from audio_engine import AudioEngine
|
|
from deck_widget import DeckWidget
|
|
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 ShowHistoryWidget
|
|
from mixer_widget import MixerWidget, MicTimerWidget, LUFSExpandedSection
|
|
from event_logger import SessionLogger
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RadioPanelWindow(QMainWindow):
|
|
def __init__(self):
|
|
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)
|
|
self.resize(1920, 1080)
|
|
self._apply_global_style(THEMES[0])
|
|
|
|
central = QWidget()
|
|
self.setCentralWidget(central)
|
|
|
|
outer = QVBoxLayout(central)
|
|
outer.setContentsMargins(6, 6, 6, 6)
|
|
outer.setSpacing(6)
|
|
|
|
# Top bar
|
|
self.top_bar = TopBarWidget()
|
|
self.top_bar.theme_changed.connect(self._on_theme_changed)
|
|
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.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,
|
|
deck_widgets={
|
|
"deck1": self.deck1,
|
|
"deck2": self.deck2,
|
|
},
|
|
cart_player=self.cart_player
|
|
)
|
|
|
|
self.history_widget = ShowHistoryWidget()
|
|
self.history_widget.set_engine(self.engine)
|
|
|
|
self.mixer = MixerWidget(self.engine)
|
|
|
|
# ── Centre column with vertical splitter ─────────────────────────
|
|
centre_col = QVBoxLayout()
|
|
centre_col.setSpacing(6)
|
|
|
|
# Top: 2 decks + cart
|
|
deck_area = QWidget()
|
|
deck_area.setStyleSheet("background: transparent; border: none;")
|
|
deck_layout = QVBoxLayout(deck_area)
|
|
deck_layout.setContentsMargins(0, 0, 0, 0)
|
|
deck_layout.setSpacing(4)
|
|
|
|
deck_row1 = QHBoxLayout()
|
|
deck_row1.setSpacing(4)
|
|
deck_row1.addWidget(self.deck1)
|
|
deck_row1.addWidget(self.deck2)
|
|
deck_layout.addLayout(deck_row1)
|
|
|
|
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)
|
|
centre_col.addWidget(self.mixer, stretch=1)
|
|
|
|
centre_widget = QWidget()
|
|
centre_widget.setStyleSheet("background: transparent; border: none;")
|
|
centre_widget.setLayout(centre_col)
|
|
|
|
# ── Horizontal splitter ──────────────────────────────────────────
|
|
self.splitter = QSplitter(Qt.Horizontal)
|
|
self.splitter.setHandleWidth(4)
|
|
self._style_splitter(THEMES[0])
|
|
|
|
self.splitter.addWidget(self.playlist_widget)
|
|
self.splitter.addWidget(centre_widget)
|
|
self.splitter.addWidget(self.history_widget)
|
|
self.splitter.setSizes([360, 1160, 360])
|
|
|
|
outer.addWidget(self.splitter)
|
|
|
|
# ── Wire signals ─────────────────────────────────────────────────
|
|
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()
|
|
|
|
def _style_splitter(self, theme: dict):
|
|
self.splitter.setStyleSheet(f"""
|
|
QSplitter::handle {{
|
|
background-color: #22242a;
|
|
border-radius: 2px;
|
|
}}
|
|
QSplitter::handle:hover {{
|
|
background-color: {theme['accent']};
|
|
}}
|
|
QSplitter::handle:pressed {{
|
|
background-color: {theme['accent_dim']};
|
|
}}
|
|
""")
|
|
|
|
def _on_theme_changed(self, theme: dict):
|
|
self._apply_global_style(theme)
|
|
self._style_splitter(theme)
|
|
self.deck1.apply_theme(theme)
|
|
self.deck2.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)
|
|
|
|
def _apply_global_style(self, theme: dict):
|
|
self.setStyleSheet(f"""
|
|
QMainWindow {{
|
|
background-color: {theme['bg']};
|
|
}}
|
|
QWidget {{
|
|
background-color: {theme['bg']};
|
|
color: {theme['text']};
|
|
font-family: "Segoe UI", Arial, sans-serif;
|
|
}}
|
|
QToolTip {{
|
|
background-color: {theme['bg2']};
|
|
color: {theme['text']};
|
|
border: 1px solid #444;
|
|
padding: 4px;
|
|
}}
|
|
""")
|
|
|
|
def _save_session(self):
|
|
path, _ = QFileDialog.getSaveFileName(
|
|
self.top_bar, "Save Session",
|
|
os.path.expanduser("~/.gti_radiostudio/session.json"),
|
|
"JSON Files (*.json)"
|
|
)
|
|
if not path:
|
|
return
|
|
|
|
state = {
|
|
'window_size': (self.width(), self.height()),
|
|
'pfl_hp_blend': self.engine.mixer['master'].pfl_hp_blend,
|
|
'channels': [],
|
|
}
|
|
|
|
for ch in self.engine.mixer['channels']:
|
|
state['channels'].append({
|
|
'name': ch.name,
|
|
'volume': ch.volume,
|
|
'gain': ch.gain,
|
|
'muted': ch.muted,
|
|
'pfl': ch.pfl,
|
|
'mic_on': ch.mic_on,
|
|
})
|
|
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, 'w') as f:
|
|
json.dump(state, f, indent=2)
|
|
|
|
def _load_session(self):
|
|
path, _ = QFileDialog.getOpenFileName(
|
|
self.top_bar, "Load Session",
|
|
os.path.expanduser("~/.gti_radiostudio/session.json"),
|
|
"JSON Files (*.json)"
|
|
)
|
|
if not path:
|
|
return
|
|
|
|
with open(path) as f:
|
|
state = json.load(f)
|
|
|
|
ws = state.get('window_size')
|
|
if ws:
|
|
self.resize(ws[0], ws[1])
|
|
|
|
pfl_hp_blend = state.get('pfl_hp_blend')
|
|
if pfl_hp_blend is not None:
|
|
self.engine.mixer['master'].pfl_hp_blend = pfl_hp_blend
|
|
|
|
for i, ch_state in enumerate(state.get('channels', [])):
|
|
if i < len(self.engine.mixer['channels']):
|
|
ch = self.engine.mixer['channels'][i]
|
|
ch.name = ch_state.get('name', ch.name)
|
|
ch.volume = ch_state.get('volume', ch.volume)
|
|
ch.gain = ch_state.get('gain', ch.gain)
|
|
ch.muted = ch_state.get('muted', ch.muted)
|
|
ch.solo = ch_state.get('solo', ch.solo)
|
|
ch.pfl = ch_state.get('pfl', ch.pfl)
|
|
ch.mic_on = ch_state.get('mic_on', ch.mic_on)
|
|
|
|
self.mixer.refresh_strips()
|
|
|
|
def _setup_midi(self):
|
|
self.midi = MidiEngine()
|
|
|
|
self.midi.deck_play_pause.connect(self._on_midi_play_pause)
|
|
self.midi.deck_cue.connect(self._on_midi_cue)
|
|
self.midi.deck_jog.connect(self._on_midi_jog)
|
|
self.midi.cart_trigger.connect(self._on_midi_cart)
|
|
self.midi.mixer_volume.connect(self._on_midi_mixer_volume)
|
|
self.midi.mixer_mute.connect(self._on_midi_mixer_mute)
|
|
self.midi.mixer_pfl.connect(self._on_midi_mixer_pfl)
|
|
self.midi.mixer_mic_on.connect(self._on_midi_mixer_mic_on)
|
|
self.midi.mixer_gain.connect(self._on_midi_mixer_gain)
|
|
self.midi.master_control.connect(self._on_midi_master_control)
|
|
self.midi.master_volume.connect(self._on_midi_master_volume)
|
|
self.midi.controller_connected.connect(self._on_midi_status)
|
|
|
|
self.midi.start()
|
|
self.top_bar.set_midi_engine(self.midi)
|
|
|
|
ports = self.midi.list_ports()
|
|
if ports:
|
|
logger.info("Available ports: %s", ports)
|
|
|
|
def _on_midi_play_pause(self, deck: str):
|
|
if deck == 'deck1':
|
|
self.deck1.toggle_play()
|
|
elif deck == 'deck2':
|
|
self.deck2.toggle_play()
|
|
|
|
def _on_midi_cue(self, deck: str):
|
|
if deck == 'deck1':
|
|
self.deck1.cue()
|
|
elif deck == 'deck2':
|
|
self.deck2.cue()
|
|
|
|
def _on_midi_jog(self, deck: str, delta: float):
|
|
if deck == 'deck1':
|
|
self.deck1.scrub(delta)
|
|
elif deck == 'deck2':
|
|
self.deck2.scrub(delta)
|
|
|
|
def _on_midi_cart(self, slot: int):
|
|
self.cart_player.toggle()
|
|
|
|
def _on_midi_mixer_volume(self, channel: int, value: float):
|
|
self.engine.set_channel_volume(channel, value)
|
|
|
|
def _on_midi_mixer_mute(self, channel: int, muted: bool):
|
|
self.engine.set_channel_mute(channel, muted)
|
|
|
|
def _on_midi_mixer_pfl(self, channel: int, on: bool):
|
|
self.engine.set_channel_pfl(channel, on)
|
|
|
|
def _on_midi_mixer_mic_on(self, channel: int, on: bool):
|
|
if channel < 2:
|
|
current = self.engine.get_channel_mic_on(channel)
|
|
self.engine.set_channel_mic_on(channel, not current)
|
|
|
|
def _on_midi_mixer_gain(self, channel: int, gain: float):
|
|
self.engine.set_channel_gain(channel, gain)
|
|
|
|
def _on_midi_master_control(self, knob: str, val: float):
|
|
if knob == "pfl_hp_blend":
|
|
self.engine.mixer['master'].pfl_hp_blend = max(0.0, min(1.0, val))
|
|
|
|
def _on_midi_master_volume(self, val: float):
|
|
self.engine.mixer['master'].volume = max(0.0, min(1.0, val))
|
|
|
|
def _on_midi_status(self, connected: bool, name: str):
|
|
if connected:
|
|
logger.info("Connected: %s", name)
|
|
else:
|
|
logger.info("Not connected: %s", name)
|
|
|
|
def closeEvent(self, event):
|
|
self.midi.stop()
|
|
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)
|
|
app.setApplicationName("Radio Panel")
|
|
app.setOrganizationName("Getting To It")
|
|
|
|
palette = QPalette()
|
|
palette.setColor(QPalette.Window, QColor("#121418"))
|
|
palette.setColor(QPalette.WindowText, QColor("#d0d4dc"))
|
|
palette.setColor(QPalette.Base, QColor("#1a1c23"))
|
|
palette.setColor(QPalette.AlternateBase, QColor("#22242c"))
|
|
palette.setColor(QPalette.Text, QColor("#d0d4dc"))
|
|
palette.setColor(QPalette.Button, QColor("#2a2c35"))
|
|
palette.setColor(QPalette.ButtonText, QColor("#d0d4dc"))
|
|
palette.setColor(QPalette.Highlight, QColor("#00b894"))
|
|
palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
|
|
app.setPalette(palette)
|
|
|
|
window = RadioPanelWindow()
|
|
window.show()
|
|
|
|
sys.exit(app.exec())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |