v0.4 - cockybastard

This commit is contained in:
2026-05-13 20:53:15 +10:00
parent 7de0cc3de0
commit 6067f442b0
5 changed files with 174 additions and 87 deletions
+15 -15
View File
@@ -29,7 +29,7 @@ class CartSlot(QFrame):
}
""")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setFixedHeight(110)
self.setFixedHeight(80)
self._build_ui()
self._connect_signals()
@@ -41,13 +41,13 @@ class CartSlot(QFrame):
def _build_ui(self):
root = QHBoxLayout(self)
root.setContentsMargins(8, 6, 8, 6)
root.setSpacing(8)
root.setContentsMargins(6, 4, 6, 4)
root.setSpacing(6)
badge = QLabel(str(self.slot_number))
badge.setFixedSize(28, 28)
badge.setFixedSize(22, 22)
badge.setAlignment(Qt.AlignCenter)
badge.setFont(QFont("Arial", 12, QFont.Bold))
badge.setFont(QFont("Arial", 10, QFont.Bold))
badge.setStyleSheet("""
background-color: #cc3300;
color: white;
@@ -67,11 +67,11 @@ class CartSlot(QFrame):
self.lcd_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
lcd_layout = QVBoxLayout(self.lcd_frame)
lcd_layout.setContentsMargins(6, 4, 6, 4)
lcd_layout.setSpacing(2)
lcd_layout.setContentsMargins(4, 2, 4, 2)
lcd_layout.setSpacing(1)
self.track_label = QLabel("EMPTY")
self.track_label.setFont(QFont("Courier New", 9, QFont.Bold))
self.track_label.setFont(QFont("Courier New", 8, QFont.Bold))
self.track_label.setStyleSheet(
"color: #00ff41; background: transparent; border: none;"
)
@@ -80,13 +80,13 @@ class CartSlot(QFrame):
time_row = QHBoxLayout()
self.elapsed_label = QLabel("00:00")
self.elapsed_label.setFont(QFont("Courier New", 14, QFont.Bold))
self.elapsed_label.setFont(QFont("Courier New", 11, QFont.Bold))
self.elapsed_label.setStyleSheet(
"color: #00ff41; background: transparent; border: none;"
)
self.remaining_label = QLabel("-00:00")
self.remaining_label.setFont(QFont("Courier New", 10))
self.remaining_label.setFont(QFont("Courier New", 8))
self.remaining_label.setStyleSheet(
"color: #00cc33; background: transparent; border: none;"
)
@@ -109,11 +109,11 @@ class CartSlot(QFrame):
root.addWidget(self.lcd_frame)
btn_col = QVBoxLayout()
btn_col.setSpacing(4)
btn_col.setSpacing(3)
self.btn_play = QPushButton()
self.btn_play.setFixedSize(60, 44)
self.btn_play.setIconSize(QSize(22, 22))
self.btn_play.setFixedSize(50, 32)
self.btn_play.setIconSize(QSize(18, 18))
self.btn_play.setIcon(icon_play("#00ff41"))
self.btn_play.setStyleSheet("""
QPushButton {
@@ -126,8 +126,8 @@ class CartSlot(QFrame):
""")
self.btn_eject = QPushButton()
self.btn_eject.setFixedSize(60, 30)
self.btn_eject.setIconSize(QSize(18, 18))
self.btn_eject.setFixedSize(50, 24)
self.btn_eject.setIconSize(QSize(14, 14))
self.btn_eject.setIcon(icon_eject("#ff4444"))
self.btn_eject.setStyleSheet("""
QPushButton {
+1 -1
View File
@@ -54,7 +54,7 @@ class RadioPanelWindow(QMainWindow):
self.deck4 = ExternalDeckWidget("deck4", "DECK 4", "#e17055", self.engine)
self.cart_bank = CartBankWidget(self.engine)
self.cart_bank.setFixedHeight(220)
self.cart_bank.setFixedHeight(180)
self.playlist_widget = PlaylistWidget(
engine=self.engine,
+58 -20
View File
@@ -18,6 +18,8 @@ logger = logging.getLogger(__name__)
NOTE_ON = 0x90
NOTE_OFF = 0x80
CC = 0xB0
PITCH_BEND = 0xE0
PITCH_BEND = 0xE0
CONFIG_DIR = os.path.expanduser("~/.gti_radiostudio")
CONFIG_FILE = os.path.join(CONFIG_DIR, "midi_map.json")
@@ -40,12 +42,13 @@ class MidiMapping:
self.bindings = {}
def add(self, action: str, target_id, midi_type: int, midi_channel: int,
midi_note: int, inverse: bool = False):
midi_note: int, inverse: bool = False, is_relative: bool = False):
key = (midi_type, midi_channel, midi_note)
self.bindings[key] = {
'action': action,
'target_id': target_id,
'inverse': inverse,
'is_relative': is_relative,
}
def lookup(self, midi_type: int, midi_channel: int,
@@ -63,6 +66,7 @@ class MidiMapping:
'action': val['action'],
'target_id': val['target_id'],
'inverse': val['inverse'],
'is_relative': val.get('is_relative', False),
})
with open(filepath, 'w') as f:
json.dump(data, f, indent=2)
@@ -78,6 +82,7 @@ class MidiMapping:
'action': entry['action'],
'target_id': entry['target_id'],
'inverse': entry.get('inverse', False),
'is_relative': entry.get('is_relative', False),
}
@@ -93,6 +98,7 @@ class MidiEngine(QObject):
master_control = Signal(str, float)
controller_connected = Signal(bool, str)
mapping_learned = Signal(str, object, int, int, int, bool)
raw_midi_event = Signal(str, int, int, int)
def __init__(self, parent=None):
super().__init__(parent)
@@ -107,6 +113,9 @@ class MidiEngine(QObject):
self._learn_mode = False
self._last_cc_values = {}
self._channel_volumes = {}
self._master_knob_values = {}
self._master_knob_values = {}
@property
def learn_mode(self) -> bool:
@@ -200,13 +209,18 @@ class MidiEngine(QObject):
status_type = status & 0xF0
channel = status & 0x0F
# Learn mode: capture any MIDI event
type_names = {0x90: "note_on", 0x80: "note_off", 0xB0: "cc", 0xE0: "pitch", 0xD0: "aftertouch"}
type_name = type_names.get(status_type, f"0x{status_type:02X}")
self.raw_midi_event.emit(type_name, channel, data_byte, value)
if self._learn_mode and self._learn_target:
action, target_id = self._learn_target
mapping_type = status_type
self.mapping.add(action, target_id, mapping_type, channel, data_byte)
is_rel = (status_type == 0xB0 and (1 <= value <= 63 or 65 <= value <= 127))
note = 0 if status_type == PITCH_BEND else data_byte
self.mapping.add(action, target_id, mapping_type, channel, note, is_relative=is_rel)
self.mapping.save()
self.mapping_learned.emit(action, target_id, mapping_type, channel, data_byte, value > 0)
self.mapping_learned.emit(action, target_id, mapping_type, channel, note, value > 0)
self.stop_learn()
return
@@ -218,6 +232,11 @@ class MidiEngine(QObject):
elif status_type == CC:
self._handle_cc(data_byte, value)
self._handle_learned_event(status_type, channel, data_byte, value)
elif status_type == PITCH_BEND:
lsb = data_byte
msb = message[2] if len(message) > 2 else 0
pb_value = (msb << 7) | lsb
self._handle_learned_event(status_type, channel, 0, pb_value)
def _handle_learned_event(self, midi_type: int, channel: int,
note: int, value: int):
@@ -228,6 +247,7 @@ class MidiEngine(QObject):
action = binding['action']
target_id = binding['target_id']
inv = binding.get('inverse', False)
is_rel = binding.get('is_relative', False)
if action == 'deck_play_pause' and isinstance(target_id, str):
self.deck_play_pause.emit(target_id)
@@ -235,14 +255,24 @@ class MidiEngine(QObject):
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':
if is_rel and 1 <= value <= 63:
prev = self._channel_volumes.get(target_id, 1.0)
new_vol = min(1.0, prev + value * 0.02)
self._channel_volumes[target_id] = new_vol
self.mixer_volume.emit(target_id, new_vol)
elif is_rel and 65 <= value <= 127:
prev = self._channel_volumes.get(target_id, 1.0)
steps = value & 0x3F
new_vol = max(0.0, prev - steps * 0.02)
self._channel_volumes[target_id] = new_vol
self.mixer_volume.emit(target_id, new_vol)
else:
if midi_type == PITCH_BEND:
scaled = (16383 - value if inv else value) / 16383.0
else:
scaled = (127 - value if inv else value) / 127.0
self._channel_volumes[target_id] = scaled
self.mixer_volume.emit(target_id, scaled)
elif action == 'mixer_mute':
muted = (value < 64) if inv else (value > 63)
@@ -254,17 +284,25 @@ class MidiEngine(QObject):
on = (value > 63) if not inv else (value < 64)
self.mixer_pfl.emit(target_id, on)
elif action == 'master_control':
knob = str(target_id)
if is_rel and 1 <= value <= 63:
prev = self._master_knob_values.get(knob, 0.5)
new_val = min(1.0, prev + value * 0.02)
self._master_knob_values[knob] = new_val
self.master_control.emit(knob, new_val)
elif is_rel and 65 <= value <= 127:
prev = self._master_knob_values.get(knob, 0.5)
steps = value & 0x3F
new_val = max(0.0, prev - steps * 0.02)
self._master_knob_values[knob] = new_val
self.master_control.emit(knob, new_val)
else:
if midi_type == PITCH_BEND:
scaled = (16383 - value if inv else value) / 16383.0
else:
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)
self._master_knob_values[knob] = scaled
self.master_control.emit(knob, scaled)
def _handle_note_on(self, note: int):
# Hardcoded Numark DJ2Go mapping (legacy)
+59 -20
View File
@@ -10,6 +10,7 @@ from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen
from audio_engine import NUM_MIXER_CHANNELS
from icons import icon_mute, icon_solo
import math
class VUMeter(QWidget):
@@ -27,23 +28,29 @@ class VUMeter(QWidget):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect().adjusted(14, 3, -3, -3)
rect = self.rect().adjusted(18, 3, -3, -3)
if rect.width() < 1 or rect.height() < 1:
painter.end()
return
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:
db_markings = [(-40,), (-30,), (-20,), (-12,), (-6,), (0,)]
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(color), 1))
if db == -6:
painter.setPen(QPen(QColor("#00ff88"), 2))
painter.drawLine(rect.right() - 3, y, rect.right(), y)
painter.setFont(QFont("Segoe UI", 8, QFont.Bold))
painter.setPen(QPen(QColor("#ffffff"), 1))
else:
painter.setPen(QPen(QColor("#2a2a2a"), 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("#cccccc"), 1))
painter.setFont(QFont("Segoe UI", 7))
painter.drawText(rect.left() - 16, y + 3, f"{db}")
painter.setPen(QPen(QColor("#1a1a1a"), 1))
painter.drawRect(rect)
@@ -63,12 +70,13 @@ class VUMeter(QWidget):
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_pct = math.sqrt(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:
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)
@@ -99,7 +107,7 @@ class ChannelStrip(QFrame):
self._connect_signals()
self.timer = QTimer(self)
self.timer.setInterval(80)
self.timer.setInterval(30)
self.timer.timeout.connect(self._refresh)
self.timer.start()
@@ -126,13 +134,15 @@ class ChannelStrip(QFrame):
""")
root.addWidget(self.name_btn)
meter_row = QHBoxLayout()
meter_row.setSpacing(2)
self.vu_meter = VUMeter()
root.addWidget(self.vu_meter, stretch=1)
meter_row.addWidget(self.vu_meter, stretch=1)
self.volume_slider = QSlider(Qt.Vertical)
self.volume_slider.setRange(0, 100)
self.volume_slider.setValue(100)
self.volume_slider.setFixedHeight(60)
self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100))
self.volume_slider.setStyleSheet("""
QSlider::groove:vertical {
background: #22242a;
@@ -147,13 +157,19 @@ class ChannelStrip(QFrame):
border-radius: 2px;
}
QSlider::handle:vertical:hover { background: #b0b8c8; }
QSlider::sub-page:vertical {
QSlider::add-page:vertical {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #00cc66, stop:1 #008844);
border-radius: 2px;
}
QSlider::sub-page:vertical {
background: transparent;
border-radius: 2px;
}
""")
root.addWidget(self.volume_slider, alignment=Qt.AlignCenter)
meter_row.addWidget(self.volume_slider, stretch=1)
root.addLayout(meter_row, stretch=1)
btn_row = QHBoxLayout()
btn_row.setSpacing(3)
@@ -238,7 +254,8 @@ class ChannelStrip(QFrame):
self.name_btn.setText(self._channel.name)
def _on_volume_changed(self, value: int):
self.engine.set_channel_volume(self.channel_index, value / 100.0)
vol = (value / 100.0) ** 2
self.engine.set_channel_volume(self.channel_index, vol)
def _on_mute_toggled(self, checked: bool):
self.engine.set_channel_mute(self.channel_index, checked)
@@ -251,6 +268,8 @@ class ChannelStrip(QFrame):
def _refresh(self):
self.vu_meter.set_value(self._channel.vu_peak)
self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100))
self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100))
def apply_theme(self, theme: dict):
pass
@@ -262,8 +281,10 @@ class MasterSection(QFrame):
self.engine = engine
self._master = engine.mixer['master']
self._knobs = {}
self.setMinimumWidth(180)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setStyleSheet("""
MasterSection {
background-color: #181a1e;
@@ -276,7 +297,7 @@ class MasterSection(QFrame):
self._connect_signals()
self.timer = QTimer(self)
self.timer.setInterval(80)
self.timer.setInterval(30)
self.timer.timeout.connect(self._refresh)
self.timer.start()
@@ -355,9 +376,10 @@ 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,
k, dial, val_label = self._make_knob(label, self._master.compressor, attr,
default, lo, hi, suffix)
knobs.addWidget(k)
self._knobs[('compressor', attr)] = (dial, val_label, lo, hi, suffix)
comp_layout.addLayout(knobs)
root.addWidget(comp_frame)
@@ -402,9 +424,10 @@ class MasterSection(QFrame):
lim_knobs = QHBoxLayout()
lim_knobs.setSpacing(4)
k = self._make_knob("CEIL", self._master.limiter, "ceiling_db",
k, dial, val_label = self._make_knob("CEIL", self._master.limiter, "ceiling_db",
-0.1, -10, 0, "dB")
lim_knobs.addWidget(k)
self._knobs[('limiter', 'ceiling_db')] = (dial, val_label, -10, 0, "dB")
lim_layout.addLayout(lim_knobs)
root.addWidget(lim_frame)
root.addStretch()
@@ -467,7 +490,7 @@ class MasterSection(QFrame):
dial.valueChanged.connect(_on_dial)
layout.addWidget(dial, alignment=Qt.AlignCenter)
layout.addWidget(val_label)
return frame
return frame, dial, val_label
def _connect_signals(self):
self.comp_bypass.toggled.connect(
@@ -493,6 +516,22 @@ class MasterSection(QFrame):
"color: #556; background: transparent; border: none;"
)
for (section, attr), (dial, val_label, lo, hi, suffix) in self._knobs.items():
obj = self._master.compressor if section == 'compressor' else self._master.limiter
val = getattr(obj, attr, lo)
dial.blockSignals(True)
dial.setValue(int((val - lo) / (hi - lo) * 1000))
dial.blockSignals(False)
val_label.setText(f"{val}{suffix}")
for (section, attr), (dial, val_label, lo, hi, suffix) in self._knobs.items():
obj = self._master.compressor if section == 'compressor' else self._master.limiter
val = getattr(obj, attr, lo)
dial.blockSignals(True)
dial.setValue(int((val - lo) / (hi - lo) * 1000))
dial.blockSignals(False)
val_label.setText(f"{val}{suffix}")
def apply_theme(self, theme: dict):
pass
+37 -27
View File
@@ -2,7 +2,7 @@
from PySide6.QtWidgets import (
QFrame, QHBoxLayout, QVBoxLayout, QLabel, QPushButton,
QComboBox, QScrollArea, QWidget
QComboBox, QScrollArea, QWidget, QListWidget
)
from PySide6.QtCore import Qt, QTimer, Signal, QSize, QPoint
from PySide6.QtGui import QFont
@@ -134,7 +134,6 @@ class TopBarWidget(QFrame):
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))
@@ -244,14 +243,12 @@ POPUP_STYLE = """
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")
@@ -265,21 +262,9 @@ class SettingsPopup(QFrame):
root.setContentsMargins(12, 10, 12, 10)
root.setSpacing(6)
# Row 1: Aspect ratio + Save/Load
# Row 1: 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)
@@ -326,7 +311,7 @@ class SettingsPopup(QFrame):
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFixedHeight(170)
scroll.setFixedHeight(200)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll.setStyleSheet(POPUP_STYLE)
@@ -347,6 +332,14 @@ class SettingsPopup(QFrame):
("Cart 2", "cart_trigger", 1),
("Cart 3", "cart_trigger", 2),
("Cart 4", "cart_trigger", 3),
("Ch 1 Volume", "mixer_volume", 0),
("Ch 2 Volume", "mixer_volume", 1),
("Ch 3 Volume", "mixer_volume", 2),
("Ch 4 Volume", "mixer_volume", 3),
("Ch 5 Volume", "mixer_volume", 4),
("Ch 6 Volume", "mixer_volume", 5),
("Ch 7 Volume", "mixer_volume", 6),
("Ch 8 Volume", "mixer_volume", 7),
("Ch 1 Solo", "mixer_solo", 0),
("Ch 1 PFL", "mixer_pfl", 0),
("Ch 2 Solo", "mixer_solo", 1),
@@ -392,7 +385,7 @@ class SettingsPopup(QFrame):
font-weight: bold;
padding: 2px 4px;
}
QPushButton:hover { background-color: #5a2a2a; }
QPushButton:hover { background-color: #5a2a2a; }
""")
clear_btn.setFixedSize(20, 20)
clear_btn.clicked.connect(partial(self._clear_mapping, action, target_id))
@@ -403,19 +396,36 @@ class SettingsPopup(QFrame):
scroll.setWidget(content)
root.addWidget(scroll)
monitor_label = QLabel("MIDI MONITOR")
monitor_label.setStyleSheet("color: #556670; background: transparent; border: none; font-size: 7pt; font-weight: bold;")
root.addWidget(monitor_label)
self._midi_monitor = QListWidget()
self._midi_monitor.setFixedHeight(80)
self._midi_monitor.setStyleSheet("""
QListWidget {
background-color: #0d0e12;
border: 1px solid #22242a;
border-radius: 3px;
color: #8890a0;
font-size: 7pt;
font-family: monospace;
}
""")
self._refresh_ports()
if self._midi_engine:
self._midi_engine.mapping_learned.connect(self._on_mapping_learned)
self._midi_engine.raw_midi_event.connect(self._on_raw_midi)
# ── 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)
def _on_raw_midi(self, type_name, channel, control, value):
self._midi_monitor.addItem(
f"{type_name:>10} ch{channel} ctl={control:>3} val={value:>3}"
)
while self._midi_monitor.count() > 200:
self._midi_monitor.takeItem(0)
self._midi_monitor.scrollToBottom()
# ── MIDI port ──────────────────────────────────────────────────