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.
This commit is contained in:
2026-05-13 16:47:02 +10:00
parent d2240c61c3
commit 7de0cc3de0
8 changed files with 1117 additions and 533 deletions
+72 -15
View File
@@ -143,6 +143,14 @@ class DeckState:
return f"-{int(remaining//60):02d}:{int(remaining%60):02d}" 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: class MixerChannel:
def __init__(self, name: str, index: int): def __init__(self, name: str, index: int):
self.name = name self.name = name
@@ -150,14 +158,10 @@ class MixerChannel:
self.volume = 1.0 self.volume = 1.0
self.muted = False self.muted = False
self.solo = False self.solo = False
self.pfl = False
self.vu_peak = 0.0 self.vu_peak = 0.0
self.in_port_L = None self.in_port_L = None
self.in_port_R = 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: class Compressor:
@@ -169,7 +173,6 @@ class Compressor:
self.release_ms = 100.0 self.release_ms = 100.0
self.makeup_gain_db = 0.0 self.makeup_gain_db = 0.0
self.bypassed = False self.bypassed = False
self._envelope = 0.0 self._envelope = 0.0
self._attack_coeff = self._time_constant(self.attack_ms) self._attack_coeff = self._time_constant(self.attack_ms)
self._release_coeff = self._time_constant(self.release_ms) self._release_coeff = self._time_constant(self.release_ms)
@@ -226,16 +229,13 @@ class MasterBus:
self.vu_peak = 0.0 self.vu_peak = 0.0
self.lufs_current = -70.0 self.lufs_current = -70.0
def reset_vu(self):
self.vu_peak = 0.0
class AudioEngine: class AudioEngine:
def __init__(self): def __init__(self):
self.client = jack.Client("RadioPanel") self.client = jack.Client("RadioPanel")
self.sr = self.client.samplerate 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 = { self.ports = {
'deck1': ( 'deck1': (
self.client.outports.register("deck1_L"), 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 # Mixer channels: 8 stereo input ports
self.mixer = { self.mixer = {
'channels': [], 'channels': [],
@@ -268,7 +279,13 @@ class AudioEngine:
self.client.outports.register("master_out_R"), 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 = { self.decks = {
'deck1': DeckState(), 'deck1': DeckState(),
'deck2': DeckState(), 'deck2': DeckState(),
@@ -300,6 +317,10 @@ class AudioEngine:
if 0 <= index < NUM_MIXER_CHANNELS: if 0 <= index < NUM_MIXER_CHANNELS:
self.mixer['channels'][index].solo = solo 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): def _fill_port(self, port_L, port_R, deck: DeckState, frames: int):
buf_L = port_L.get_array() buf_L = port_L.get_array()
buf_R = port_R.get_array() buf_R = port_R.get_array()
@@ -327,7 +348,7 @@ class AudioEngine:
deck.position = track.num_samples - 1 deck.position = track.num_samples - 1
def _process(self, frames: int): def _process(self, frames: int):
# ── Decks (unchanged) ────────────────────────────────────────── # ── Digital decks ───────────────────────────────────────────────
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames) self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames) self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
@@ -355,6 +376,34 @@ class AudioEngine:
deck.playing = False deck.playing = False
deck.position = track.num_samples - 1 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 ─────────────────────────────────────────────────────── # ── Mixer ───────────────────────────────────────────────────────
master_buf_L = self.master_out[0].get_array() master_buf_L = self.master_out[0].get_array()
master_buf_R = self.master_out[1].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']) 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']: for ch in self.mixer['channels']:
in_L = ch.in_port_L.get_array() in_L = ch.in_port_L.get_array()
in_R = ch.in_port_R.get_array() in_R = ch.in_port_R.get_array()
mute = ch.muted or (any_solo and not ch.solo) 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: if mute:
ch.vu_peak = 0.0 ch.vu_peak = 0.0
continue continue
@@ -390,15 +450,12 @@ class AudioEngine:
self.mixer['master'].compressor.process(master_audio) self.mixer['master'].compressor.process(master_audio)
self.mixer['master'].limiter.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_L[:] = master_audio[0]
master_buf_R[:] = master_audio[1] 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]))) master_peak = max(np.max(np.abs(master_audio[0])), np.max(np.abs(master_audio[1])))
self.mixer['master'].vu_peak = master_peak self.mixer['master'].vu_peak = master_peak
# LUFS calculation (every ~512 frames)
if frames % 512 < frames: if frames % 512 < frames:
self._calculate_lufs(master_audio) self._calculate_lufs(master_audio)
+1 -5
View File
@@ -18,7 +18,6 @@ class CartSlot(QFrame):
self.slot_number = slot_number self.slot_number = slot_number
self.engine = engine self.engine = engine
self._playing_icon = False self._playing_icon = False
self._playing_icon = False
self.setFrameStyle(QFrame.Box | QFrame.Raised) self.setFrameStyle(QFrame.Box | QFrame.Raised)
self.setLineWidth(1) self.setLineWidth(1)
@@ -146,7 +145,6 @@ class CartSlot(QFrame):
root.addLayout(btn_col) root.addLayout(btn_col)
def apply_theme(self, theme: dict): def apply_theme(self, theme: dict):
"""Apply theme to cart slot LCD."""
self.lcd_frame.setStyleSheet(f""" self.lcd_frame.setStyleSheet(f"""
QFrame {{ QFrame {{
background-color: {theme['bg']}; background-color: {theme['bg']};
@@ -291,7 +289,5 @@ class CartBankWidget(QWidget):
root.addLayout(grid) root.addLayout(grid)
def apply_theme(self, theme: dict): def apply_theme(self, theme: dict):
"""Propagate theme to all cart slots."""
for slot in self.slots: for slot in self.slots:
slot.apply_theme(theme) slot.apply_theme(theme)
+206 -69
View File
@@ -5,7 +5,7 @@ from PySide6.QtWidgets import (
QPushButton, QFrame, QSizePolicy QPushButton, QFrame, QSizePolicy
) )
from PySide6.QtCore import Qt, QTimer, Signal, QSize 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 numpy as np
import os import os
@@ -16,8 +16,6 @@ from icons import (
class WaveformWidget(QFrame): class WaveformWidget(QFrame):
"""Displays downsampled waveform with playhead position."""
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setFixedHeight(60) self.setFixedHeight(60)
@@ -25,7 +23,7 @@ class WaveformWidget(QFrame):
QFrame { QFrame {
background-color: #050505; background-color: #050505;
border: 1px solid #1a1a1a; border: 1px solid #1a1a1a;
border-radius: 3px; border-radius: 4px;
} }
""") """)
@@ -41,7 +39,6 @@ class WaveformWidget(QFrame):
mono = np.mean(audio, axis=0) mono = np.mean(audio, axis=0)
width = max(500, self.width()) width = max(500, self.width())
samples_per_pixel = max(1, len(mono) // width) samples_per_pixel = max(1, len(mono) // width)
num_pixels = len(mono) // samples_per_pixel 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 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) scaled = (self.waveform_data / max_val) * (height * 0.8)
x_scale = width / len(self.waveform_data) x_scale = width / len(self.waveform_data)
for i, rms in enumerate(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 y_top = rect.y() + (height - bar_h) // 2
pct = i / len(self.waveform_data) pct = i / len(self.waveform_data)
if pct < self.position_pct: if pct < self.position_pct:
color = QColor("#004400") color = QColor("#004400")
elif pct > 0.85: elif pct > 0.85:
@@ -101,6 +96,80 @@ class WaveformWidget(QFrame):
painter.drawLine(playhead_x, rect.y(), playhead_x, rect.y() + height) 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): class LCDDisplay(QFrame):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -119,9 +188,7 @@ class LCDDisplay(QFrame):
self.track_label = QLabel("NO TRACK LOADED") self.track_label = QLabel("NO TRACK LOADED")
self.track_label.setFont(QFont("Courier New", 13, QFont.Bold)) self.track_label.setFont(QFont("Courier New", 13, QFont.Bold))
self.track_label.setStyleSheet( self.track_label.setStyleSheet("color: #00ff41; background: transparent;")
"color: #00ff41; background: transparent;"
)
self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.track_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.track_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
@@ -130,16 +197,12 @@ class LCDDisplay(QFrame):
self.elapsed_label = QLabel("00:00") self.elapsed_label = QLabel("00:00")
self.elapsed_label.setFont(QFont("Courier New", 32, QFont.Bold)) self.elapsed_label.setFont(QFont("Courier New", 32, QFont.Bold))
self.elapsed_label.setStyleSheet( self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent;")
"color: #00ff41; background: transparent;"
)
self.elapsed_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) self.elapsed_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.remaining_label = QLabel("-00:00") self.remaining_label = QLabel("-00:00")
self.remaining_label.setFont(QFont("Courier New", 18, QFont.Bold)) self.remaining_label.setFont(QFont("Courier New", 18, QFont.Bold))
self.remaining_label.setStyleSheet( self.remaining_label.setStyleSheet("color: #00cc33; background: transparent;")
"color: #00cc33; background: transparent;"
)
self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
time_row.addWidget(self.elapsed_label) time_row.addWidget(self.elapsed_label)
@@ -150,15 +213,11 @@ class LCDDisplay(QFrame):
self.status_label = QLabel("STOPPED") self.status_label = QLabel("STOPPED")
self.status_label.setFont(QFont("Courier New", 11, QFont.Bold)) self.status_label.setFont(QFont("Courier New", 11, QFont.Bold))
self.status_label.setStyleSheet( self.status_label.setStyleSheet("color: #008822; background: transparent;")
"color: #008822; background: transparent;"
)
self.queue_label = QLabel("QUEUE: EMPTY") self.queue_label = QLabel("QUEUE: EMPTY")
self.queue_label.setFont(QFont("Courier New", 10)) self.queue_label.setFont(QFont("Courier New", 10))
self.queue_label.setStyleSheet( self.queue_label.setStyleSheet("color: #008822; background: transparent;")
"color: #008822; background: transparent;"
)
self.queue_label.setAlignment(Qt.AlignRight) self.queue_label.setAlignment(Qt.AlignRight)
status_row.addWidget(self.status_label) status_row.addWidget(self.status_label)
@@ -190,33 +249,19 @@ class LCDDisplay(QFrame):
def set_playing(self, playing: bool): def set_playing(self, playing: bool):
colour = "#00ff41" if playing else "#00aa2a" colour = "#00ff41" if playing else "#00aa2a"
self.elapsed_label.setStyleSheet( self.elapsed_label.setStyleSheet(f"color: {colour}; background: transparent;")
f"color: {colour}; background: transparent;"
)
def flash_warning(self, active: bool): def flash_warning(self, active: bool):
"""Flash all time displays red when in warning zone."""
if active: if active:
self.elapsed_label.setStyleSheet( self.elapsed_label.setStyleSheet("color: #ff4444; background: transparent;")
"color: #ff4444; background: transparent;" self.remaining_label.setStyleSheet("color: #ff4444; background: transparent;")
)
self.remaining_label.setStyleSheet(
"color: #ff4444; background: transparent;"
)
else: else:
self.elapsed_label.setStyleSheet( self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent;")
"color: #00ff41; background: transparent;" self.remaining_label.setStyleSheet("color: #00cc33; background: transparent;")
)
self.remaining_label.setStyleSheet(
"color: #00cc33; background: transparent;"
)
def make_icon_button(icon: QIcon, def make_icon_button(icon, color="#3a3a3a", icon_color="white",
color: str = "#3a3a3a", width=52, height=48):
icon_color: str = "white",
width: int = 52,
height: int = 48) -> QPushButton:
btn = QPushButton() btn = QPushButton()
btn.setFixedSize(width, height) btn.setFixedSize(width, height)
btn.setIconSize(QSize(22, 22)) btn.setIconSize(QSize(22, 22))
@@ -250,9 +295,7 @@ class DeckWidget(QWidget):
self.current_theme = None self.current_theme = None
self.flash_state = False self.flash_state = False
self._playing_icon = False self._playing_icon = False
self._playing_icon = False
# Convert "DECK 1" to "DECK_001"
deck_num = label.split()[-1] deck_num = label.split()[-1]
self.deck_label = f"DECK_{int(deck_num):03d}" self.deck_label = f"DECK_{int(deck_num):03d}"
@@ -321,10 +364,9 @@ class DeckWidget(QWidget):
def apply_theme(self, theme: dict): def apply_theme(self, theme: dict):
self.current_theme = theme self.current_theme = theme
border_color = theme['border'] border_color = theme['border']
bg_color = theme['bg2'] bg_color = theme['bg2']
self.setStyleSheet(f""" self.setStyleSheet(f"""
QWidget {{ QWidget {{
background-color: {bg_color}; background-color: {bg_color};
@@ -332,7 +374,6 @@ class DeckWidget(QWidget):
border-radius: 6px; border-radius: 6px;
}} }}
""") """)
self.lcd.setStyleSheet(f""" self.lcd.setStyleSheet(f"""
QFrame {{ QFrame {{
background-color: {theme['bg']}; background-color: {theme['bg']};
@@ -340,7 +381,6 @@ class DeckWidget(QWidget):
border-radius: 4px; border-radius: 4px;
}} }}
""") """)
self.waveform.setStyleSheet(f""" self.waveform.setStyleSheet(f"""
QFrame {{ QFrame {{
background-color: {theme['bg']}; background-color: {theme['bg']};
@@ -466,7 +506,6 @@ class DeckWidget(QWidget):
def _refresh_display(self): def _refresh_display(self):
deck = self.engine.decks[self.deck_key] 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 not deck.playing and deck.track is not None:
if deck.position >= deck.track.num_samples - 1: if deck.position >= deck.track.num_samples - 1:
if deck.queued: if deck.queued:
@@ -477,7 +516,6 @@ class DeckWidget(QWidget):
self.lcd.update_queue("") self.lcd.update_queue("")
self.lcd.update_status("LOADED") self.lcd.update_status("LOADED")
# Sync button if stopped manually
if not deck.playing and self._playing_icon: if not deck.playing and self._playing_icon:
self.btn_play.setIcon(icon_play("#00ff41")) self.btn_play.setIcon(icon_play("#00ff41"))
self._playing_icon = False self._playing_icon = False
@@ -493,33 +531,21 @@ class DeckWidget(QWidget):
self.lcd.set_playing(False) self.lcd.set_playing(False)
if deck.track: if deck.track:
self.lcd.update_time( self.lcd.update_time(deck.get_elapsed(), deck.get_remaining())
deck.get_elapsed(),
deck.get_remaining()
)
position_pct = deck.position / deck.track.num_samples position_pct = deck.position / deck.track.num_samples
remaining_sec = deck.track.duration - (deck.position / deck.track.sr) 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) 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: if deck.playing and in_warning:
flash_color = '#ff4444' if self.flash_state else self.accent_color flash_color = '#ff4444' if self.flash_state else self.accent_color
# Flash header
self.header.setStyleSheet(f""" self.header.setStyleSheet(f"""
color: {flash_color}; color: {flash_color};
background: transparent; background: transparent;
border: none; border: none;
padding: 4px; padding: 4px;
""") """)
# Flash LCD numbers
self.lcd.flash_warning(self.flash_state) self.lcd.flash_warning(self.flash_state)
else: else:
# Normal colors
self.header.setStyleSheet(f""" self.header.setStyleSheet(f"""
color: {self.accent_color}; color: {self.accent_color};
background: transparent; background: transparent;
@@ -527,10 +553,121 @@ class DeckWidget(QWidget):
padding: 4px; padding: 4px;
""") """)
self.lcd.flash_warning(False) self.lcd.flash_warning(False)
self.waveform.set_position(position_pct, in_warning and self.flash_state) self.waveform.set_position(position_pct, in_warning and self.flash_state)
if deck.queued: if deck.queued:
self.lcd.update_queue( self.lcd.update_queue(os.path.basename(deck.queued.filepath))
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;
}}
""")
+66
View File
@@ -279,6 +279,72 @@ def icon_solo(color: str = "#ffaa00") -> QIcon:
return _make_icon(paint, color) 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 icon_theme(color: str = "#00ff41") -> QIcon:
def paint(p, r, c): def paint(p, r, c):
p.setPen(Qt.NoPen) p.setPen(Qt.NoPen)
+146 -47
View File
@@ -1,15 +1,17 @@
# main.py # main.py
import json
import os
import sys import sys
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QApplication, QMainWindow, QWidget, QFileDialog,
QVBoxLayout, QHBoxLayout, QSplitter QVBoxLayout, QHBoxLayout, QSplitter
) )
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QFont, QColor, QPalette from PySide6.QtGui import QFont, QColor, QPalette
from audio_engine import AudioEngine from audio_engine import AudioEngine
from deck_widget import DeckWidget from deck_widget import DeckWidget, ExternalDeckWidget
from cart_widget import CartBankWidget from cart_widget import CartBankWidget
from playlist_widget import PlaylistWidget from playlist_widget import PlaylistWidget
from midi_engine import MidiEngine from midi_engine import MidiEngine
@@ -25,7 +27,7 @@ class RadioPanelWindow(QMainWindow):
self.engine = AudioEngine() self.engine = AudioEngine()
self.setWindowTitle("Getting To It") self.setWindowTitle("Getting To It")
self.setMinimumSize(1280, 720) self.setMinimumSize(1400, 860)
self.resize(1920, 1080) self.resize(1920, 1080)
self._apply_global_style(THEMES[0]) self._apply_global_style(THEMES[0])
@@ -33,31 +35,26 @@ class RadioPanelWindow(QMainWindow):
self.setCentralWidget(central) self.setCentralWidget(central)
outer = QVBoxLayout(central) outer = QVBoxLayout(central)
outer.setContentsMargins(8, 8, 8, 8) outer.setContentsMargins(6, 6, 6, 6)
outer.setSpacing(8) outer.setSpacing(6)
# Top bar # Top bar
self.top_bar = TopBarWidget() self.top_bar = TopBarWidget()
self.top_bar.theme_changed.connect(self._on_theme_changed) 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) outer.addWidget(self.top_bar)
# ── Build widgets ───────────────────────────────────────────────── # ── Build widgets ─────────────────────────────────────────────────
self.deck1 = DeckWidget( self.deck1 = DeckWidget("deck1", "DECK 1", "#00b894", self.engine)
deck_key="deck1", self.deck2 = DeckWidget("deck2", "DECK 2", "#6c5ce7", self.engine)
label="DECK 1", self.deck3 = ExternalDeckWidget("deck3", "DECK 3", "#fdcb6e", self.engine)
accent_color="#00897b", self.deck4 = ExternalDeckWidget("deck4", "DECK 4", "#e17055", self.engine)
engine=self.engine
)
self.deck2 = DeckWidget(
deck_key="deck2",
label="DECK 2",
accent_color="#f9ca24",
engine=self.engine
)
self.cart_bank = CartBankWidget(self.engine) self.cart_bank = CartBankWidget(self.engine)
self.cart_bank.setFixedHeight(280) self.cart_bank.setFixedHeight(220)
self.playlist_widget = PlaylistWidget( self.playlist_widget = PlaylistWidget(
engine=self.engine, engine=self.engine,
@@ -72,46 +69,52 @@ class RadioPanelWindow(QMainWindow):
self.mixer = MixerWidget(self.engine) self.mixer = MixerWidget(self.engine)
# ── Centre column (decks + carts + mixer) ───────────────────────── # ── Centre column with vertical splitter ─────────────────────────
centre_col = QVBoxLayout() 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 = QWidget()
deck_area.setStyleSheet("background: transparent; border: none;") deck_area.setStyleSheet("background: transparent; border: none;")
deck_layout = QVBoxLayout(deck_area) deck_layout = QVBoxLayout(deck_area)
deck_layout.setContentsMargins(0, 0, 0, 0) 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.addWidget(self.cart_bank)
deck_layout.addStretch()
# Bottom: mixer # Mixer fills remaining space below decks
centre_col.addWidget(deck_area, stretch=1) centre_col.addWidget(deck_area)
centre_col.addWidget(self.mixer) centre_col.addWidget(self.mixer, stretch=1)
centre_widget = QWidget() centre_widget = QWidget()
centre_widget.setStyleSheet("background: transparent; border: none;") centre_widget.setStyleSheet("background: transparent; border: none;")
centre_widget.setLayout(centre_col) centre_widget.setLayout(centre_col)
# ── Splitter ────────────────────────────────────────────────────── # ── Horizontal splitter ──────────────────────────────────────────
self.splitter = QSplitter(Qt.Horizontal) self.splitter = QSplitter(Qt.Horizontal)
self.splitter.setHandleWidth(6) self.splitter.setHandleWidth(4)
self._style_splitter(THEMES[0]) self._style_splitter(THEMES[0])
self.splitter.addWidget(self.playlist_widget) self.splitter.addWidget(self.playlist_widget)
self.splitter.addWidget(centre_widget) self.splitter.addWidget(centre_widget)
self.splitter.addWidget(self.history_widget) self.splitter.addWidget(self.history_widget)
self.splitter.setSizes([400, 1120, 400]) self.splitter.setSizes([360, 1160, 360])
outer.addWidget(self.splitter) outer.addWidget(self.splitter)
# ── Wire signals ───────────────────────────────────────────────── # ── Wire signals ─────────────────────────────────────────────────
self.deck1.track_started.connect(self.history_widget.add_track) self.deck1.track_started.connect(self.history_widget.add_track)
self.deck2.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): def _style_splitter(self, theme: dict):
self.splitter.setStyleSheet(f""" self.splitter.setStyleSheet(f"""
QSplitter::handle {{ QSplitter::handle {{
background-color: #333333; background-color: #22242a;
border-radius: 3px; border-radius: 2px;
}} }}
QSplitter::handle:hover {{ QSplitter::handle:hover {{
background-color: {theme['accent']}; background-color: {theme['accent']};
@@ -137,10 +140,15 @@ class RadioPanelWindow(QMainWindow):
self._style_splitter(theme) self._style_splitter(theme)
self.deck1.apply_theme(theme) self.deck1.apply_theme(theme)
self.deck2.apply_theme(theme) self.deck2.apply_theme(theme)
self.deck3.apply_theme(theme)
self.deck4.apply_theme(theme)
self.cart_bank.apply_theme(theme) self.cart_bank.apply_theme(theme)
self.history_widget.apply_theme(theme) self.history_widget.apply_theme(theme)
self.mixer.apply_theme(theme) self.mixer.apply_theme(theme)
def _apply_ratio(self, w, h):
self.resize(w, h)
def _apply_global_style(self, theme: dict): def _apply_global_style(self, theme: dict):
self.setStyleSheet(f""" self.setStyleSheet(f"""
QMainWindow {{ QMainWindow {{
@@ -149,7 +157,7 @@ class RadioPanelWindow(QMainWindow):
QWidget {{ QWidget {{
background-color: {theme['bg']}; background-color: {theme['bg']};
color: {theme['text']}; color: {theme['text']};
font-family: Arial; font-family: "Segoe UI", Arial, sans-serif;
}} }}
QToolTip {{ QToolTip {{
background-color: {theme['bg2']}; 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): def _setup_midi(self):
self.midi = MidiEngine() self.midi = MidiEngine()
@@ -168,15 +230,27 @@ class RadioPanelWindow(QMainWindow):
self.midi.cart_trigger.connect(self._on_midi_cart) self.midi.cart_trigger.connect(self._on_midi_cart)
self.midi.mixer_volume.connect(self._on_midi_mixer_volume) self.midi.mixer_volume.connect(self._on_midi_mixer_volume)
self.midi.mixer_mute.connect(self._on_midi_mixer_mute) 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.controller_connected.connect(self._on_midi_status)
self.midi.start() 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): def _on_midi_play_pause(self, deck: str):
if deck == 'deck1': if deck == 'deck1':
self.deck1.toggle_play() self.deck1.toggle_play()
elif deck == 'deck2': elif deck == 'deck2':
self.deck2.toggle_play() self.deck2.toggle_play()
elif deck == 'deck3':
self.deck3.toggle_play()
elif deck == 'deck4':
self.deck4.toggle_play()
def _on_midi_cue(self, deck: str): def _on_midi_cue(self, deck: str):
if deck == 'deck1': if deck == 'deck1':
@@ -199,6 +273,31 @@ class RadioPanelWindow(QMainWindow):
def _on_midi_mixer_mute(self, channel: int, muted: bool): def _on_midi_mixer_mute(self, channel: int, muted: bool):
self.engine.set_channel_mute(channel, muted) 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): def _on_midi_status(self, connected: bool, name: str):
if connected: if connected:
print(f"[MIDI] Connected: {name}") print(f"[MIDI] Connected: {name}")
@@ -217,14 +316,14 @@ def main():
app.setOrganizationName("Getting To It") app.setOrganizationName("Getting To It")
palette = QPalette() palette = QPalette()
palette.setColor(QPalette.Window, QColor("#0d0d0d")) palette.setColor(QPalette.Window, QColor("#121418"))
palette.setColor(QPalette.WindowText, QColor("#cccccc")) palette.setColor(QPalette.WindowText, QColor("#d0d4dc"))
palette.setColor(QPalette.Base, QColor("#111111")) palette.setColor(QPalette.Base, QColor("#1a1c23"))
palette.setColor(QPalette.AlternateBase, QColor("#1a1a1a")) palette.setColor(QPalette.AlternateBase, QColor("#22242c"))
palette.setColor(QPalette.Text, QColor("#cccccc")) palette.setColor(QPalette.Text, QColor("#d0d4dc"))
palette.setColor(QPalette.Button, QColor("#2a2a2a")) palette.setColor(QPalette.Button, QColor("#2a2c35"))
palette.setColor(QPalette.ButtonText, QColor("#cccccc")) palette.setColor(QPalette.ButtonText, QColor("#d0d4dc"))
palette.setColor(QPalette.Highlight, QColor("#1a3a5a")) palette.setColor(QPalette.Highlight, QColor("#00b894"))
palette.setColor(QPalette.HighlightedText, QColor("#ffffff")) palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
app.setPalette(palette) app.setPalette(palette)
@@ -235,4 +334,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+34 -1
View File
@@ -88,6 +88,9 @@ class MidiEngine(QObject):
cart_trigger = Signal(int) cart_trigger = Signal(int)
mixer_volume = Signal(int, float) mixer_volume = Signal(int, float)
mixer_mute = Signal(int, bool) 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) controller_connected = Signal(bool, str)
mapping_learned = Signal(str, object, int, int, int, bool) mapping_learned = Signal(str, object, int, int, int, bool)
@@ -226,12 +229,42 @@ class MidiEngine(QObject):
target_id = binding['target_id'] target_id = binding['target_id']
inv = binding.get('inverse', False) 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 scaled = (127 - value if inv else value) / 127.0
self.mixer_volume.emit(target_id, scaled) self.mixer_volume.emit(target_id, scaled)
elif action == 'mixer_mute': elif action == 'mixer_mute':
muted = (value < 64) if inv else (value > 63) muted = (value < 64) if inv else (value > 63)
self.mixer_mute.emit(target_id, muted) 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): def _handle_note_on(self, note: int):
# Hardcoded Numark DJ2Go mapping (legacy) # Hardcoded Numark DJ2Go mapping (legacy)
+211 -181
View File
@@ -3,31 +3,21 @@
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider,
QPushButton, QFrame, QScrollArea, QDial, QSizePolicy, 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 PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen
from audio_engine import NUM_MIXER_CHANNELS from audio_engine import NUM_MIXER_CHANNELS
from icons import icon_mute, icon_solo from icons import icon_mute, icon_solo
STYLESHEET = """
QWidget {{
background-color: {bg};
color: {text};
font-family: Arial;
}}
"""
class VUMeter(QWidget): class VUMeter(QWidget):
def __init__(self, parent=None, orientation=Qt.Vertical): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._value = 0.0 self._value = 0.0
self._orientation = orientation self.setMinimumWidth(28)
self.setMinimumWidth(24 if orientation == Qt.Vertical else 100) self.setMinimumHeight(80)
self.setMinimumHeight(100 if orientation == Qt.Vertical else 24)
def set_value(self, value: float): def set_value(self, value: float):
self._value = max(0.0, min(1.0, value)) self._value = max(0.0, min(1.0, value))
@@ -37,26 +27,32 @@ class VUMeter(QWidget):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing) 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: if rect.width() < 1 or rect.height() < 1:
painter.end() painter.end()
return return
# Background painter.fillRect(rect, QColor("#080808"))
painter.fillRect(rect, QColor("#0a0a0a"))
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 val = self._value
if val < 1e-10: if val < 1e-10:
painter.setPen(QPen(QColor("#222222"), 1))
painter.drawRect(rect)
painter.end() painter.end()
return 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 = QLinearGradient(0, rect.bottom(), 0, rect.top())
gradient.setColorAt(0.0, QColor("#003300")) gradient.setColorAt(0.0, QColor("#003300"))
gradient.setColorAt(0.5, QColor("#00cc00")) gradient.setColorAt(0.5, QColor("#00cc00"))
@@ -64,14 +60,21 @@ class VUMeter(QWidget):
gradient.setColorAt(0.9, QColor("#ff6600")) gradient.setColorAt(0.9, QColor("#ff6600"))
gradient.setColorAt(1.0, QColor("#ff0000")) gradient.setColorAt(1.0, QColor("#ff0000"))
fill_h = int(rect.height() * fill_pct) db = -46 * (1 - val) if val < 1.0 else 3
painter.fillRect( fill_pct = (db + 46) / 49.0 if db <= 0 else 1.0
rect.x(), rect.bottom() - fill_h, fill_pct = max(0.0, min(1.0, fill_pct))
rect.width(), fill_h, gradient
) 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() painter.end()
@@ -82,13 +85,13 @@ class ChannelStrip(QFrame):
self.channel_index = channel_index self.channel_index = channel_index
self._channel = engine.get_channel(channel_index) self._channel = engine.get_channel(channel_index)
self.setFixedWidth(110) self.setMinimumWidth(90)
self.setFrameStyle(QFrame.Box | QFrame.Raised) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setStyleSheet(""" self.setStyleSheet("""
QFrame { ChannelStrip {
background-color: #151515; background-color: #181a1e;
border: 1px solid #333333; border: 1px solid #282a30;
border-radius: 4px; border-radius: 6px;
} }
""") """)
@@ -105,109 +108,117 @@ class ChannelStrip(QFrame):
root.setContentsMargins(4, 6, 4, 6) root.setContentsMargins(4, 6, 4, 6)
root.setSpacing(4) root.setSpacing(4)
# Name label
self.name_btn = QPushButton(self._channel.name) 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(""" self.name_btn.setStyleSheet("""
QPushButton { QPushButton {
background-color: transparent; background-color: transparent;
color: #aaaaaa; color: #999aaa;
border: none; border: none;
padding: 2px; padding: 2px;
font-size: 8pt;
} }
QPushButton:hover { QPushButton:hover {
color: #ffffff; color: #ffffff;
background-color: #222222; background-color: #22252c;
border-radius: 3px; border-radius: 3px;
} }
""") """)
root.addWidget(self.name_btn) root.addWidget(self.name_btn)
# VU Meter self.vu_meter = VUMeter()
self.vu_meter = VUMeter(orientation=Qt.Vertical)
self.vu_meter.setMinimumHeight(120)
root.addWidget(self.vu_meter, stretch=1) 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 = QSlider(Qt.Vertical)
self.volume_slider.setRange(0, 100) self.volume_slider.setRange(0, 100)
self.volume_slider.setValue(100) self.volume_slider.setValue(100)
self.volume_slider.setFixedHeight(80) self.volume_slider.setFixedHeight(60)
self.volume_slider.setStyleSheet(""" self.volume_slider.setStyleSheet("""
QSlider::groove:vertical { QSlider::groove:vertical {
background: #222222; background: #22242a;
width: 6px; width: 4px;
border-radius: 3px;
}
QSlider::handle:vertical {
background: #666666;
height: 12px;
width: 16px;
margin: -4px -4px;
border-radius: 2px; border-radius: 2px;
} }
QSlider::handle:vertical:hover { QSlider::handle:vertical {
background: #aaaaaa; background: #8890a0;
height: 12px;
width: 14px;
margin: -4px -5px;
border-radius: 2px;
} }
QSlider::handle:vertical:hover { background: #b0b8c8; }
QSlider::sub-page:vertical { QSlider::sub-page:vertical {
background: #00aa44; background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
border-radius: 3px; stop:0 #00cc66, stop:1 #008844);
border-radius: 2px;
} }
""") """)
vol_row.addWidget(self.volume_slider, alignment=Qt.AlignCenter) root.addWidget(self.volume_slider, alignment=Qt.AlignCenter)
root.addLayout(vol_row)
# Mute / Solo buttons
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_row.setSpacing(4) btn_row.setSpacing(3)
self.mute_btn = QPushButton() self.mute_btn = QPushButton()
self.mute_btn.setFixedSize(32, 28) self.mute_btn.setFixedSize(28, 24)
self.mute_btn.setIconSize(self.mute_btn.size() * 0.7) self.mute_btn.setIconSize(QSize(16, 16))
self.mute_btn.setIcon(icon_mute()) self.mute_btn.setIcon(icon_mute())
self.mute_btn.setCheckable(True) self.mute_btn.setCheckable(True)
self.mute_btn.setToolTip("Mute")
self.mute_btn.setStyleSheet(""" self.mute_btn.setStyleSheet("""
QPushButton { QPushButton {
background-color: #2a1a1a; background-color: #242026;
border: 1px solid #4a2a2a; border: 1px solid #3a303a;
border-radius: 3px; border-radius: 3px;
} }
QPushButton:checked { QPushButton:checked {
background-color: #5a2020; background-color: #4a1a1a;
border: 1px solid #ff4444; 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) btn_row.addWidget(self.mute_btn)
self.solo_btn = QPushButton() self.solo_btn = QPushButton()
self.solo_btn.setFixedSize(32, 28) self.solo_btn.setFixedSize(28, 24)
self.solo_btn.setIconSize(self.solo_btn.size() * 0.7) self.solo_btn.setIconSize(QSize(16, 16))
self.solo_btn.setIcon(icon_solo()) self.solo_btn.setIcon(icon_solo())
self.solo_btn.setCheckable(True) self.solo_btn.setCheckable(True)
self.solo_btn.setToolTip("Solo")
self.solo_btn.setStyleSheet(""" self.solo_btn.setStyleSheet("""
QPushButton { QPushButton {
background-color: #2a2a1a; background-color: #262420;
border: 1px solid #4a4a2a; border: 1px solid #3a3830;
border-radius: 3px; border-radius: 3px;
} }
QPushButton:checked { QPushButton:checked {
background-color: #5a5a20; background-color: #4a4a1a;
border: 1px solid #ffaa00; 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) 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) root.addLayout(btn_row)
def _connect_signals(self): def _connect_signals(self):
@@ -215,11 +226,11 @@ class ChannelStrip(QFrame):
self.volume_slider.valueChanged.connect(self._on_volume_changed) self.volume_slider.valueChanged.connect(self._on_volume_changed)
self.mute_btn.toggled.connect(self._on_mute_toggled) self.mute_btn.toggled.connect(self._on_mute_toggled)
self.solo_btn.toggled.connect(self._on_solo_toggled) self.solo_btn.toggled.connect(self._on_solo_toggled)
self.pfl_btn.toggled.connect(self._on_pfl_toggled)
def _rename(self): def _rename(self):
name, ok = QInputDialog.getText( name, ok = QInputDialog.getText(
self, "Rename Channel", self, "Rename Channel", "Channel name:",
"Channel name:",
text=self._channel.name text=self._channel.name
) )
if ok and name.strip(): if ok and name.strip():
@@ -235,6 +246,9 @@ class ChannelStrip(QFrame):
def _on_solo_toggled(self, checked: bool): def _on_solo_toggled(self, checked: bool):
self.engine.set_channel_solo(self.channel_index, checked) 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): def _refresh(self):
self.vu_meter.set_value(self._channel.vu_peak) self.vu_meter.set_value(self._channel.vu_peak)
@@ -248,13 +262,13 @@ class MasterSection(QFrame):
self.engine = engine self.engine = engine
self._master = engine.mixer['master'] self._master = engine.mixer['master']
self.setFixedWidth(180) self.setMinimumWidth(180)
self.setFrameStyle(QFrame.Box | QFrame.Raised) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setStyleSheet(""" self.setStyleSheet("""
QFrame { MasterSection {
background-color: #151515; background-color: #181a1e;
border: 1px solid #333333; border: 1px solid #282a30;
border-radius: 4px; border-radius: 6px;
} }
""") """)
@@ -268,70 +282,72 @@ class MasterSection(QFrame):
def _build_ui(self): def _build_ui(self):
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(6, 6, 6, 6) root.setContentsMargins(8, 6, 8, 6)
root.setSpacing(6) root.setSpacing(4)
# Header
header = QLabel("MASTER") header = QLabel("MASTER")
header.setFont(QFont("Arial", 10, QFont.Bold)) header.setFont(QFont("Segoe UI", 10, QFont.Bold))
header.setAlignment(Qt.AlignCenter) header.setAlignment(Qt.AlignCenter)
header.setStyleSheet("color: #00ff41; background: transparent; border: none;") header.setStyleSheet("color: #00cc88; background: transparent; border: none;")
root.addWidget(header) root.addWidget(header)
# VU Meter self.vu_meter = VUMeter()
self.vu_meter = VUMeter(orientation=Qt.Vertical)
self.vu_meter.setMinimumHeight(100)
root.addWidget(self.vu_meter) root.addWidget(self.vu_meter)
# LUFS readout
self.lufs_label = QLabel("-70.0 LUFS") 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.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) 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 = QFrame()
comp_frame.setStyleSheet(""" comp_frame.setStyleSheet("""
QFrame { QFrame {
background-color: #111111; background-color: #121318;
border: 1px solid #2a2a2a; border: 1px solid #22242a;
border-radius: 3px; border-radius: 4px;
} }
""") """)
comp_layout = QVBoxLayout(comp_frame) comp_layout = QVBoxLayout(comp_frame)
comp_layout.setContentsMargins(4, 4, 4, 4) comp_layout.setContentsMargins(4, 4, 4, 4)
comp_layout.setSpacing(3) comp_layout.setSpacing(2)
comp_header_row = QHBoxLayout() comp_header = QHBoxLayout()
comp_label = QLabel("COMPRESSOR") cl = QLabel("COMPRESSOR")
comp_label.setFont(QFont("Arial", 8, QFont.Bold)) cl.setFont(QFont("Segoe UI", 7, QFont.Bold))
comp_label.setStyleSheet("color: #888888; background: transparent; border: none;") cl.setStyleSheet("color: #778; background: transparent; border: none;")
comp_header_row.addWidget(comp_label) comp_header.addWidget(cl)
self.comp_bypass = QPushButton("B") 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.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(""" self.comp_bypass.setStyleSheet("""
QPushButton { QPushButton {
background-color: #2a2a2a; background-color: #22242a;
color: #888888; color: #778;
border: 1px solid #444; border: 1px solid #333;
border-radius: 2px; border-radius: 2px;
} }
QPushButton:checked { QPushButton:checked {
background-color: #5a2020; background-color: #4a1a1a;
color: #ff6666; color: #ff6666;
border: 1px solid #ff4444; border: 1px solid #ff4444;
} }
""") """)
self.comp_bypass.setToolTip("Bypass compressor") self.comp_bypass.setToolTip("Bypass compressor")
comp_header_row.addWidget(self.comp_bypass) comp_header.addWidget(self.comp_bypass)
comp_layout.addLayout(comp_header_row) comp_layout.addLayout(comp_header)
knobs_row = QHBoxLayout() knobs = QHBoxLayout()
knobs_row.setSpacing(2) knobs.setSpacing(4)
for label, attr, default, lo, hi, suffix in [ for label, attr, default, lo, hi, suffix in [
("THR", "threshold_db", -20, -60, 0, "dB"), ("THR", "threshold_db", -20, -60, 0, "dB"),
("RAT", "ratio", 4, 1, 20, ":1"), ("RAT", "ratio", 4, 1, 20, ":1"),
@@ -339,60 +355,58 @@ class MasterSection(QFrame):
("REL", "release_ms", 100, 10, 1000, "ms"), ("REL", "release_ms", 100, 10, 1000, "ms"),
("MKG", "makeup_gain_db", 0, 0, 24, "dB"), ("MKG", "makeup_gain_db", 0, 0, 24, "dB"),
]: ]:
k = self._make_knob(label, self._master.compressor, attr, default, lo, hi, suffix) k = self._make_knob(label, self._master.compressor, attr,
knobs_row.addWidget(k) default, lo, hi, suffix)
comp_layout.addLayout(knobs_row) knobs.addWidget(k)
comp_layout.addLayout(knobs)
root.addWidget(comp_frame) root.addWidget(comp_frame)
# Limiter section
lim_frame = QFrame() lim_frame = QFrame()
lim_frame.setStyleSheet(""" lim_frame.setStyleSheet("""
QFrame { QFrame {
background-color: #111111; background-color: #121318;
border: 1px solid #2a2a2a; border: 1px solid #22242a;
border-radius: 3px; border-radius: 4px;
} }
""") """)
lim_layout = QVBoxLayout(lim_frame) lim_layout = QVBoxLayout(lim_frame)
lim_layout.setContentsMargins(4, 4, 4, 4) lim_layout.setContentsMargins(4, 4, 4, 4)
lim_layout.setSpacing(3) lim_layout.setSpacing(2)
lim_header_row = QHBoxLayout() lim_header = QHBoxLayout()
lim_label = QLabel("LIMITER") ll = QLabel("LIMITER")
lim_label.setFont(QFont("Arial", 8, QFont.Bold)) ll.setFont(QFont("Segoe UI", 7, QFont.Bold))
lim_label.setStyleSheet("color: #888888; background: transparent; border: none;") ll.setStyleSheet("color: #778; background: transparent; border: none;")
lim_header_row.addWidget(lim_label) lim_header.addWidget(ll)
self.lim_bypass = QPushButton("B") 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.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(""" self.lim_bypass.setStyleSheet("""
QPushButton { QPushButton {
background-color: #2a2a2a; background-color: #22242a;
color: #888888; color: #778;
border: 1px solid #444; border: 1px solid #333;
border-radius: 2px; border-radius: 2px;
} }
QPushButton:checked { QPushButton:checked {
background-color: #5a2020; background-color: #4a1a1a;
color: #ff6666; color: #ff6666;
border: 1px solid #ff4444; border: 1px solid #ff4444;
} }
""") """)
self.lim_bypass.setToolTip("Bypass limiter") self.lim_bypass.setToolTip("Bypass limiter")
lim_header_row.addWidget(self.lim_bypass) lim_header.addWidget(self.lim_bypass)
lim_layout.addLayout(lim_header_row) lim_layout.addLayout(lim_header)
lim_knobs = QHBoxLayout() lim_knobs = QHBoxLayout()
lim_knobs.setSpacing(2) lim_knobs.setSpacing(4)
k = self._make_knob("CEIL", self._master.limiter, "ceiling_db", -0.1, -10, 0, "dB") k = self._make_knob("CEIL", self._master.limiter, "ceiling_db",
-0.1, -10, 0, "dB")
lim_knobs.addWidget(k) lim_knobs.addWidget(k)
lim_layout.addLayout(lim_knobs) lim_layout.addLayout(lim_knobs)
root.addWidget(lim_frame) root.addWidget(lim_frame)
root.addStretch() root.addStretch()
def _make_knob(self, label, obj, attr, default, lo, hi, suffix): def _make_knob(self, label, obj, attr, default, lo, hi, suffix):
@@ -403,24 +417,23 @@ class MasterSection(QFrame):
layout.setSpacing(1) layout.setSpacing(1)
lbl = QLabel(label) lbl = QLabel(label)
lbl.setFont(QFont("Arial", 7, QFont.Bold)) lbl.setFont(QFont("Segoe UI", 7, QFont.Bold))
lbl.setAlignment(Qt.AlignCenter) lbl.setAlignment(Qt.AlignCenter)
lbl.setStyleSheet("color: #666666; background: transparent; border: none;") lbl.setStyleSheet("color: #556; background: transparent; border: none;")
layout.addWidget(lbl) layout.addWidget(lbl)
dial = QDial() dial = QDial()
dial.setFixedSize(32, 32) dial.setFixedSize(44, 44)
dial.setRange(0, 1000) dial.setRange(0, 1000)
dial.setNotchesVisible(False) dial.setNotchesVisible(False)
dial.setStyleSheet(""" dial.setStyleSheet("""
QDial { QDial {
background-color: #1a1a1a; background-color: #121318;
border: 1px solid #333; border: 1px solid #2a2c33;
border-radius: 16px; border-radius: 22px;
} }
""") """)
# Map value to dial position
def _val_to_pos(v): def _val_to_pos(v):
return int((v - lo) / (hi - lo) * 1000) return int((v - lo) / (hi - lo) * 1000)
@@ -430,9 +443,9 @@ class MasterSection(QFrame):
dial.setValue(_val_to_pos(default)) dial.setValue(_val_to_pos(default))
val_label = QLabel(f"{default}{suffix}") 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.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): def _on_dial(v):
val = _pos_to_val(v) val = _pos_to_val(v)
@@ -454,7 +467,6 @@ class MasterSection(QFrame):
dial.valueChanged.connect(_on_dial) dial.valueChanged.connect(_on_dial)
layout.addWidget(dial, alignment=Qt.AlignCenter) layout.addWidget(dial, alignment=Qt.AlignCenter)
layout.addWidget(val_label) layout.addWidget(val_label)
return frame return frame
def _connect_signals(self): def _connect_signals(self):
@@ -468,6 +480,18 @@ class MasterSection(QFrame):
def _refresh(self): def _refresh(self):
self.vu_meter.set_value(self._master.vu_peak) self.vu_meter.set_value(self._master.vu_peak)
self.lufs_label.setText(f"{self._master.lufs_current:.1f} LUFS") 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): def apply_theme(self, theme: dict):
pass pass
@@ -477,13 +501,11 @@ class MixerWidget(QFrame):
def __init__(self, engine, parent=None): def __init__(self, engine, parent=None):
super().__init__(parent) super().__init__(parent)
self.engine = engine self.engine = engine
self.setFrameStyle(QFrame.Box | QFrame.Raised)
self.setStyleSheet(""" self.setStyleSheet("""
QFrame { MixerWidget {
background-color: #0d0d0d; background-color: #121318;
border: 2px solid #333333; border: 1px solid #282a30;
border-radius: 6px; border-radius: 8px;
} }
""") """)
@@ -491,34 +513,36 @@ class MixerWidget(QFrame):
def _build_ui(self): def _build_ui(self):
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(8, 8, 8, 8) root.setContentsMargins(6, 4, 6, 6)
root.setSpacing(6) root.setSpacing(4)
# Header
header = QLabel("MIXER") header = QLabel("MIXER")
header.setFont(QFont("Arial", 10, QFont.Bold)) header.setFont(QFont("Segoe UI", 9, QFont.Bold))
header.setStyleSheet("color: #666666; background: transparent; border: none;") header.setStyleSheet("color: #556670; background: transparent; border: none; "
"letter-spacing: 2px;")
root.addWidget(header) root.addWidget(header)
# Scrollable channel strips
scroll = QScrollArea() scroll = QScrollArea()
scroll.setWidgetResizable(True) scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll.setFixedHeight(350)
scroll.setStyleSheet(""" scroll.setStyleSheet("""
QScrollArea { QScrollArea {
border: none; border: none;
background: transparent; background: transparent;
} }
QScrollBar:horizontal { QScrollBar:horizontal {
background: #1a1a1a; background: #1a1c22;
height: 8px; height: 6px;
border-radius: 4px; border-radius: 3px;
} }
QScrollBar::handle:horizontal { QScrollBar::handle:horizontal {
background: #444444; background: #3a3c44;
border-radius: 4px; 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) self._strips.append(strip)
channels_layout.addWidget(strip) channels_layout.addWidget(strip)
# Master section
self.master_section = MasterSection(self.engine) self.master_section = MasterSection(self.engine)
channels_layout.addWidget(self.master_section) channels_layout.addWidget(self.master_section)
channels_layout.addStretch()
scroll.setWidget(scroll_content) scroll.setWidget(scroll_content)
root.addWidget(scroll) root.addWidget(scroll)
@@ -547,3 +568,12 @@ class MixerWidget(QFrame):
for strip in self._strips: for strip in self._strips:
strip.apply_theme(theme) strip.apply_theme(theme)
self.master_section.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)
+381 -215
View File
@@ -1,211 +1,61 @@
# top_bar_widget.py # top_bar_widget.py
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton from PySide6.QtWidgets import (
from PySide6.QtCore import Qt, QTimer, Signal, QSize QFrame, QHBoxLayout, QVBoxLayout, QLabel, QPushButton,
QComboBox, QScrollArea, QWidget
)
from PySide6.QtCore import Qt, QTimer, Signal, QSize, QPoint
from PySide6.QtGui import QFont from PySide6.QtGui import QFont
from datetime import datetime from datetime import datetime
from functools import partial
from icons import icon_theme from icons import icon_theme, icon_cog, icon_save, icon_load
from icons import icon_theme
THEMES = [ THEMES = [
# Dark themes {'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': 'GREEN', {'name': 'AMBER', 'accent': '#ffaa00', 'accent_dim': '#cc8800', 'bg': '#0d0d00', 'bg2': '#111100', 'border': '#3a3000', 'text': '#ddddcc', 'is_light': False},
'accent': '#00ff41', {'name': 'BLUE', 'accent': '#00aaff', 'accent_dim': '#0077cc', 'bg': '#00080d', 'bg2': '#001122', 'border': '#001a3a', 'text': '#ccd8dd', 'is_light': False},
'accent_dim': '#00aa2a', {'name': 'RED', 'accent': '#ff4444', 'accent_dim': '#cc2222', 'bg': '#0d0000', 'bg2': '#110000', 'border': '#3a0000', 'text': '#ddcccc', 'is_light': False},
'bg': '#0d0d0d', {'name': 'PURPLE', 'accent': '#cc00ff', 'accent_dim': '#9900cc', 'bg': '#0d000d', 'bg2': '#1a001a', 'border': '#3a003a', 'text': '#ddccdd', 'is_light': False},
'bg2': '#111111', {'name': 'CYAN', 'accent': '#00ffff', 'accent_dim': '#00cccc', 'bg': '#000d0d', 'bg2': '#001a1a', 'border': '#003a3a', 'text': '#ccdddd', 'is_light': False},
'border': '#1a3a1a', {'name': 'ORANGE', 'accent': '#ff8800', 'accent_dim': '#cc6600', 'bg': '#0d0700', 'bg2': '#1a1100', 'border': '#3a2200', 'text': '#ddccbb', 'is_light': False},
'text': '#cccccc', {'name': 'PINK', 'accent': '#ff1493', 'accent_dim': '#cc0066', 'bg': '#0d0005', 'bg2': '#1a000a', 'border': '#3a0015', 'text': '#ddccdd', 'is_light': False},
'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': 'AMBER', {'name': 'TURQUOISE', 'accent': '#40e0d0', 'accent_dim': '#20b0a0', 'bg': '#000d0a', 'bg2': '#001a15', 'border': '#00382d', 'text': '#ccddda', 'is_light': False},
'accent': '#ffaa00', {'name': 'CRIMSON', 'accent': '#dc143c', 'accent_dim': '#aa0022', 'bg': '#0d0002', 'bg2': '#1a0005', 'border': '#3a000a', 'text': '#ddcccc', 'is_light': False},
'accent_dim': '#cc8800', {'name': 'CHARTREUSE', 'accent': '#7fff00', 'accent_dim': '#5fcc00', 'bg': '#050d00', 'bg2': '#0a1a00', 'border': '#153a00', 'text': '#ddddcc', 'is_light': False},
'bg': '#0d0d00', {'name': 'PAPER WHITE', 'accent': '#0066cc', 'accent_dim': '#004488', 'bg': '#ffffff', 'bg2': '#eeeeee', 'border': '#cccccc', 'text': '#111111', 'is_light': True},
'bg2': '#111100', {'name': 'GREY PAPER', 'accent': '#cc3300', 'accent_dim': '#992200', 'bg': '#e8e8e8', 'bg2': '#d8d8d8', 'border': '#aaaaaa', 'text': '#111111', 'is_light': True},
'border': '#3a3000', {'name': 'CREAM', 'accent': '#884422', 'accent_dim': '#663311', 'bg': '#f5f0e8', 'bg2': '#e8e0d4', 'border': '#c0b8a8', 'text': '#221100', 'is_light': True},
'text': '#ddddcc', {'name': 'FROST', 'accent': '#0088aa', 'accent_dim': '#006688', 'bg': '#f0f4f8', 'bg2': '#e0e8f0', 'border': '#b0c0d0', 'text': '#001122', 'is_light': True},
'is_light': False, {'name': 'LIGHT LAVENDER','accent': '#6633cc','accent_dim': '#4422aa', 'bg': '#f5f0fa', 'bg2': '#e8e0f0', 'border': '#c0b8d0', 'text': '#110022', 'is_light': True},
}, ]
{
'name': 'BLUE', RATIOS = [
'accent': '#00aaff', ("21:9", 2560, 1080),
'accent_dim': '#0077cc', ("16:9", 1920, 1080),
'bg': '#00080d', ("4:3", 1440, 1080),
'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,
},
] ]
class TopBarWidget(QFrame): class TopBarWidget(QFrame):
theme_changed = Signal(dict) theme_changed = Signal(dict)
ratio_changed = Signal(int, int)
save_session = Signal()
load_session = Signal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._theme_index = 0 self._theme_index = 0
self._ratio_index = 1
self._midi_engine = None
self.setFrameStyle(QFrame.Box | QFrame.Raised) self.setFrameStyle(QFrame.Box | QFrame.Raised)
self.setFixedHeight(80) self.setFixedHeight(56)
self._apply_bar_style() self._apply_bar_style()
self._build_ui() self._build_ui()
@@ -214,13 +64,15 @@ class TopBarWidget(QFrame):
self.timer.setInterval(1000) self.timer.setInterval(1000)
self.timer.timeout.connect(self._update) self.timer.timeout.connect(self._update)
self.timer.start() self.timer.start()
self._update() self._update()
def set_midi_engine(self, engine):
self._midi_engine = engine
def _apply_bar_style(self): def _apply_bar_style(self):
theme = THEMES[self._theme_index] theme = THEMES[self._theme_index]
self.setStyleSheet(f""" self.setStyleSheet(f"""
QFrame {{ TopBarWidget {{
background-color: {theme['bg2']}; background-color: {theme['bg2']};
border: 2px solid {theme['border']}; border: 2px solid {theme['border']};
border-radius: 6px; border-radius: 6px;
@@ -229,46 +81,68 @@ class TopBarWidget(QFrame):
def _build_ui(self): def _build_ui(self):
layout = QHBoxLayout(self) layout = QHBoxLayout(self)
layout.setContentsMargins(20, 8, 20, 8) layout.setContentsMargins(16, 4, 16, 4)
layout.setSpacing(12) layout.setSpacing(8)
# Theme cycle button
self.theme_btn = QPushButton() self.theme_btn = QPushButton()
self.theme_btn.setFixedSize(40, 40) self.theme_btn.setFixedSize(30, 30)
self.theme_btn.setIconSize(QSize(22, 22)) self.theme_btn.setIconSize(QSize(16, 16))
self.theme_btn.setIcon(icon_theme()) self.theme_btn.setIcon(icon_theme())
self.theme_btn.setToolTip("Cycle colour theme") self.theme_btn.setToolTip("Cycle colour theme")
self.theme_btn.clicked.connect(self._cycle_theme) self.theme_btn.clicked.connect(self._cycle_theme)
self._style_theme_btn() self._style_theme_btn()
layout.addWidget(self.theme_btn) layout.addWidget(self.theme_btn)
# Station name
self.station_label = QLabel("GETTING TO IT") self.station_label = QLabel("GETTING TO IT")
self.station_label.setFont(QFont("Arial", 16, QFont.Bold)) self.station_label.setFont(QFont("Arial", 13, QFont.Bold))
self.station_label.setStyleSheet( self.station_label.setStyleSheet("color: #555555; background: transparent; border: none;")
"color: #444444; background: transparent; border: none;"
)
self.station_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
layout.addWidget(self.station_label) 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() layout.addStretch()
# Date
self.date_label = QLabel("") self.date_label = QLabel("")
self.date_label.setFont(QFont("Arial", 20, QFont.Bold)) self.date_label.setFont(QFont("Arial", 13, QFont.Bold))
self.date_label.setAlignment(Qt.AlignCenter | Qt.AlignVCenter) self.date_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.date_label) layout.addWidget(self.date_label)
layout.addStretch() layout.addStretch()
# Clock
self.clock_label = QLabel("") 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.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.clock_label.setMinimumWidth(220) self.clock_label.setMinimumWidth(170)
self._style_clock_and_date() self._style_clock_and_date()
layout.addWidget(self.clock_label) 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): def _style_theme_btn(self):
theme = THEMES[self._theme_index] theme = THEMES[self._theme_index]
self.theme_btn.setStyleSheet(f""" self.theme_btn.setStyleSheet(f"""
@@ -276,15 +150,12 @@ class TopBarWidget(QFrame):
background-color: transparent; background-color: transparent;
color: {theme['accent']}; color: {theme['accent']};
border: 2px solid {theme['accent']}; border: 2px solid {theme['accent']};
border-radius: 20px; border-radius: 15px;
}} }}
QPushButton:hover {{ QPushButton:hover {{
background-color: {theme['accent']}; background-color: {theme['accent']};
color: {'#ffffff' if not theme['is_light'] else '#000000'}; color: {'#ffffff' if not theme['is_light'] else '#000000'};
}} }}
QPushButton:pressed {{
background-color: {theme['accent_dim']};
}}
""") """)
def _style_clock_and_date(self): def _style_clock_and_date(self):
@@ -292,7 +163,6 @@ class TopBarWidget(QFrame):
self.clock_label.setStyleSheet( self.clock_label.setStyleSheet(
f"color: {theme['accent']}; background: transparent; border: none;" f"color: {theme['accent']}; background: transparent; border: none;"
) )
# Date color adapts to light/dark
date_color = '#555555' if theme['is_light'] else '#aaaaaa' date_color = '#555555' if theme['is_light'] else '#aaaaaa'
self.date_label.setStyleSheet( self.date_label.setStyleSheet(
f"color: {date_color}; background: transparent; border: none;" f"color: {date_color}; background: transparent; border: none;"
@@ -308,8 +178,304 @@ class TopBarWidget(QFrame):
def _update(self): def _update(self):
now = datetime.now() now = datetime.now()
self.clock_label.setText(now.strftime("%H:%M:%S")) 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 @property
def current_theme(self) -> dict: def current_theme(self) -> dict:
return THEMES[self._theme_index] 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")