From 6067f442b0feeec15d485237aff88debed1cbec4 Mon Sep 17 00:00:00 2001 From: njmb Date: Wed, 13 May 2026 20:53:15 +1000 Subject: [PATCH] v0.4 - cockybastard --- cart_widget.py | 30 ++++++++--------- main.py | 2 +- midi_engine.py | 84 ++++++++++++++++++++++++++++++++++------------- mixer_widget.py | 81 +++++++++++++++++++++++++++++++++------------ top_bar_widget.py | 64 +++++++++++++++++++++--------------- 5 files changed, 174 insertions(+), 87 deletions(-) diff --git a/cart_widget.py b/cart_widget.py index c418483..67263d4 100644 --- a/cart_widget.py +++ b/cart_widget.py @@ -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 { diff --git a/main.py b/main.py index 5412fd3..9ee58f5 100644 --- a/main.py +++ b/main.py @@ -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, diff --git a/midi_engine.py b/midi_engine.py index 0a35481..ed943d5 100644 --- a/midi_engine.py +++ b/midi_engine.py @@ -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,15 +255,25 @@ 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': - scaled = (127 - value if inv else value) / 127.0 - self.mixer_volume.emit(target_id, scaled) + 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) self.mixer_mute.emit(target_id, muted) @@ -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': - 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) + 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_knob_values[knob] = scaled + self.master_control.emit(knob, scaled) def _handle_note_on(self, note: int): # Hardcoded Numark DJ2Go mapping (legacy) diff --git a/mixer_widget.py b/mixer_widget.py index 0251795..aafbdad 100644 --- a/mixer_widget.py +++ b/mixer_widget.py @@ -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)) - painter.drawLine(rect.right() - 5, y, rect.right(), y) - painter.setPen(QPen(QColor("#444444"), 1)) - painter.drawText(rect.left() - 13, y + 4, f"{db}") + 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("#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 diff --git a/top_bar_widget.py b/top_bar_widget.py index 97ada90..d42fe8b 100644 --- a/top_bar_widget.py +++ b/top_bar_widget.py @@ -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 ──────────────────────────────────────────────────