From 7de0cc3de09042e89ee501ef2722be893aea6c26 Mon Sep 17 00:00:00 2001 From: njmb Date: Wed, 13 May 2026 16:47:02 +1000 Subject: [PATCH] Fix AttributeError: remove redundant mapping_learned connection in main.py TopBarWidget already connects mapping_learned to _on_mapping_learned internally in set_midi_engine, so the external connection in main.py was both redundant and referenced a non-existent method. --- audio_engine.py | 87 +++++-- cart_widget.py | 6 +- deck_widget.py | 275 +++++++++++++++------ icons.py | 66 +++++ main.py | 193 +++++++++++---- midi_engine.py | 35 ++- mixer_widget.py | 392 ++++++++++++++++-------------- top_bar_widget.py | 596 +++++++++++++++++++++++++++++----------------- 8 files changed, 1117 insertions(+), 533 deletions(-) diff --git a/audio_engine.py b/audio_engine.py index 9b96d20..ba225a3 100644 --- a/audio_engine.py +++ b/audio_engine.py @@ -143,6 +143,14 @@ class DeckState: return f"-{int(remaining//60):02d}:{int(remaining%60):02d}" +class ExternalInputMonitor: + def __init__(self, buffer_size=600): + self.buffer = np.zeros(buffer_size, dtype=np.float32) + self.write_pos = 0 + self.buffer_size = buffer_size + self.monitoring = False + + class MixerChannel: def __init__(self, name: str, index: int): self.name = name @@ -150,14 +158,10 @@ class MixerChannel: self.volume = 1.0 self.muted = False self.solo = False + self.pfl = False self.vu_peak = 0.0 self.in_port_L = None self.in_port_R = None - self.out_port_L = None - self.out_port_R = None - - def reset_vu(self): - self.vu_peak = 0.0 class Compressor: @@ -169,7 +173,6 @@ class Compressor: self.release_ms = 100.0 self.makeup_gain_db = 0.0 self.bypassed = False - self._envelope = 0.0 self._attack_coeff = self._time_constant(self.attack_ms) self._release_coeff = self._time_constant(self.release_ms) @@ -226,16 +229,13 @@ class MasterBus: self.vu_peak = 0.0 self.lufs_current = -70.0 - def reset_vu(self): - self.vu_peak = 0.0 - class AudioEngine: def __init__(self): self.client = jack.Client("RadioPanel") self.sr = self.client.samplerate - # Output ports for decks (standalone, not mixed into master) + # Output ports for digital decks (standalone, not mixed into master) self.ports = { 'deck1': ( self.client.outports.register("deck1_L"), @@ -251,6 +251,17 @@ class AudioEngine: ), } + # External input monitors (deck3, deck4 — pass-through with waveform) + self.ext_monitors = {} + self.ext_ports = {} + for ext_key, label in [('deck3', 'deck3'), ('deck4', 'deck4')]: + in_L = self.client.inports.register(f"{label}_in_L") + in_R = self.client.inports.register(f"{label}_in_R") + out_L = self.client.outports.register(f"{label}_out_L") + out_R = self.client.outports.register(f"{label}_out_R") + self.ext_ports[ext_key] = (in_L, in_R, out_L, out_R) + self.ext_monitors[ext_key] = ExternalInputMonitor() + # Mixer channels: 8 stereo input ports self.mixer = { 'channels': [], @@ -268,7 +279,13 @@ class AudioEngine: self.client.outports.register("master_out_R"), ) - # Deck states (unchanged) + # PFL output ports + self.pfl_out = ( + self.client.outports.register("pfl_out_L"), + self.client.outports.register("pfl_out_R"), + ) + + # Deck states (for digital decks and carts) self.decks = { 'deck1': DeckState(), 'deck2': DeckState(), @@ -300,6 +317,10 @@ class AudioEngine: if 0 <= index < NUM_MIXER_CHANNELS: self.mixer['channels'][index].solo = solo + def set_channel_pfl(self, index: int, pfl: bool): + if 0 <= index < NUM_MIXER_CHANNELS: + self.mixer['channels'][index].pfl = pfl + def _fill_port(self, port_L, port_R, deck: DeckState, frames: int): buf_L = port_L.get_array() buf_R = port_R.get_array() @@ -327,7 +348,7 @@ class AudioEngine: deck.position = track.num_samples - 1 def _process(self, frames: int): - # ── Decks (unchanged) ────────────────────────────────────────── + # ── Digital decks ─────────────────────────────────────────────── self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames) self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames) @@ -355,6 +376,34 @@ class AudioEngine: deck.playing = False deck.position = track.num_samples - 1 + # ── External input monitors (deck3, deck4) ───────────────────── + gate_linear = 10 ** (-46.0 / 20.0) + for ext_key in ('deck3', 'deck4'): + in_L, in_R, out_L, out_R = self.ext_ports[ext_key] + in_buf_L = in_L.get_array() + in_buf_R = in_R.get_array() + out_buf_L = out_L.get_array() + out_buf_R = out_R.get_array() + + out_buf_L[:] = in_buf_L[:] + out_buf_R[:] = in_buf_R[:] + + monitor = self.ext_monitors[ext_key] + if not monitor.monitoring: + monitor.buffer[:] = 0.0 + continue + + rms = np.sqrt(np.mean((in_buf_L ** 2 + in_buf_R ** 2) / 2.0)) + if rms > gate_linear: + db = 20 * np.log10(max(rms, 1e-10)) + display = max(0.0, (db + 46.0) / 46.0) + display = min(1.0, display) + else: + display = 0.0 + + monitor.buffer[monitor.write_pos] = display + monitor.write_pos = (monitor.write_pos + 1) % monitor.buffer_size + # ── Mixer ─────────────────────────────────────────────────────── master_buf_L = self.master_out[0].get_array() master_buf_R = self.master_out[1].get_array() @@ -363,12 +412,23 @@ class AudioEngine: any_solo = any(ch.solo for ch in self.mixer['channels']) + # PFL bus + pfl_buf_L = self.pfl_out[0].get_array() + pfl_buf_R = self.pfl_out[1].get_array() + pfl_buf_L.fill(0.0) + pfl_buf_R.fill(0.0) + for ch in self.mixer['channels']: in_L = ch.in_port_L.get_array() in_R = ch.in_port_R.get_array() mute = ch.muted or (any_solo and not ch.solo) + # PFL: pre-fader, pre-mute signal to PFL bus + if ch.pfl: + pfl_buf_L[:] += in_L[:] + pfl_buf_R[:] += in_R[:] + if mute: ch.vu_peak = 0.0 continue @@ -390,15 +450,12 @@ class AudioEngine: self.mixer['master'].compressor.process(master_audio) self.mixer['master'].limiter.process(master_audio) - # Copy processed audio back to port buffers master_buf_L[:] = master_audio[0] master_buf_R[:] = master_audio[1] - # Master VU master_peak = max(np.max(np.abs(master_audio[0])), np.max(np.abs(master_audio[1]))) self.mixer['master'].vu_peak = master_peak - # LUFS calculation (every ~512 frames) if frames % 512 < frames: self._calculate_lufs(master_audio) diff --git a/cart_widget.py b/cart_widget.py index 2a78d4c..c418483 100644 --- a/cart_widget.py +++ b/cart_widget.py @@ -18,7 +18,6 @@ class CartSlot(QFrame): self.slot_number = slot_number self.engine = engine self._playing_icon = False - self._playing_icon = False self.setFrameStyle(QFrame.Box | QFrame.Raised) self.setLineWidth(1) @@ -146,7 +145,6 @@ class CartSlot(QFrame): root.addLayout(btn_col) def apply_theme(self, theme: dict): - """Apply theme to cart slot LCD.""" self.lcd_frame.setStyleSheet(f""" QFrame {{ background-color: {theme['bg']}; @@ -291,7 +289,5 @@ class CartBankWidget(QWidget): root.addLayout(grid) def apply_theme(self, theme: dict): - """Propagate theme to all cart slots.""" for slot in self.slots: - slot.apply_theme(theme) - \ No newline at end of file + slot.apply_theme(theme) \ No newline at end of file diff --git a/deck_widget.py b/deck_widget.py index 902bca4..6b41b53 100644 --- a/deck_widget.py +++ b/deck_widget.py @@ -5,7 +5,7 @@ from PySide6.QtWidgets import ( QPushButton, QFrame, QSizePolicy ) from PySide6.QtCore import Qt, QTimer, Signal, QSize -from PySide6.QtGui import QFont, QColor, QPainter, QPen +from PySide6.QtGui import QFont, QColor, QPainter, QPen, QPainterPath import numpy as np import os @@ -16,8 +16,6 @@ from icons import ( class WaveformWidget(QFrame): - """Displays downsampled waveform with playhead position.""" - def __init__(self, parent=None): super().__init__(parent) self.setFixedHeight(60) @@ -25,7 +23,7 @@ class WaveformWidget(QFrame): QFrame { background-color: #050505; border: 1px solid #1a1a1a; - border-radius: 3px; + border-radius: 4px; } """) @@ -41,7 +39,6 @@ class WaveformWidget(QFrame): mono = np.mean(audio, axis=0) width = max(500, self.width()) - samples_per_pixel = max(1, len(mono) // width) num_pixels = len(mono) // samples_per_pixel @@ -72,7 +69,6 @@ class WaveformWidget(QFrame): max_val = np.max(self.waveform_data) if np.max(self.waveform_data) > 0 else 1.0 scaled = (self.waveform_data / max_val) * (height * 0.8) - x_scale = width / len(self.waveform_data) for i, rms in enumerate(self.waveform_data): @@ -81,7 +77,6 @@ class WaveformWidget(QFrame): y_top = rect.y() + (height - bar_h) // 2 pct = i / len(self.waveform_data) - if pct < self.position_pct: color = QColor("#004400") elif pct > 0.85: @@ -101,6 +96,80 @@ class WaveformWidget(QFrame): painter.drawLine(playhead_x, rect.y(), playhead_x, rect.y() + height) +class WaveformMonitor(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setStyleSheet(""" + QFrame { + background-color: #050a05; + border: 1px solid #1a2a1a; + border-radius: 4px; + } + """) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = self.rect().adjusted(3, 3, -3, -3) + if rect.width() < 2 or rect.height() < 2: + painter.end() + return + + painter.fillRect(rect, QColor("#050a05")) + + pen = QPen(QColor("#1a2a1a"), 1) + painter.setPen(pen) + painter.drawRect(rect) + + engine = getattr(self, '_engine', None) + monitor_key = getattr(self, '_monitor_key', None) + if engine is None or monitor_key is None: + painter.end() + return + + monitor = engine.ext_monitors.get(monitor_key) + if monitor is None or not monitor.monitoring: + pen = QPen(QColor("#0a1a0a"), 1) + painter.setPen(pen) + mid_y = rect.center().y() + painter.drawLine(rect.left(), mid_y, rect.right(), mid_y) + painter.end() + return + + buf = monitor.buffer + w = rect.width() + h = rect.height() + mid_y = rect.y() + h // 2 + + path = QPainterPath() + path.moveTo(rect.right(), mid_y) + for i in range(len(buf)): + x = rect.right() - int((i / len(buf)) * w) + val = buf[(monitor.write_pos - i) % len(buf)] + y = mid_y - int(val * (h * 0.45)) + if i == 0: + path.lineTo(x, y) + else: + path.lineTo(x, y) + path.lineTo(rect.right(), mid_y) + path.closeSubpath() + + painter.setPen(QPen(QColor("#00ff41"), 1)) + painter.setBrush(QColor(0, 255, 65, 30)) + painter.drawPath(path) + + pen = QPen(QColor("#00ff41"), 1) + painter.setPen(pen) + for i in range(len(buf)): + x = rect.right() - int((i / len(buf)) * w) + val = buf[(monitor.write_pos - i) % len(buf)] + y = mid_y - int(val * (h * 0.45)) + if val > 0.01: + painter.drawLine(x, y, x, mid_y) + + painter.end() + + class LCDDisplay(QFrame): def __init__(self, parent=None): super().__init__(parent) @@ -119,9 +188,7 @@ class LCDDisplay(QFrame): self.track_label = QLabel("NO TRACK LOADED") self.track_label.setFont(QFont("Courier New", 13, QFont.Bold)) - self.track_label.setStyleSheet( - "color: #00ff41; background: transparent;" - ) + self.track_label.setStyleSheet("color: #00ff41; background: transparent;") self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) self.track_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) @@ -130,16 +197,12 @@ class LCDDisplay(QFrame): self.elapsed_label = QLabel("00:00") self.elapsed_label.setFont(QFont("Courier New", 32, QFont.Bold)) - self.elapsed_label.setStyleSheet( - "color: #00ff41; background: transparent;" - ) + self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent;") self.elapsed_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) self.remaining_label = QLabel("-00:00") self.remaining_label.setFont(QFont("Courier New", 18, QFont.Bold)) - self.remaining_label.setStyleSheet( - "color: #00cc33; background: transparent;" - ) + self.remaining_label.setStyleSheet("color: #00cc33; background: transparent;") self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) time_row.addWidget(self.elapsed_label) @@ -150,15 +213,11 @@ class LCDDisplay(QFrame): self.status_label = QLabel("STOPPED") self.status_label.setFont(QFont("Courier New", 11, QFont.Bold)) - self.status_label.setStyleSheet( - "color: #008822; background: transparent;" - ) + self.status_label.setStyleSheet("color: #008822; background: transparent;") self.queue_label = QLabel("QUEUE: EMPTY") self.queue_label.setFont(QFont("Courier New", 10)) - self.queue_label.setStyleSheet( - "color: #008822; background: transparent;" - ) + self.queue_label.setStyleSheet("color: #008822; background: transparent;") self.queue_label.setAlignment(Qt.AlignRight) status_row.addWidget(self.status_label) @@ -190,33 +249,19 @@ class LCDDisplay(QFrame): def set_playing(self, playing: bool): colour = "#00ff41" if playing else "#00aa2a" - self.elapsed_label.setStyleSheet( - f"color: {colour}; background: transparent;" - ) + self.elapsed_label.setStyleSheet(f"color: {colour}; background: transparent;") def flash_warning(self, active: bool): - """Flash all time displays red when in warning zone.""" if active: - self.elapsed_label.setStyleSheet( - "color: #ff4444; background: transparent;" - ) - self.remaining_label.setStyleSheet( - "color: #ff4444; background: transparent;" - ) + self.elapsed_label.setStyleSheet("color: #ff4444; background: transparent;") + self.remaining_label.setStyleSheet("color: #ff4444; background: transparent;") else: - self.elapsed_label.setStyleSheet( - "color: #00ff41; background: transparent;" - ) - self.remaining_label.setStyleSheet( - "color: #00cc33; background: transparent;" - ) + self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent;") + self.remaining_label.setStyleSheet("color: #00cc33; background: transparent;") -def make_icon_button(icon: QIcon, - color: str = "#3a3a3a", - icon_color: str = "white", - width: int = 52, - height: int = 48) -> QPushButton: +def make_icon_button(icon, color="#3a3a3a", icon_color="white", + width=52, height=48): btn = QPushButton() btn.setFixedSize(width, height) btn.setIconSize(QSize(22, 22)) @@ -250,9 +295,7 @@ class DeckWidget(QWidget): self.current_theme = None self.flash_state = False self._playing_icon = False - self._playing_icon = False - # Convert "DECK 1" to "DECK_001" deck_num = label.split()[-1] self.deck_label = f"DECK_{int(deck_num):03d}" @@ -321,10 +364,9 @@ class DeckWidget(QWidget): def apply_theme(self, theme: dict): self.current_theme = theme - border_color = theme['border'] bg_color = theme['bg2'] - + self.setStyleSheet(f""" QWidget {{ background-color: {bg_color}; @@ -332,7 +374,6 @@ class DeckWidget(QWidget): border-radius: 6px; }} """) - self.lcd.setStyleSheet(f""" QFrame {{ background-color: {theme['bg']}; @@ -340,7 +381,6 @@ class DeckWidget(QWidget): border-radius: 4px; }} """) - self.waveform.setStyleSheet(f""" QFrame {{ background-color: {theme['bg']}; @@ -466,7 +506,6 @@ class DeckWidget(QWidget): def _refresh_display(self): deck = self.engine.decks[self.deck_key] - # Auto-load queued track when current finishes if not deck.playing and deck.track is not None: if deck.position >= deck.track.num_samples - 1: if deck.queued: @@ -477,7 +516,6 @@ class DeckWidget(QWidget): self.lcd.update_queue("") self.lcd.update_status("LOADED") - # Sync button if stopped manually if not deck.playing and self._playing_icon: self.btn_play.setIcon(icon_play("#00ff41")) self._playing_icon = False @@ -493,33 +531,21 @@ class DeckWidget(QWidget): self.lcd.set_playing(False) if deck.track: - self.lcd.update_time( - deck.get_elapsed(), - deck.get_remaining() - ) - + self.lcd.update_time(deck.get_elapsed(), deck.get_remaining()) position_pct = deck.position / deck.track.num_samples remaining_sec = deck.track.duration - (deck.position / deck.track.sr) - - # Warning if < 20 seconds OR < 15% remaining in_warning = (remaining_sec < 20) or (position_pct > 0.85) - - # Flash header AND all numbers if in warning zone and playing + if deck.playing and in_warning: flash_color = '#ff4444' if self.flash_state else self.accent_color - - # Flash header self.header.setStyleSheet(f""" color: {flash_color}; background: transparent; border: none; padding: 4px; """) - - # Flash LCD numbers self.lcd.flash_warning(self.flash_state) else: - # Normal colors self.header.setStyleSheet(f""" color: {self.accent_color}; background: transparent; @@ -527,10 +553,121 @@ class DeckWidget(QWidget): padding: 4px; """) self.lcd.flash_warning(False) - + self.waveform.set_position(position_pct, in_warning and self.flash_state) if deck.queued: - self.lcd.update_queue( - os.path.basename(deck.queued.filepath) - ) \ No newline at end of file + self.lcd.update_queue(os.path.basename(deck.queued.filepath)) + + +class ExternalDeckWidget(QWidget): + def __init__(self, deck_key: str, label: str, accent_color: str, + engine, parent=None): + super().__init__(parent) + self.deck_key = deck_key + self.engine = engine + self.accent_color = accent_color + self._monitoring = False + + deck_num = label.split()[-1] + self.deck_label = f"EXT_{int(deck_num):03d}" + + self._build_ui() + + def _build_ui(self): + self.setStyleSheet(f""" + QWidget {{ + background-color: #1c1c1c; + border: 2px solid {self.accent_color}; + border-radius: 6px; + }} + """) + + root = QVBoxLayout(self) + root.setContentsMargins(10, 10, 10, 10) + root.setSpacing(8) + + self.header = QLabel(self.deck_label) + self.header.setFont(QFont("Courier New", 24, QFont.Bold)) + self.header.setStyleSheet(f""" + color: {self.accent_color}; + background: transparent; + border: none; + padding: 4px; + """) + self.header.setAlignment(Qt.AlignCenter) + root.addWidget(self.header) + + self.waveform = WaveformMonitor() + self.waveform._engine = self.engine + self.waveform._monitor_key = self.deck_key + self.waveform.setMinimumHeight(100) + root.addWidget(self.waveform, stretch=1) + + transport = QHBoxLayout() + transport.setSpacing(6) + + self.btn_play = make_icon_button(icon_play(), "#1a6b3a", "#00ff41", 68, 48) + transport.addStretch() + transport.addWidget(self.btn_play) + transport.addStretch() + + root.addLayout(transport) + + self._connect_signals() + + def _connect_signals(self): + self.btn_play.clicked.connect(self._toggle_monitor) + + def toggle_play(self): + self._toggle_monitor() + + def _toggle_monitor(self): + monitor = self.engine.ext_monitors[self.deck_key] + self._monitoring = not self._monitoring + monitor.monitoring = self._monitoring + + if self._monitoring: + self.btn_play.setIcon(icon_pause("#ffffff")) + self.btn_play.setStyleSheet(""" + QPushButton { + background-color: #00aa33; + border: 1px solid #00ff41; + border-radius: 4px; + } + QPushButton:hover { background-color: #00cc44; } + """) + self.header.setStyleSheet(f""" + color: #00ff41; + background: transparent; + border: none; + padding: 4px; + """) + else: + self.btn_play.setIcon(icon_play("#00ff41")) + self.btn_play.setStyleSheet(""" + QPushButton { + background-color: #1a6b3a; + border: 1px solid #555; + border-radius: 4px; + } + QPushButton:hover { background-color: #2a7b4a; } + """) + self.header.setStyleSheet(f""" + color: {self.accent_color}; + background: transparent; + border: none; + padding: 4px; + """) + self.waveform.update() + + def apply_theme(self, theme: dict): + border_color = theme['border'] + bg_color = theme['bg2'] + self.setStyleSheet(f""" + QWidget {{ + background-color: {bg_color}; + border: 2px solid {border_color}; + border-radius: 6px; + }} + """) diff --git a/icons.py b/icons.py index 51d0d10..9b17fd4 100644 --- a/icons.py +++ b/icons.py @@ -279,6 +279,72 @@ def icon_solo(color: str = "#ffaa00") -> QIcon: return _make_icon(paint, color) +def icon_cog(color: str = "#aaaaaa") -> QIcon: + def paint(p, r, c): + pen = QPen(c, r.width() * 0.1) + pen.setCapStyle(Qt.RoundCap) + p.setPen(pen) + p.setBrush(Qt.NoBrush) + # Outer circle (gear ring) + cx, cy = r.center().x(), r.center().y() + outer_r = min(r.width(), r.height()) * 0.42 + inner_r = outer_r * 0.6 + # Draw gear teeth (6 bumps) + import math + p.setBrush(QColor(c)) + for i in range(6): + angle = i * 60 - 30 + a_rad = math.radians(angle) + bx = cx + outer_r * math.cos(a_rad) + by = cy + outer_r * math.sin(a_rad) + tooth = QPainterPath() + tw = r.width() * 0.12 + th = r.width() * 0.18 + tooth.addRect(bx - tw/2, by - th/2, tw, th) + p.drawRect(bx - tw/2, by - th/2, tw, th) + # Center circle + p.setPen(Qt.NoPen) + p.setBrush(QColor(c)) + p.drawEllipse(QPointF(cx, cy), inner_r, inner_r) + # Inner hole + p.setBrush(QColor("#000000")) + p.drawEllipse(QPointF(cx, cy), inner_r * 0.55, inner_r * 0.55) + return _make_icon(paint, color) + + +def icon_cog(color: str = "#aaaaaa") -> QIcon: + def paint(p, r, c): + pen = QPen(c, r.width() * 0.1) + pen.setCapStyle(Qt.RoundCap) + p.setPen(pen) + p.setBrush(Qt.NoBrush) + # Outer circle (gear ring) + cx, cy = r.center().x(), r.center().y() + outer_r = min(r.width(), r.height()) * 0.42 + inner_r = outer_r * 0.6 + # Draw gear teeth (6 bumps) + import math + p.setBrush(QColor(c)) + for i in range(6): + angle = i * 60 - 30 + a_rad = math.radians(angle) + bx = cx + outer_r * math.cos(a_rad) + by = cy + outer_r * math.sin(a_rad) + tooth = QPainterPath() + tw = r.width() * 0.12 + th = r.width() * 0.18 + tooth.addRect(bx - tw/2, by - th/2, tw, th) + p.drawRect(bx - tw/2, by - th/2, tw, th) + # Center circle + p.setPen(Qt.NoPen) + p.setBrush(QColor(c)) + p.drawEllipse(QPointF(cx, cy), inner_r, inner_r) + # Inner hole + p.setBrush(QColor("#000000")) + p.drawEllipse(QPointF(cx, cy), inner_r * 0.55, inner_r * 0.55) + return _make_icon(paint, color) + + def icon_theme(color: str = "#00ff41") -> QIcon: def paint(p, r, c): p.setPen(Qt.NoPen) diff --git a/main.py b/main.py index 4fa7a40..5412fd3 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,17 @@ # main.py +import json +import os import sys from PySide6.QtWidgets import ( - QApplication, QMainWindow, QWidget, + 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 deck_widget import DeckWidget, ExternalDeckWidget from cart_widget import CartBankWidget from playlist_widget import PlaylistWidget from midi_engine import MidiEngine @@ -25,7 +27,7 @@ class RadioPanelWindow(QMainWindow): self.engine = AudioEngine() self.setWindowTitle("Getting To It") - self.setMinimumSize(1280, 720) + self.setMinimumSize(1400, 860) self.resize(1920, 1080) self._apply_global_style(THEMES[0]) @@ -33,31 +35,26 @@ class RadioPanelWindow(QMainWindow): self.setCentralWidget(central) outer = QVBoxLayout(central) - outer.setContentsMargins(8, 8, 8, 8) - outer.setSpacing(8) + 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) outer.addWidget(self.top_bar) # ── Build widgets ───────────────────────────────────────────────── - self.deck1 = DeckWidget( - deck_key="deck1", - label="DECK 1", - accent_color="#00897b", - engine=self.engine - ) - self.deck2 = DeckWidget( - deck_key="deck2", - label="DECK 2", - accent_color="#f9ca24", - engine=self.engine - ) + self.deck1 = DeckWidget("deck1", "DECK 1", "#00b894", self.engine) + self.deck2 = DeckWidget("deck2", "DECK 2", "#6c5ce7", self.engine) + self.deck3 = ExternalDeckWidget("deck3", "DECK 3", "#fdcb6e", self.engine) + self.deck4 = ExternalDeckWidget("deck4", "DECK 4", "#e17055", self.engine) self.cart_bank = CartBankWidget(self.engine) - self.cart_bank.setFixedHeight(280) + self.cart_bank.setFixedHeight(220) self.playlist_widget = PlaylistWidget( engine=self.engine, @@ -72,46 +69,52 @@ class RadioPanelWindow(QMainWindow): self.mixer = MixerWidget(self.engine) - # ── Centre column (decks + carts + mixer) ───────────────────────── + # ── Centre column with vertical splitter ───────────────────────── centre_col = QVBoxLayout() - centre_col.setSpacing(8) + centre_col.setSpacing(6) - # Top: decks row + carts + # Top: 4 decks in 2x2 grid + carts 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(8) + 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) + + deck_row2 = QHBoxLayout() + deck_row2.setSpacing(4) + deck_row2.addWidget(self.deck3) + deck_row2.addWidget(self.deck4) + deck_layout.addLayout(deck_row2) - deck_row = QHBoxLayout() - deck_row.setSpacing(8) - deck_row.addWidget(self.deck1) - deck_row.addWidget(self.deck2) - deck_layout.addLayout(deck_row) deck_layout.addWidget(self.cart_bank) - deck_layout.addStretch() - # Bottom: mixer - centre_col.addWidget(deck_area, stretch=1) - centre_col.addWidget(self.mixer) + # 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) - # ── Splitter ────────────────────────────────────────────────────── + # ── Horizontal splitter ────────────────────────────────────────── self.splitter = QSplitter(Qt.Horizontal) - self.splitter.setHandleWidth(6) + 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([400, 1120, 400]) + self.splitter.setSizes([360, 1160, 360]) outer.addWidget(self.splitter) - # ── Wire signals ────────────────────────────────────────────────── + # ── Wire signals ───────────────────────────────────────────────── self.deck1.track_started.connect(self.history_widget.add_track) self.deck2.track_started.connect(self.history_widget.add_track) @@ -121,8 +124,8 @@ class RadioPanelWindow(QMainWindow): def _style_splitter(self, theme: dict): self.splitter.setStyleSheet(f""" QSplitter::handle {{ - background-color: #333333; - border-radius: 3px; + background-color: #22242a; + border-radius: 2px; }} QSplitter::handle:hover {{ background-color: {theme['accent']}; @@ -137,10 +140,15 @@ 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.history_widget.apply_theme(theme) self.mixer.apply_theme(theme) + def _apply_ratio(self, w, h): + self.resize(w, h) + def _apply_global_style(self, theme: dict): self.setStyleSheet(f""" QMainWindow {{ @@ -149,7 +157,7 @@ class RadioPanelWindow(QMainWindow): QWidget {{ background-color: {theme['bg']}; color: {theme['text']}; - font-family: Arial; + font-family: "Segoe UI", Arial, sans-serif; }} QToolTip {{ background-color: {theme['bg2']}; @@ -159,6 +167,60 @@ class RadioPanelWindow(QMainWindow): }} """) + 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()), + 'channels': [], + } + + for ch in self.engine.mixer['channels']: + state['channels'].append({ + 'name': ch.name, + 'volume': ch.volume, + 'muted': ch.muted, + 'solo': ch.solo, + 'pfl': ch.pfl, + }) + + 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]) + + 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.muted = ch_state.get('muted', ch.muted) + ch.solo = ch_state.get('solo', ch.solo) + ch.pfl = ch_state.get('pfl', ch.pfl) + + self.mixer.refresh_strips() + def _setup_midi(self): self.midi = MidiEngine() @@ -168,15 +230,27 @@ class RadioPanelWindow(QMainWindow): 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_solo.connect(self._on_midi_mixer_solo) + self.midi.mixer_pfl.connect(self._on_midi_mixer_pfl) + self.midi.master_control.connect(self._on_midi_master_control) 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: + print(f"[MIDI] Available ports: {ports}") def _on_midi_play_pause(self, deck: str): if deck == 'deck1': 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': @@ -199,6 +273,31 @@ class RadioPanelWindow(QMainWindow): def _on_midi_mixer_mute(self, channel: int, muted: bool): self.engine.set_channel_mute(channel, muted) + def _on_midi_mixer_solo(self, channel: int, on: bool): + self.engine.set_channel_solo(channel, on) + + def _on_midi_mixer_pfl(self, channel: int, on: bool): + self.engine.set_channel_pfl(channel, on) + + def _on_midi_master_control(self, knob: str, val: float): + master = self.engine.mixer['master'] + attrs = { + "threshold_db": (-60, 0), + "ratio": (1, 20), + "attack_ms": (0.1, 50), + "release_ms": (10, 1000), + "makeup_gain_db": (0, 24), + "ceiling_db": (-10, 0), + } + if knob in attrs: + lo, hi = attrs[knob] + v = lo + val * (hi - lo) + if knob == "ratio": + v = round(max(1, v), 1) + elif knob in ("threshold_db", "ceiling_db", "makeup_gain_db"): + v = round(max(lo, min(hi, v)), 1) + setattr(master.compressor if knob != "ceiling_db" else master.limiter, knob, v) + def _on_midi_status(self, connected: bool, name: str): if connected: print(f"[MIDI] Connected: {name}") @@ -217,14 +316,14 @@ def main(): app.setOrganizationName("Getting To It") palette = QPalette() - palette.setColor(QPalette.Window, QColor("#0d0d0d")) - palette.setColor(QPalette.WindowText, QColor("#cccccc")) - palette.setColor(QPalette.Base, QColor("#111111")) - palette.setColor(QPalette.AlternateBase, QColor("#1a1a1a")) - palette.setColor(QPalette.Text, QColor("#cccccc")) - palette.setColor(QPalette.Button, QColor("#2a2a2a")) - palette.setColor(QPalette.ButtonText, QColor("#cccccc")) - palette.setColor(QPalette.Highlight, QColor("#1a3a5a")) + 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) @@ -235,4 +334,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/midi_engine.py b/midi_engine.py index 02479e6..0a35481 100644 --- a/midi_engine.py +++ b/midi_engine.py @@ -88,6 +88,9 @@ class MidiEngine(QObject): cart_trigger = Signal(int) mixer_volume = Signal(int, float) mixer_mute = Signal(int, bool) + mixer_solo = Signal(int, bool) + mixer_pfl = Signal(int, bool) + master_control = Signal(str, float) controller_connected = Signal(bool, str) mapping_learned = Signal(str, object, int, int, int, bool) @@ -226,12 +229,42 @@ class MidiEngine(QObject): target_id = binding['target_id'] inv = binding.get('inverse', False) - if action == 'mixer_volume': + if action == 'deck_play_pause' and isinstance(target_id, str): + self.deck_play_pause.emit(target_id) + elif action == 'deck_cue' and isinstance(target_id, str): + self.deck_cue.emit(target_id) + elif action == 'cart_trigger': + self.cart_trigger.emit(target_id) + elif action == 'deck_play_pause' and isinstance(target_id, str): + self.deck_play_pause.emit(target_id) + elif action == 'deck_cue' and isinstance(target_id, str): + self.deck_cue.emit(target_id) + elif action == 'cart_trigger': + self.cart_trigger.emit(target_id) + elif action == 'mixer_volume': scaled = (127 - value if inv else value) / 127.0 self.mixer_volume.emit(target_id, scaled) elif action == 'mixer_mute': muted = (value < 64) if inv else (value > 63) self.mixer_mute.emit(target_id, muted) + elif action == 'mixer_solo': + on = (value > 63) if not inv else (value < 64) + self.mixer_solo.emit(target_id, on) + elif action == 'mixer_pfl': + on = (value > 63) if not inv else (value < 64) + self.mixer_pfl.emit(target_id, on) + elif action == 'master_control': + scaled = (127 - value if inv else value) / 127.0 + self.master_control.emit(str(target_id), scaled) + elif action == 'mixer_solo': + on = (value > 63) if not inv else (value < 64) + self.mixer_solo.emit(target_id, on) + elif action == 'mixer_pfl': + on = (value > 63) if not inv else (value < 64) + self.mixer_pfl.emit(target_id, on) + elif action == 'master_control': + scaled = (127 - value if inv else value) / 127.0 + self.master_control.emit(str(target_id), scaled) def _handle_note_on(self, note: int): # Hardcoded Numark DJ2Go mapping (legacy) diff --git a/mixer_widget.py b/mixer_widget.py index a32abe3..0251795 100644 --- a/mixer_widget.py +++ b/mixer_widget.py @@ -3,31 +3,21 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QPushButton, QFrame, QScrollArea, QDial, QSizePolicy, - QInputDialog, QCheckBox + QInputDialog ) -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import Qt, QTimer, QSize from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen from audio_engine import NUM_MIXER_CHANNELS from icons import icon_mute, icon_solo -STYLESHEET = """ - QWidget {{ - background-color: {bg}; - color: {text}; - font-family: Arial; - }} -""" - - class VUMeter(QWidget): - def __init__(self, parent=None, orientation=Qt.Vertical): + def __init__(self, parent=None): super().__init__(parent) self._value = 0.0 - self._orientation = orientation - self.setMinimumWidth(24 if orientation == Qt.Vertical else 100) - self.setMinimumHeight(100 if orientation == Qt.Vertical else 24) + self.setMinimumWidth(28) + self.setMinimumHeight(80) def set_value(self, value: float): self._value = max(0.0, min(1.0, value)) @@ -37,26 +27,32 @@ class VUMeter(QWidget): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) - rect = self.rect().adjusted(2, 2, -2, -2) + rect = self.rect().adjusted(14, 3, -3, -3) if rect.width() < 1 or rect.height() < 1: painter.end() return - # Background - painter.fillRect(rect, QColor("#0a0a0a")) + painter.fillRect(rect, QColor("#080808")) + + db_markings = [(-40, "#1a1a1a"), (-30, "#1a1a1a"), (-20, "#1a1a1a"), + (-12, "#2a2a1a"), (-6, "#2a1a1a"), (0, "#2a0a0a")] + for db, color in db_markings: + pct = (db + 46) / 46.0 if db <= 0 else 1.0 + pct = max(0.0, min(1.0, pct)) + y = rect.bottom() - int(rect.height() * pct) + painter.setPen(QPen(QColor(color), 1)) + painter.drawLine(rect.right() - 5, y, rect.right(), y) + painter.setPen(QPen(QColor("#444444"), 1)) + painter.drawText(rect.left() - 13, y + 4, f"{db}") + + painter.setPen(QPen(QColor("#1a1a1a"), 1)) + painter.drawRect(rect) val = self._value if val < 1e-10: - painter.setPen(QPen(QColor("#222222"), 1)) - painter.drawRect(rect) painter.end() return - # Scale to dB-like (log-ish): map 0.0..1.0 to -40..0 dB display - db = -40 * (1 - val) if val < 1.0 else 3 - fill_pct = (db + 40) / 43.0 if db <= 0 else 1.0 - fill_pct = max(0.0, min(1.0, fill_pct)) - gradient = QLinearGradient(0, rect.bottom(), 0, rect.top()) gradient.setColorAt(0.0, QColor("#003300")) gradient.setColorAt(0.5, QColor("#00cc00")) @@ -64,14 +60,21 @@ class VUMeter(QWidget): gradient.setColorAt(0.9, QColor("#ff6600")) gradient.setColorAt(1.0, QColor("#ff0000")) - fill_h = int(rect.height() * fill_pct) - painter.fillRect( - rect.x(), rect.bottom() - fill_h, - rect.width(), fill_h, gradient - ) + db = -46 * (1 - val) if val < 1.0 else 3 + fill_pct = (db + 46) / 49.0 if db <= 0 else 1.0 + fill_pct = max(0.0, min(1.0, fill_pct)) + + fill_h = int(rect.height() * fill_pct) + painter.fillRect(rect.x(), rect.bottom() - fill_h, + rect.width(), fill_h, gradient) + + for db, _ in db_markings: + pct = (db + 46) / 46.0 if db <= 0 else 1.0 + pct = max(0.0, min(1.0, pct)) + y = rect.bottom() - int(rect.height() * pct) + painter.setPen(QPen(QColor("#000000"), 1)) + painter.drawLine(rect.x(), y, rect.right(), y) - painter.setPen(QPen(QColor("#333333"), 1)) - painter.drawRect(rect) painter.end() @@ -82,13 +85,13 @@ class ChannelStrip(QFrame): self.channel_index = channel_index self._channel = engine.get_channel(channel_index) - self.setFixedWidth(110) - self.setFrameStyle(QFrame.Box | QFrame.Raised) + self.setMinimumWidth(90) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.setStyleSheet(""" - QFrame { - background-color: #151515; - border: 1px solid #333333; - border-radius: 4px; + ChannelStrip { + background-color: #181a1e; + border: 1px solid #282a30; + border-radius: 6px; } """) @@ -105,109 +108,117 @@ class ChannelStrip(QFrame): root.setContentsMargins(4, 6, 4, 6) root.setSpacing(4) - # Name label self.name_btn = QPushButton(self._channel.name) - self.name_btn.setFont(QFont("Arial", 9, QFont.Bold)) + self.name_btn.setFont(QFont("Segoe UI", 8, QFont.Bold)) self.name_btn.setStyleSheet(""" QPushButton { background-color: transparent; - color: #aaaaaa; + color: #999aaa; border: none; padding: 2px; + font-size: 8pt; } QPushButton:hover { color: #ffffff; - background-color: #222222; + background-color: #22252c; border-radius: 3px; } """) root.addWidget(self.name_btn) - # VU Meter - self.vu_meter = VUMeter(orientation=Qt.Vertical) - self.vu_meter.setMinimumHeight(120) + self.vu_meter = VUMeter() root.addWidget(self.vu_meter, stretch=1) - # Volume slider - vol_row = QHBoxLayout() - vol_row.setSpacing(2) - vol_label = QLabel("V") - vol_label.setFont(QFont("Arial", 8)) - vol_label.setStyleSheet("color: #666666; background: transparent; border: none;") - vol_row.addWidget(vol_label) - self.volume_slider = QSlider(Qt.Vertical) self.volume_slider.setRange(0, 100) self.volume_slider.setValue(100) - self.volume_slider.setFixedHeight(80) + self.volume_slider.setFixedHeight(60) self.volume_slider.setStyleSheet(""" QSlider::groove:vertical { - background: #222222; - width: 6px; - border-radius: 3px; - } - QSlider::handle:vertical { - background: #666666; - height: 12px; - width: 16px; - margin: -4px -4px; + background: #22242a; + width: 4px; border-radius: 2px; } - QSlider::handle:vertical:hover { - background: #aaaaaa; + QSlider::handle:vertical { + background: #8890a0; + height: 12px; + width: 14px; + margin: -4px -5px; + border-radius: 2px; } + QSlider::handle:vertical:hover { background: #b0b8c8; } QSlider::sub-page:vertical { - background: #00aa44; - border-radius: 3px; + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #00cc66, stop:1 #008844); + border-radius: 2px; } """) - vol_row.addWidget(self.volume_slider, alignment=Qt.AlignCenter) - root.addLayout(vol_row) + root.addWidget(self.volume_slider, alignment=Qt.AlignCenter) - # Mute / Solo buttons btn_row = QHBoxLayout() - btn_row.setSpacing(4) + btn_row.setSpacing(3) self.mute_btn = QPushButton() - self.mute_btn.setFixedSize(32, 28) - self.mute_btn.setIconSize(self.mute_btn.size() * 0.7) + self.mute_btn.setFixedSize(28, 24) + self.mute_btn.setIconSize(QSize(16, 16)) self.mute_btn.setIcon(icon_mute()) self.mute_btn.setCheckable(True) + self.mute_btn.setToolTip("Mute") self.mute_btn.setStyleSheet(""" QPushButton { - background-color: #2a1a1a; - border: 1px solid #4a2a2a; + background-color: #242026; + border: 1px solid #3a303a; border-radius: 3px; } QPushButton:checked { - background-color: #5a2020; + background-color: #4a1a1a; border: 1px solid #ff4444; } - QPushButton:hover { background-color: #3a2a2a; } + QPushButton:hover { background-color: #343036; } """) - self.mute_btn.setToolTip("Mute") btn_row.addWidget(self.mute_btn) self.solo_btn = QPushButton() - self.solo_btn.setFixedSize(32, 28) - self.solo_btn.setIconSize(self.solo_btn.size() * 0.7) + self.solo_btn.setFixedSize(28, 24) + self.solo_btn.setIconSize(QSize(16, 16)) self.solo_btn.setIcon(icon_solo()) self.solo_btn.setCheckable(True) + self.solo_btn.setToolTip("Solo") self.solo_btn.setStyleSheet(""" QPushButton { - background-color: #2a2a1a; - border: 1px solid #4a4a2a; + background-color: #262420; + border: 1px solid #3a3830; border-radius: 3px; } QPushButton:checked { - background-color: #5a5a20; + background-color: #4a4a1a; border: 1px solid #ffaa00; } - QPushButton:hover { background-color: #3a3a2a; } + QPushButton:hover { background-color: #363430; } """) - self.solo_btn.setToolTip("Solo") btn_row.addWidget(self.solo_btn) + self.pfl_btn = QPushButton("PFL") + self.pfl_btn.setFixedSize(28, 24) + self.pfl_btn.setFont(QFont("Segoe UI", 7, QFont.Bold)) + self.pfl_btn.setCheckable(True) + self.pfl_btn.setToolTip("Pre-Fader Listen") + self.pfl_btn.setStyleSheet(""" + QPushButton { + background-color: #202426; + border: 1px solid #303a3a; + border-radius: 3px; + color: #668; + } + QPushButton:checked { + background-color: #1a3a4a; + border: 1px solid #00aaff; + color: #00ccff; + } + QPushButton:hover { background-color: #303436; } + """) + btn_row.addWidget(self.pfl_btn) + root.addLayout(btn_row) def _connect_signals(self): @@ -215,11 +226,11 @@ class ChannelStrip(QFrame): self.volume_slider.valueChanged.connect(self._on_volume_changed) self.mute_btn.toggled.connect(self._on_mute_toggled) self.solo_btn.toggled.connect(self._on_solo_toggled) + self.pfl_btn.toggled.connect(self._on_pfl_toggled) def _rename(self): name, ok = QInputDialog.getText( - self, "Rename Channel", - "Channel name:", + self, "Rename Channel", "Channel name:", text=self._channel.name ) if ok and name.strip(): @@ -235,6 +246,9 @@ class ChannelStrip(QFrame): def _on_solo_toggled(self, checked: bool): self.engine.set_channel_solo(self.channel_index, checked) + def _on_pfl_toggled(self, checked: bool): + self.engine.set_channel_pfl(self.channel_index, checked) + def _refresh(self): self.vu_meter.set_value(self._channel.vu_peak) @@ -248,13 +262,13 @@ class MasterSection(QFrame): self.engine = engine self._master = engine.mixer['master'] - self.setFixedWidth(180) - self.setFrameStyle(QFrame.Box | QFrame.Raised) + self.setMinimumWidth(180) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.setStyleSheet(""" - QFrame { - background-color: #151515; - border: 1px solid #333333; - border-radius: 4px; + MasterSection { + background-color: #181a1e; + border: 1px solid #282a30; + border-radius: 6px; } """) @@ -268,70 +282,72 @@ class MasterSection(QFrame): def _build_ui(self): root = QVBoxLayout(self) - root.setContentsMargins(6, 6, 6, 6) - root.setSpacing(6) + root.setContentsMargins(8, 6, 8, 6) + root.setSpacing(4) - # Header header = QLabel("MASTER") - header.setFont(QFont("Arial", 10, QFont.Bold)) + header.setFont(QFont("Segoe UI", 10, QFont.Bold)) header.setAlignment(Qt.AlignCenter) - header.setStyleSheet("color: #00ff41; background: transparent; border: none;") + header.setStyleSheet("color: #00cc88; background: transparent; border: none;") root.addWidget(header) - # VU Meter - self.vu_meter = VUMeter(orientation=Qt.Vertical) - self.vu_meter.setMinimumHeight(100) + self.vu_meter = VUMeter() root.addWidget(self.vu_meter) - # LUFS readout self.lufs_label = QLabel("-70.0 LUFS") - self.lufs_label.setFont(QFont("Courier New", 10, QFont.Bold)) + self.lufs_label.setFont(QFont("Segoe UI", 9, QFont.Bold)) self.lufs_label.setAlignment(Qt.AlignCenter) - self.lufs_label.setStyleSheet("color: #00ff41; background: transparent; border: none;") + self.lufs_label.setStyleSheet("color: #00cc88; background: transparent; border: none;") root.addWidget(self.lufs_label) - # Compressor section + pfl_indicator = QLabel("PFL: —") + self.pfl_indicator = pfl_indicator + pfl_indicator.setFont(QFont("Segoe UI", 8)) + pfl_indicator.setAlignment(Qt.AlignCenter) + pfl_indicator.setStyleSheet("color: #556; background: transparent; border: none;") + root.addWidget(pfl_indicator) + comp_frame = QFrame() comp_frame.setStyleSheet(""" QFrame { - background-color: #111111; - border: 1px solid #2a2a2a; - border-radius: 3px; + background-color: #121318; + border: 1px solid #22242a; + border-radius: 4px; } """) comp_layout = QVBoxLayout(comp_frame) comp_layout.setContentsMargins(4, 4, 4, 4) - comp_layout.setSpacing(3) + comp_layout.setSpacing(2) - comp_header_row = QHBoxLayout() - comp_label = QLabel("COMPRESSOR") - comp_label.setFont(QFont("Arial", 8, QFont.Bold)) - comp_label.setStyleSheet("color: #888888; background: transparent; border: none;") - comp_header_row.addWidget(comp_label) + comp_header = QHBoxLayout() + cl = QLabel("COMPRESSOR") + cl.setFont(QFont("Segoe UI", 7, QFont.Bold)) + cl.setStyleSheet("color: #778; background: transparent; border: none;") + comp_header.addWidget(cl) self.comp_bypass = QPushButton("B") - self.comp_bypass.setFixedSize(24, 20) + self.comp_bypass.setFixedSize(22, 18) self.comp_bypass.setCheckable(True) - self.comp_bypass.setFont(QFont("Arial", 8, QFont.Bold)) + self.comp_bypass.setFont(QFont("Segoe UI", 7, QFont.Bold)) self.comp_bypass.setStyleSheet(""" QPushButton { - background-color: #2a2a2a; - color: #888888; - border: 1px solid #444; + background-color: #22242a; + color: #778; + border: 1px solid #333; border-radius: 2px; } QPushButton:checked { - background-color: #5a2020; + background-color: #4a1a1a; color: #ff6666; border: 1px solid #ff4444; } """) self.comp_bypass.setToolTip("Bypass compressor") - comp_header_row.addWidget(self.comp_bypass) - comp_layout.addLayout(comp_header_row) + comp_header.addWidget(self.comp_bypass) + comp_layout.addLayout(comp_header) - knobs_row = QHBoxLayout() - knobs_row.setSpacing(2) + knobs = QHBoxLayout() + knobs.setSpacing(4) for label, attr, default, lo, hi, suffix in [ ("THR", "threshold_db", -20, -60, 0, "dB"), ("RAT", "ratio", 4, 1, 20, ":1"), @@ -339,60 +355,58 @@ class MasterSection(QFrame): ("REL", "release_ms", 100, 10, 1000, "ms"), ("MKG", "makeup_gain_db", 0, 0, 24, "dB"), ]: - k = self._make_knob(label, self._master.compressor, attr, default, lo, hi, suffix) - knobs_row.addWidget(k) - comp_layout.addLayout(knobs_row) - + k = self._make_knob(label, self._master.compressor, attr, + default, lo, hi, suffix) + knobs.addWidget(k) + comp_layout.addLayout(knobs) root.addWidget(comp_frame) - # Limiter section lim_frame = QFrame() lim_frame.setStyleSheet(""" QFrame { - background-color: #111111; - border: 1px solid #2a2a2a; - border-radius: 3px; + background-color: #121318; + border: 1px solid #22242a; + border-radius: 4px; } """) lim_layout = QVBoxLayout(lim_frame) lim_layout.setContentsMargins(4, 4, 4, 4) - lim_layout.setSpacing(3) + lim_layout.setSpacing(2) - lim_header_row = QHBoxLayout() - lim_label = QLabel("LIMITER") - lim_label.setFont(QFont("Arial", 8, QFont.Bold)) - lim_label.setStyleSheet("color: #888888; background: transparent; border: none;") - lim_header_row.addWidget(lim_label) + lim_header = QHBoxLayout() + ll = QLabel("LIMITER") + ll.setFont(QFont("Segoe UI", 7, QFont.Bold)) + ll.setStyleSheet("color: #778; background: transparent; border: none;") + lim_header.addWidget(ll) self.lim_bypass = QPushButton("B") - self.lim_bypass.setFixedSize(24, 20) + self.lim_bypass.setFixedSize(22, 18) self.lim_bypass.setCheckable(True) - self.lim_bypass.setFont(QFont("Arial", 8, QFont.Bold)) + self.lim_bypass.setFont(QFont("Segoe UI", 7, QFont.Bold)) self.lim_bypass.setStyleSheet(""" QPushButton { - background-color: #2a2a2a; - color: #888888; - border: 1px solid #444; + background-color: #22242a; + color: #778; + border: 1px solid #333; border-radius: 2px; } QPushButton:checked { - background-color: #5a2020; + background-color: #4a1a1a; color: #ff6666; border: 1px solid #ff4444; } """) self.lim_bypass.setToolTip("Bypass limiter") - lim_header_row.addWidget(self.lim_bypass) - lim_layout.addLayout(lim_header_row) + lim_header.addWidget(self.lim_bypass) + lim_layout.addLayout(lim_header) lim_knobs = QHBoxLayout() - lim_knobs.setSpacing(2) - k = self._make_knob("CEIL", self._master.limiter, "ceiling_db", -0.1, -10, 0, "dB") + lim_knobs.setSpacing(4) + k = self._make_knob("CEIL", self._master.limiter, "ceiling_db", + -0.1, -10, 0, "dB") lim_knobs.addWidget(k) lim_layout.addLayout(lim_knobs) - root.addWidget(lim_frame) - root.addStretch() def _make_knob(self, label, obj, attr, default, lo, hi, suffix): @@ -403,24 +417,23 @@ class MasterSection(QFrame): layout.setSpacing(1) lbl = QLabel(label) - lbl.setFont(QFont("Arial", 7, QFont.Bold)) + lbl.setFont(QFont("Segoe UI", 7, QFont.Bold)) lbl.setAlignment(Qt.AlignCenter) - lbl.setStyleSheet("color: #666666; background: transparent; border: none;") + lbl.setStyleSheet("color: #556; background: transparent; border: none;") layout.addWidget(lbl) dial = QDial() - dial.setFixedSize(32, 32) + dial.setFixedSize(44, 44) dial.setRange(0, 1000) dial.setNotchesVisible(False) dial.setStyleSheet(""" QDial { - background-color: #1a1a1a; - border: 1px solid #333; - border-radius: 16px; + background-color: #121318; + border: 1px solid #2a2c33; + border-radius: 22px; } """) - # Map value to dial position def _val_to_pos(v): return int((v - lo) / (hi - lo) * 1000) @@ -430,9 +443,9 @@ class MasterSection(QFrame): dial.setValue(_val_to_pos(default)) val_label = QLabel(f"{default}{suffix}") - val_label.setFont(QFont("Courier New", 8)) + val_label.setFont(QFont("Segoe UI", 10, QFont.Bold)) val_label.setAlignment(Qt.AlignCenter) - val_label.setStyleSheet("color: #aaaaaa; background: transparent; border: none;") + val_label.setStyleSheet("color: #ccd; background: transparent; border: none;") def _on_dial(v): val = _pos_to_val(v) @@ -454,7 +467,6 @@ class MasterSection(QFrame): dial.valueChanged.connect(_on_dial) layout.addWidget(dial, alignment=Qt.AlignCenter) layout.addWidget(val_label) - return frame def _connect_signals(self): @@ -468,6 +480,18 @@ class MasterSection(QFrame): def _refresh(self): self.vu_meter.set_value(self._master.vu_peak) self.lufs_label.setText(f"{self._master.lufs_current:.1f} LUFS") + active_pfl = [ch for ch in self.engine.mixer['channels'] if ch.pfl] + if active_pfl: + names = ", ".join(ch.name[:6] for ch in active_pfl) + self.pfl_indicator.setText(f"PFL: {names}") + self.pfl_indicator.setStyleSheet( + "color: #00aaff; background: transparent; border: none;" + ) + else: + self.pfl_indicator.setText("PFL: —") + self.pfl_indicator.setStyleSheet( + "color: #556; background: transparent; border: none;" + ) def apply_theme(self, theme: dict): pass @@ -477,13 +501,11 @@ class MixerWidget(QFrame): def __init__(self, engine, parent=None): super().__init__(parent) self.engine = engine - - self.setFrameStyle(QFrame.Box | QFrame.Raised) self.setStyleSheet(""" - QFrame { - background-color: #0d0d0d; - border: 2px solid #333333; - border-radius: 6px; + MixerWidget { + background-color: #121318; + border: 1px solid #282a30; + border-radius: 8px; } """) @@ -491,34 +513,36 @@ class MixerWidget(QFrame): def _build_ui(self): root = QVBoxLayout(self) - root.setContentsMargins(8, 8, 8, 8) - root.setSpacing(6) + root.setContentsMargins(6, 4, 6, 6) + root.setSpacing(4) - # Header header = QLabel("MIXER") - header.setFont(QFont("Arial", 10, QFont.Bold)) - header.setStyleSheet("color: #666666; background: transparent; border: none;") + header.setFont(QFont("Segoe UI", 9, QFont.Bold)) + header.setStyleSheet("color: #556670; background: transparent; border: none; " + "letter-spacing: 2px;") root.addWidget(header) - # Scrollable channel strips scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - scroll.setFixedHeight(350) scroll.setStyleSheet(""" QScrollArea { border: none; background: transparent; } QScrollBar:horizontal { - background: #1a1a1a; - height: 8px; - border-radius: 4px; + background: #1a1c22; + height: 6px; + border-radius: 3px; } QScrollBar::handle:horizontal { - background: #444444; - border-radius: 4px; + background: #3a3c44; + border-radius: 3px; + } + QScrollBar::handle:horizontal:hover { background: #5a5c64; } + QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 0px; } """) @@ -534,12 +558,9 @@ class MixerWidget(QFrame): self._strips.append(strip) channels_layout.addWidget(strip) - # Master section self.master_section = MasterSection(self.engine) channels_layout.addWidget(self.master_section) - channels_layout.addStretch() - scroll.setWidget(scroll_content) root.addWidget(scroll) @@ -547,3 +568,12 @@ class MixerWidget(QFrame): for strip in self._strips: strip.apply_theme(theme) self.master_section.apply_theme(theme) + + def refresh_strips(self): + for strip in self._strips: + ch = strip._channel + strip.name_btn.setText(ch.name) + strip.volume_slider.setValue(int(ch.volume * 100)) + strip.mute_btn.setChecked(ch.muted) + strip.solo_btn.setChecked(ch.solo) + strip.pfl_btn.setChecked(ch.pfl) \ No newline at end of file diff --git a/top_bar_widget.py b/top_bar_widget.py index e44ad3c..97ada90 100644 --- a/top_bar_widget.py +++ b/top_bar_widget.py @@ -1,211 +1,61 @@ # top_bar_widget.py -from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton -from PySide6.QtCore import Qt, QTimer, Signal, QSize +from PySide6.QtWidgets import ( + QFrame, QHBoxLayout, QVBoxLayout, QLabel, QPushButton, + QComboBox, QScrollArea, QWidget +) +from PySide6.QtCore import Qt, QTimer, Signal, QSize, QPoint from PySide6.QtGui import QFont from datetime import datetime +from functools import partial -from icons import icon_theme - -from icons import icon_theme +from icons import icon_theme, icon_cog, icon_save, icon_load THEMES = [ - # Dark themes - { - 'name': 'GREEN', - 'accent': '#00ff41', - 'accent_dim': '#00aa2a', - 'bg': '#0d0d0d', - 'bg2': '#111111', - 'border': '#1a3a1a', - 'text': '#cccccc', - 'is_light': False, - }, - { - 'name': 'AMBER', - 'accent': '#ffaa00', - 'accent_dim': '#cc8800', - 'bg': '#0d0d00', - 'bg2': '#111100', - 'border': '#3a3000', - 'text': '#ddddcc', - 'is_light': False, - }, - { - 'name': 'BLUE', - 'accent': '#00aaff', - 'accent_dim': '#0077cc', - 'bg': '#00080d', - 'bg2': '#001122', - 'border': '#001a3a', - 'text': '#ccd8dd', - 'is_light': False, - }, - { - 'name': 'RED', - 'accent': '#ff4444', - 'accent_dim': '#cc2222', - 'bg': '#0d0000', - 'bg2': '#110000', - 'border': '#3a0000', - 'text': '#ddcccc', - 'is_light': False, - }, - { - 'name': 'PURPLE', - 'accent': '#cc00ff', - 'accent_dim': '#9900cc', - 'bg': '#0d000d', - 'bg2': '#1a001a', - 'border': '#3a003a', - 'text': '#ddccdd', - 'is_light': False, - }, - { - 'name': 'CYAN', - 'accent': '#00ffff', - 'accent_dim': '#00cccc', - 'bg': '#000d0d', - 'bg2': '#001a1a', - 'border': '#003a3a', - 'text': '#ccdddd', - 'is_light': False, - }, - { - 'name': 'ORANGE', - 'accent': '#ff8800', - 'accent_dim': '#cc6600', - 'bg': '#0d0700', - 'bg2': '#1a1100', - 'border': '#3a2200', - 'text': '#ddccbb', - 'is_light': False, - }, - { - 'name': 'PINK', - 'accent': '#ff1493', - 'accent_dim': '#cc0066', - 'bg': '#0d0005', - 'bg2': '#1a000a', - 'border': '#3a0015', - 'text': '#ddccdd', - 'is_light': False, - }, - { - 'name': 'LIME', - 'accent': '#ccff00', - 'accent_dim': '#99cc00', - 'bg': '#0a0d00', - 'bg2': '#111a00', - 'border': '#223a00', - 'text': '#ddddcc', - 'is_light': False, - }, - { - 'name': 'GOLD', - 'accent': '#ffd700', - 'accent_dim': '#ccaa00', - 'bg': '#0d0d05', - 'bg2': '#1a1a0a', - 'border': '#3a3a15', - 'text': '#ddddbb', - 'is_light': False, - }, - { - 'name': 'VIOLET', - 'accent': '#8a2be2', - 'accent_dim': '#6600cc', - 'bg': '#05000d', - 'bg2': '#0a0015', - 'border': '#15002a', - 'text': '#ccbbdd', - 'is_light': False, - }, - { - 'name': 'TURQUOISE', - 'accent': '#40e0d0', - 'accent_dim': '#20b0a0', - 'bg': '#000d0a', - 'bg2': '#001a15', - 'border': '#00382d', - 'text': '#ccddda', - 'is_light': False, - }, - { - 'name': 'CRIMSON', - 'accent': '#dc143c', - 'accent_dim': '#aa0022', - 'bg': '#0d0002', - 'bg2': '#1a0005', - 'border': '#3a000a', - 'text': '#ddcccc', - 'is_light': False, - }, - { - 'name': 'CHARTREUSE', - 'accent': '#7fff00', - 'accent_dim': '#5fcc00', - 'bg': '#050d00', - 'bg2': '#0a1a00', - 'border': '#153a00', - 'text': '#ddddcc', - 'is_light': False, - }, - # Light themes - { - 'name': 'LIGHT BLUE', - 'accent': '#0066cc', - 'accent_dim': '#0044aa', - 'bg': '#f5f5f5', - 'bg2': '#e8e8e8', - 'border': '#bbbbbb', - 'text': '#222222', - 'is_light': True, - }, - { - 'name': 'LIGHT GREEN', - 'accent': '#00aa44', - 'accent_dim': '#008833', - 'bg': '#f0f5f0', - 'bg2': '#e0e8e0', - 'border': '#aaccaa', - 'text': '#112211', - 'is_light': True, - }, - { - 'name': 'LIGHT AMBER', - 'accent': '#cc8800', - 'accent_dim': '#aa6600', - 'bg': '#f5f3f0', - 'bg2': '#e8e5e0', - 'border': '#ccbb99', - 'text': '#221100', - 'is_light': True, - }, - { - 'name': 'LIGHT PURPLE', - 'accent': '#8844cc', - 'accent_dim': '#6622aa', - 'bg': '#f3f0f5', - 'bg2': '#e5e0e8', - 'border': '#ccbbdd', - 'text': '#221122', - 'is_light': True, - }, + {'name': 'DARK TEAL', 'accent': '#00b894', 'accent_dim': '#00876a', 'bg': '#121418', 'bg2': '#1a1c23', 'border': '#2a2c35', 'text': '#d0d4dc', 'is_light': False}, + {'name': 'GREEN', 'accent': '#00ff41', 'accent_dim': '#00aa2a', 'bg': '#0d0d0d', 'bg2': '#111111', 'border': '#1a3a1a', 'text': '#cccccc', 'is_light': False}, + {'name': 'AMBER', 'accent': '#ffaa00', 'accent_dim': '#cc8800', 'bg': '#0d0d00', 'bg2': '#111100', 'border': '#3a3000', 'text': '#ddddcc', 'is_light': False}, + {'name': 'BLUE', 'accent': '#00aaff', 'accent_dim': '#0077cc', 'bg': '#00080d', 'bg2': '#001122', 'border': '#001a3a', 'text': '#ccd8dd', 'is_light': False}, + {'name': 'RED', 'accent': '#ff4444', 'accent_dim': '#cc2222', 'bg': '#0d0000', 'bg2': '#110000', 'border': '#3a0000', 'text': '#ddcccc', 'is_light': False}, + {'name': 'PURPLE', 'accent': '#cc00ff', 'accent_dim': '#9900cc', 'bg': '#0d000d', 'bg2': '#1a001a', 'border': '#3a003a', 'text': '#ddccdd', 'is_light': False}, + {'name': 'CYAN', 'accent': '#00ffff', 'accent_dim': '#00cccc', 'bg': '#000d0d', 'bg2': '#001a1a', 'border': '#003a3a', 'text': '#ccdddd', 'is_light': False}, + {'name': 'ORANGE', 'accent': '#ff8800', 'accent_dim': '#cc6600', 'bg': '#0d0700', 'bg2': '#1a1100', 'border': '#3a2200', 'text': '#ddccbb', 'is_light': False}, + {'name': 'PINK', 'accent': '#ff1493', 'accent_dim': '#cc0066', 'bg': '#0d0005', 'bg2': '#1a000a', 'border': '#3a0015', 'text': '#ddccdd', 'is_light': False}, + {'name': 'LIME', 'accent': '#ccff00', 'accent_dim': '#99cc00', 'bg': '#0a0d00', 'bg2': '#111a00', 'border': '#223a00', 'text': '#ddddcc', 'is_light': False}, + {'name': 'GOLD', 'accent': '#ffd700', 'accent_dim': '#ccaa00', 'bg': '#0d0d05', 'bg2': '#1a1a0a', 'border': '#3a3a15', 'text': '#ddddbb', 'is_light': False}, + {'name': 'VIOLET', 'accent': '#8a2be2', 'accent_dim': '#6600cc', 'bg': '#05000d', 'bg2': '#0a0015', 'border': '#15002a', 'text': '#ccbbdd', 'is_light': False}, + {'name': 'TURQUOISE', 'accent': '#40e0d0', 'accent_dim': '#20b0a0', 'bg': '#000d0a', 'bg2': '#001a15', 'border': '#00382d', 'text': '#ccddda', 'is_light': False}, + {'name': 'CRIMSON', 'accent': '#dc143c', 'accent_dim': '#aa0022', 'bg': '#0d0002', 'bg2': '#1a0005', 'border': '#3a000a', 'text': '#ddcccc', 'is_light': False}, + {'name': 'CHARTREUSE', 'accent': '#7fff00', 'accent_dim': '#5fcc00', 'bg': '#050d00', 'bg2': '#0a1a00', 'border': '#153a00', 'text': '#ddddcc', 'is_light': False}, + {'name': 'PAPER WHITE', 'accent': '#0066cc', 'accent_dim': '#004488', 'bg': '#ffffff', 'bg2': '#eeeeee', 'border': '#cccccc', 'text': '#111111', 'is_light': True}, + {'name': 'GREY PAPER', 'accent': '#cc3300', 'accent_dim': '#992200', 'bg': '#e8e8e8', 'bg2': '#d8d8d8', 'border': '#aaaaaa', 'text': '#111111', 'is_light': True}, + {'name': 'CREAM', 'accent': '#884422', 'accent_dim': '#663311', 'bg': '#f5f0e8', 'bg2': '#e8e0d4', 'border': '#c0b8a8', 'text': '#221100', 'is_light': True}, + {'name': 'FROST', 'accent': '#0088aa', 'accent_dim': '#006688', 'bg': '#f0f4f8', 'bg2': '#e0e8f0', 'border': '#b0c0d0', 'text': '#001122', 'is_light': True}, + {'name': 'LIGHT LAVENDER','accent': '#6633cc','accent_dim': '#4422aa', 'bg': '#f5f0fa', 'bg2': '#e8e0f0', 'border': '#c0b8d0', 'text': '#110022', 'is_light': True}, +] + +RATIOS = [ + ("21:9", 2560, 1080), + ("16:9", 1920, 1080), + ("4:3", 1440, 1080), ] class TopBarWidget(QFrame): theme_changed = Signal(dict) + ratio_changed = Signal(int, int) + save_session = Signal() + load_session = Signal() def __init__(self, parent=None): super().__init__(parent) - self._theme_index = 0 + self._ratio_index = 1 + self._midi_engine = None self.setFrameStyle(QFrame.Box | QFrame.Raised) - self.setFixedHeight(80) + self.setFixedHeight(56) self._apply_bar_style() self._build_ui() @@ -214,13 +64,15 @@ class TopBarWidget(QFrame): self.timer.setInterval(1000) self.timer.timeout.connect(self._update) self.timer.start() - self._update() + def set_midi_engine(self, engine): + self._midi_engine = engine + def _apply_bar_style(self): theme = THEMES[self._theme_index] self.setStyleSheet(f""" - QFrame {{ + TopBarWidget {{ background-color: {theme['bg2']}; border: 2px solid {theme['border']}; border-radius: 6px; @@ -229,46 +81,68 @@ class TopBarWidget(QFrame): def _build_ui(self): layout = QHBoxLayout(self) - layout.setContentsMargins(20, 8, 20, 8) - layout.setSpacing(12) + layout.setContentsMargins(16, 4, 16, 4) + layout.setSpacing(8) - # Theme cycle button self.theme_btn = QPushButton() - self.theme_btn.setFixedSize(40, 40) - self.theme_btn.setIconSize(QSize(22, 22)) + self.theme_btn.setFixedSize(30, 30) + self.theme_btn.setIconSize(QSize(16, 16)) self.theme_btn.setIcon(icon_theme()) self.theme_btn.setToolTip("Cycle colour theme") self.theme_btn.clicked.connect(self._cycle_theme) self._style_theme_btn() layout.addWidget(self.theme_btn) - # Station name self.station_label = QLabel("GETTING TO IT") - self.station_label.setFont(QFont("Arial", 16, QFont.Bold)) - self.station_label.setStyleSheet( - "color: #444444; background: transparent; border: none;" - ) - self.station_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self.station_label.setFont(QFont("Arial", 13, QFont.Bold)) + self.station_label.setStyleSheet("color: #555555; background: transparent; border: none;") layout.addWidget(self.station_label) + self.settings_btn = QPushButton() + self.settings_btn.setFixedSize(24, 24) + self.settings_btn.setIconSize(QSize(14, 14)) + self.settings_btn.setIcon(icon_cog("#666666")) + self.settings_btn.setToolTip("Settings") + self.settings_btn.setStyleSheet(""" + QPushButton { + background-color: transparent; + border: 1px solid #3a3c44; + border-radius: 12px; + } + QPushButton:hover { background-color: #2a2c34; border-color: #5a5c64; } + """) + self.settings_btn.clicked.connect(self._toggle_settings_popup) + layout.addWidget(self.settings_btn) + layout.addStretch() - # Date self.date_label = QLabel("") - self.date_label.setFont(QFont("Arial", 20, QFont.Bold)) - self.date_label.setAlignment(Qt.AlignCenter | Qt.AlignVCenter) + self.date_label.setFont(QFont("Arial", 13, QFont.Bold)) + self.date_label.setAlignment(Qt.AlignCenter) layout.addWidget(self.date_label) layout.addStretch() - # Clock self.clock_label = QLabel("") - self.clock_label.setFont(QFont("Courier New", 42, QFont.Bold)) + self.clock_label.setFont(QFont("Courier New", 28, QFont.Bold)) self.clock_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self.clock_label.setMinimumWidth(220) + self.clock_label.setMinimumWidth(170) self._style_clock_and_date() layout.addWidget(self.clock_label) + # ── Settings popup ───────────────────────────────────────────── + + def _toggle_settings_popup(self): + self._popup = SettingsPopup(self._midi_engine, self) + self._popup.ratio_changed.connect(self.ratio_changed.emit) + self._popup.save_session.connect(self.save_session.emit) + self._popup.load_session.connect(self.load_session.emit) + pos = self.settings_btn.mapToGlobal(QPoint(0, self.settings_btn.height() + 4)) + self._popup.move(pos) + self._popup.show() + + # ── Theme ────────────────────────────────────────────────────── + def _style_theme_btn(self): theme = THEMES[self._theme_index] self.theme_btn.setStyleSheet(f""" @@ -276,15 +150,12 @@ class TopBarWidget(QFrame): background-color: transparent; color: {theme['accent']}; border: 2px solid {theme['accent']}; - border-radius: 20px; + border-radius: 15px; }} QPushButton:hover {{ background-color: {theme['accent']}; color: {'#ffffff' if not theme['is_light'] else '#000000'}; }} - QPushButton:pressed {{ - background-color: {theme['accent_dim']}; - }} """) def _style_clock_and_date(self): @@ -292,7 +163,6 @@ class TopBarWidget(QFrame): self.clock_label.setStyleSheet( f"color: {theme['accent']}; background: transparent; border: none;" ) - # Date color adapts to light/dark date_color = '#555555' if theme['is_light'] else '#aaaaaa' self.date_label.setStyleSheet( f"color: {date_color}; background: transparent; border: none;" @@ -308,8 +178,304 @@ class TopBarWidget(QFrame): def _update(self): now = datetime.now() self.clock_label.setText(now.strftime("%H:%M:%S")) - self.date_label.setText(now.strftime("%A %d %B %Y")) + self.date_label.setText(now.strftime("%a %d %b")) @property def current_theme(self) -> dict: - return THEMES[self._theme_index] \ No newline at end of file + return THEMES[self._theme_index] + + +# ── Settings Popup ───────────────────────────────────────────────── + +POPUP_STYLE = """ + QFrame#settingsPopup { + background-color: #1a1c23; + border: 1px solid #2a2c35; + border-radius: 8px; + } + QPushButton#popupBtn { + background-color: #22242a; + color: #d0d4dc; + border: 1px solid #3a3c44; + border-radius: 3px; + padding: 3px 8px; + font-size: 7pt; + font-weight: bold; + } + QPushButton#popupBtn:hover { + background-color: #2a2c34; + border-color: #5a5c64; + } + QPushButton#ratioBtn { + background-color: #22242a; + color: #8890a0; + border: 1px solid #3a3c44; + border-radius: 12px; + padding: 2px 10px; + font-size: 7pt; + font-weight: bold; + } + QPushButton#ratioBtn:hover { + background-color: #2a2c34; + border-color: #5a5c64; + } + QComboBox#portCombo { + background-color: #22242a; + color: #d0d4dc; + border: 1px solid #3a3c44; + border-radius: 3px; + padding: 2px 4px; + font-size: 7pt; + min-height: 20px; + } + QComboBox#portCombo:hover { border-color: #5a5c64; } + QComboBox#portCombo::drop-down { border: none; width: 14px; } + QComboBox#portCombo QAbstractItemView { + background-color: #1a1c23; + color: #d0d4dc; + selection-background-color: #2a2c35; + border: 1px solid #3a3c44; + } + QScrollArea { border: none; background: transparent; } + QScrollBar:vertical { background: #1a1c22; width: 4px; border-radius: 2px; } + QScrollBar::handle:vertical { background: #3a3c44; border-radius: 2px; } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } +""" + + +class SettingsPopup(QFrame): + ratio_changed = Signal(int, int) + save_session = Signal() + load_session = Signal() + + def __init__(self, midi_engine, parent=None): + super().__init__(parent) + self._midi_engine = midi_engine + self._ratio_index = 1 + + self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint) + self.setObjectName("settingsPopup") + self.setStyleSheet(POPUP_STYLE) + self.setFixedWidth(400) + + self._build_ui() + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(12, 10, 12, 10) + root.setSpacing(6) + + # Row 1: Aspect ratio + Save/Load + row1 = QHBoxLayout() + row1.setSpacing(6) + self._ratio_btns = [] + for i, (label, w, h) in enumerate(RATIOS): + btn = QPushButton(label) + btn.setObjectName("ratioBtn") + btn.setFixedHeight(24) + btn.setCheckable(True) + btn.setChecked(i == self._ratio_index) + btn.clicked.connect(partial(self._set_ratio, i)) + row1.addWidget(btn) + self._ratio_btns.append(btn) + + row1.addSpacing(8) + save_btn = QPushButton("SAVE") + save_btn.setObjectName("popupBtn") + save_btn.setFixedHeight(24) + save_btn.clicked.connect(self.save_session.emit) + row1.addWidget(save_btn) + + load_btn = QPushButton("LOAD") + load_btn.setObjectName("popupBtn") + load_btn.setFixedHeight(24) + load_btn.clicked.connect(self.load_session.emit) + row1.addWidget(load_btn) + + root.addLayout(row1) + + # Row 2: MIDI port + row2 = QHBoxLayout() + row2.setSpacing(4) + self._port_combo = QComboBox() + self._port_combo.setObjectName("portCombo") + self._port_combo.setMinimumWidth(160) + row2.addWidget(self._port_combo) + + refresh_btn = QPushButton("⟳") + refresh_btn.setObjectName("popupBtn") + refresh_btn.setFixedSize(26, 22) + refresh_btn.clicked.connect(self._refresh_ports) + row2.addWidget(refresh_btn) + + self._connect_btn = QPushButton("CONNECT") + self._connect_btn.setObjectName("popupBtn") + self._connect_btn.setFixedHeight(22) + self._connect_btn.clicked.connect(self._connect_midi) + row2.addWidget(self._connect_btn) + + self._midi_status = QLabel("") + self._midi_status.setStyleSheet("color: #888; background: transparent; border: none; font-size: 7pt;") + row2.addWidget(self._midi_status, stretch=1) + root.addLayout(row2) + + # Row 3: MIDI learn scroll + learn_label = QLabel("MIDI LEARN") + learn_label.setStyleSheet("color: #00b894; background: transparent; border: none; font-size: 7pt; font-weight: bold; padding-top: 2px;") + root.addWidget(learn_label) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFixedHeight(170) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll.setStyleSheet(POPUP_STYLE) + + content = QWidget() + content.setStyleSheet("background: transparent;") + grid = QVBoxLayout(content) + grid.setContentsMargins(0, 0, 0, 0) + grid.setSpacing(2) + + learn_actions = [ + ("Deck 1 Play/Pause", "deck_play_pause", "deck1"), + ("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), + ("Ch 1 Solo", "mixer_solo", 0), + ("Ch 1 PFL", "mixer_pfl", 0), + ("Ch 2 Solo", "mixer_solo", 1), + ("Ch 2 PFL", "mixer_pfl", 1), + ("Ch 3 Solo", "mixer_solo", 2), + ("Ch 3 PFL", "mixer_pfl", 2), + ("Ch 4 Solo", "mixer_solo", 3), + ("Ch 4 PFL", "mixer_pfl", 3), + ("Master THR", "master_control", "threshold_db"), + ("Master RAT", "master_control", "ratio"), + ("Master ATK", "master_control", "attack_ms"), + ("Master REL", "master_control", "release_ms"), + ("Master MKG", "master_control", "makeup_gain_db"), + ("Master CEIL", "master_control", "ceiling_db"), + ] + + self._learn_btns = {} + for label, action, target_id in learn_actions: + row = QHBoxLayout() + row.setSpacing(3) + + lbl = QLabel(label) + lbl.setStyleSheet("color: #d0d4dc; background: transparent; border: none; font-size: 7pt;") + row.addWidget(lbl, stretch=1) + + btn = QPushButton("LEARN") + btn.setObjectName("popupBtn") + btn.setFixedSize(52, 20) + btn.setCheckable(True) + btn.toggled.connect(partial(self._toggle_learn, btn, action, target_id)) + row.addWidget(btn) + self._learn_btns[(action, target_id)] = btn + + clear_btn = QPushButton("X") + clear_btn.setObjectName("popupBtn") + clear_btn.setStyleSheet(""" + QPushButton { + background-color: #3a1a1a; + color: #ff6666; + border: 1px solid #5a2a2a; + border-radius: 3px; + font-size: 7pt; + font-weight: bold; + padding: 2px 4px; + } + QPushButton:hover { background-color: #5a2a2a; } + """) + clear_btn.setFixedSize(20, 20) + clear_btn.clicked.connect(partial(self._clear_mapping, action, target_id)) + row.addWidget(clear_btn) + + grid.addLayout(row) + + scroll.setWidget(content) + root.addWidget(scroll) + + self._refresh_ports() + + if self._midi_engine: + self._midi_engine.mapping_learned.connect(self._on_mapping_learned) + + # ── Ratio ────────────────────────────────────────────────────── + + def _set_ratio(self, index): + self._ratio_index = index + for i, btn in enumerate(self._ratio_btns): + btn.setChecked(i == index) + _, w, h = RATIOS[index] + self.ratio_changed.emit(w, h) + + # ── MIDI port ────────────────────────────────────────────────── + + def _refresh_ports(self): + self._port_combo.clear() + if not self._midi_engine: + return + ports = self._midi_engine.list_ports() + if ports: + for p in ports: + self._port_combo.addItem(p) + self._connect_btn.setEnabled(True) + self._midi_status.setText("Select port") + else: + self._port_combo.addItem("No MIDI ports found") + self._connect_btn.setEnabled(False) + self._midi_status.setText("No MIDI devices") + + def _connect_midi(self): + if not self._midi_engine or self._port_combo.currentText() == "No MIDI ports found": + return + self._midi_engine.stop() + idx = self._port_combo.currentIndex() + success = self._midi_engine.start(port_index=idx) + if success: + self._midi_status.setText(f"Connected") + self._midi_status.setStyleSheet("color: #00b894; background: transparent; border: none; font-size: 7pt;") + else: + self._midi_status.setText("Failed") + self._midi_status.setStyleSheet("color: #ff6666; background: transparent; border: none; font-size: 7pt;") + + # ── MIDI learn ───────────────────────────────────────────────── + + def _toggle_learn(self, btn, action, target_id, checked): + if not self._midi_engine: + btn.setChecked(False) + return + if checked: + self._midi_engine.start_learn(action, target_id) + for other_btn in self._learn_btns.values(): + if other_btn is not btn: + other_btn.setChecked(False) + btn.setText("...") + else: + self._midi_engine.stop_learn() + btn.setText("LEARN") + + def _clear_mapping(self, action, target_id): + if not self._midi_engine: + return + to_remove = [] + for key, val in self._midi_engine.mapping.bindings.items(): + if val['action'] == action and val['target_id'] == target_id: + to_remove.append(key) + for key in to_remove: + del self._midi_engine.mapping.bindings[key] + self._midi_engine.mapping.save() + + def _on_mapping_learned(self, action, target_id, midi_type, channel, note, value): + btn = self._learn_btns.get((action, target_id)) + if btn and btn.isChecked(): + btn.setChecked(False) + btn.setText("OK") \ No newline at end of file