Compare commits

...

3 Commits

Author SHA1 Message Date
Paul Lipscomb 327ca7e00a Layout panel wired, section visibility, preset management improvements
- Wire all 5 Layout panel section toggles (Preset Browser, Big Title, Fader Row, Toggle Row, Transport Row)
- Replace checkboxes with clickable title buttons (highlighted=visible, dimmed=hidden)
- Move Big Title inside fader container, between Hide Params button and fader strips
- Wrap transport/fader/toggle rows in container widgets for setVisible support
- Reposition separator between preset browser and mixer row
- Open Layout panel by default on launch
- Add Duplicate preset button (auto-generates new UUID)
- Add Rename preset button
- Swap preset browser label and show-hide button order
- Persist section visibility state per preset under new 'sections' key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 21:18:28 -04:00
Paul Lipscomb d8f4cbcdd4 add Mom/Tog to ToggleWidget, Big Title, Layout panel scaffold
- ToggleWidget: add Momentary/Toggle mode selector (mirrors TransportWidget
  minus OSC); HW routing respects trigger_mode; fix invisible button on init
  by removing explicit empty stylesheet; saves/loads trigger_mode per preset
- Big Title: full-width 28px bold QLineEdit above fader row, saves per-preset
  as big_title; green underline on focus; single bottom border, no separator
- Layout panel: floating 220px window to the left of main window, toggled by
  new Layout button next to Messages; shows Sections checkboxes (Preset
  Browser, Big Title, Fader Row, Toggle Row, Transport Row); tracks window
  move/resize; checkboxes not yet wired to functionality

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 18:49:50 -04:00
Paul Lipscomb b68e5e3ac2 major session: transport lasso layout, MIDI learn wiring, note passthrough, log windows, lock preset, CC blocking
- transport widget restructured to two-group lasso layout (grp_learn + grp_cc) matching fader/toggle pattern
- fixed MIDI learn for transport (bind_hw_cc channel param) and preset browser (wired to check_and_start_midi_learn)
- transport HW routing now respects output_mode and trigger_mode; OSC mode fires on any value, MIDI momentary fires on any value
- preset browser on_hw_trigger fires on any value (0 or 127 both send 127 out)
- unbound CCs are dropped, no passthrough; notes (0x80/0x90) pass through freely to midi_out
- four MIDI/OSC log windows: OSC In, OSC Out, MIDI In, MIDI Out with formatted note/CC display
- midi_out_signal now carries message list for logging
- lock preset checkbox: always defaults to True on launch, not persisted
- transport cc_output_lbl always visible, content adapts to MIDI/OSC mode
- preset browser show/hide saves and restores correctly per preset
- CLAUDE.md added with full project architecture, conventions, and planned features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 17:56:45 -04:00
4 changed files with 476 additions and 45 deletions
+155
View File
@@ -0,0 +1,155 @@
# Virtual Controller — Project Context
## What this app is
A PyQt6 desktop application that acts as a MIDI/OSC routing hub and control surface. It sits between hardware MIDI controllers (AKAI, JL Cooper motorized fader bank, iPad via Network MIDI) and a DAW (REAPER). It translates incoming CC numbers to different output CC numbers and channels, applies momentary/toggle logic, and manages named presets per instrument/VSTi.
## Architecture
### Files
- `main.py` — app entry point, all layout assembly, routing logic, preset load/save
- `faderwidget.py` — fader strip widget (slider + CC/CH translation + learn)
- `togglewidget.py` — toggle button widget (CC/CH translation + learn)
- `transportwidget.py` — transport button widget (MIDI or OSC output, Mom/Tog mode)
- `presetwidget.py` — preset browser widget (Back/Fwd, CC emit for VSTi preset control)
- `midi_sender.py``send_cc()` and `send_transport_cc()`, emits `midi_out_signal`
- `midi_receiver.py``MidiReceiver` QObject with signals: `midi_in_signal(list)`, `midi_out_signal(list)`, `hw_cc_signal(str, int)`
- `osc_sender.py``send_osc_message(addr, val)`
- `osc_receiver.py``OSCReceiver` QObject with signals for DAW feedback
- `zonebutton.py` — split L/R zone assignment button (orange=left, green=right)
- `transportwidget.py` — transport buttons, MIDI or OSC output mode
- `styles.py` — shared style constants
- `palette.py` — color palette definitions
- `presets.py` — preset file load/save helpers
### Key globals in main.py
- `faders` — list of FaderWidget
- `toggles` — list of ToggleWidget
- `transports` — list of TransportWidget
- `preset_browsers` — [preset_browser_back, preset_browser_fwd]
- `learning_strip` — whichever widget is currently in MIDI learn mode
- `preset_switch_locked` — bool, prevents DAW OSC from auto-switching presets (default True on launch)
- `fader_show_hide_config`, `toggle_show_hide_config`, `transport_show_hide_config`, `preset_browser_show_hide_config`
## Widget design pattern
All widgets follow the same two-group lasso layout:
```
┌─ grp_learn ──────────────┐
│ learn_btn │
│ cc_input_lbl │
└──────────────────────────┘
▼ (arrow_lbl)
┌─ grp_cc ─────────────────┐
│ CC spin / OSC addr │
│ CH spin │
│ cc_output_lbl │
└──────────────────────────┘
[main button/slider]
[title_edit scribble strip]
```
- `grp_learn` and `grp_cc` are QFrame with `border: 1px solid rgba(255,255,255,0.2)`
- `arrow_lbl` is a `▼` label between groups
- Show/hide hides the groups and arrow, leaves the button/slider visible
- `cc_input_lbl` shows incoming HW CC and channel
- `cc_output_lbl` shows outgoing CC, channel, and last value
## MIDI routing (route_midi_message_input)
```
CC in → check learning_strip → bind and return
→ check bound to fader/toggle → emit hw_cc_signal
→ check bound to transport → emit hw_cc_signal
→ check bound to preset browser → on_hw_trigger()
→ unbound CC → drop (NO passthrough)
Note on/off → pass through to midi_out unchanged
```
**Critical:** Unbound CCs are silently dropped. All CC that the app should act on must be explicitly learned/bound to a widget. Notes pass through freely.
## CC translation pattern
Every widget has:
- `hw_cc` / `hw_channel` — what the hardware sends
- `cc_spin` / `ch_spin` — what to send out
- `bind_hw_cc(cc_number, channel=None)` — called by router on learn
- `_cc_input_label(vel)` — formats input readout
- `_cc_output_label(vel)` — formats output readout
## Momentary vs Toggle
- **Momentary** — every trigger sends 127, ignore 0. Used in transport and preset browser.
- **Toggle** — tracks on/off state, alternates 127/0 on positive edge.
- Hardware toggle pads (send 127 then 0 alternating): in momentary mode, EVERY incoming value (0 or 127) fires 127 out — so every pad press does something regardless of pad mode.
## Preset system
Presets saved in `presets/presets.json`. Structure:
```json
{
"__last_used__": "name",
"presets": {
"name": {
"faders": [...],
"toggles": [...],
"browser": { "back": {...}, "fwd": {...} },
"show_hide": { "fader": bool, "toggle": bool, "transport": bool, "browser": bool },
"preset_uuid": "uuid-string"
}
},
"transport_preset": [...], GLOBAL, not per-preset
"transport_show_hide": bool, GLOBAL
"io_config": {...} GLOBAL
}
```
Transport is GLOBAL across all presets. Browser, faders, toggles are per-preset.
`preset_switch_locked` is NOT saved — always defaults to True (locked) on launch.
## DAW integration
- **OSC in** from REAPER: track name, preset name, play/record state, bar/position, UUID for auto preset switching
- **OSC out** to REAPER: transport button triggers (play, stop, record, arm)
- **MIDI in**: hardware CC from controllers
- **MIDI out**: translated CC to VSTi/DAW instruments
- **Transport MIDI out**: separate port for transport CC
## Log windows
Four floating windows toggled by "Messages" button:
- OSC In (green) — incoming from REAPER
- OSC Out (blue) — sent to REAPER
- MIDI In (orange) — incoming CC/notes from hardware
- MIDI Out (cyan) — outgoing translated CC
## Zone system
Faders and toggles can be assigned to Left or Right zones. The app shows vertical separators between zones. Zone button: left half = mango orange (#ff8c00), right half = green (#00c853).
## Planned: Android tablet performance surface
Architecture discussed but not yet built:
- PyQt6 app adds a WebSocket server (QWebSocketServer)
- Android app (Java) connects via WebSocket
- App broadcasts preset JSON on load/switch
- Android renders playable-only widgets (no config UI): faders, toggles, transport, preset browser
- Widgets are free-floating, draggable, lockable layout saved per-preset back to app
- Android app sends `{ type, uid, value }` messages back; app routes through existing CC/OSC logic
- No MIDI knowledge needed on Android side
## Planned: JL Cooper motorized fader sync
JL Cooper fader bank connected via MIDI. On preset load, app should send stored fader values back out to JL Cooper so motors move to correct positions. Need to confirm JL Cooper MIDI feedback channel spec.
## Conventions
- Width: faders 115px, toggles 115px, transport 100px, preset browser 115px
- All spinners: CC 0-127, CH 1-16
- Colors: active/learned green `#00e676`, unlearned gray `#888888`, warning orange `#ff8c00`
- No "In"/"Out" prefix on readout labels — implicit from position
- Show/hide hides config groups, leaves playable controls visible
- `check_and_start_midi_learn(strip)` must be called (not just `strip.start_learn()`) to wire the global router
+202 -14
View File
@@ -113,6 +113,7 @@ view_uuid_error_overlay = None
label_overlay_message = None
placeholder_fader_empty = None
placeholder_toggle_empty = None
big_title_edit = None
@@ -165,6 +166,14 @@ buttonImport = QPushButton("Import")
buttonImport.setFixedWidth(55)
buttonImport.clicked.connect(lambda: import_preset())
buttonDuplicate = QPushButton("Duplicate")
buttonDuplicate.setFixedWidth(75)
buttonDuplicate.clicked.connect(lambda: duplicate_preset())
buttonRename = QPushButton("Rename")
buttonRename.setFixedWidth(65)
buttonRename.clicked.connect(lambda: rename_preset())
checkbox_lock_preset = QCheckBox("Lock Preset")
checkbox_lock_preset.setStyleSheet("color: #aaa; font-size: 11px;")
checkbox_lock_preset.setChecked(True)
@@ -346,6 +355,11 @@ button_osc_messages_log = QPushButton("Messages")
button_osc_messages_log.setFixedWidth(80)
button_osc_messages_log.setCheckable(True)
# Widget: button_layout_panel
button_layout_panel = QPushButton("Layout")
button_layout_panel.setFixedWidth(65)
button_layout_panel.setCheckable(True)
# Layout: hstack_osc_status
hstack_osc_status = QHBoxLayout()
@@ -553,6 +567,51 @@ textedit_midi_out_log.setStyleSheet("""
}
""")
# MARK: LAYOUT PANEL
container_layout_panel = QWidget(None, Qt.WindowType.Window)
container_layout_panel.setWindowTitle("Layout")
container_layout_panel.setStyleSheet("background-color: #1a1a1a;")
container_layout_panel.setFixedWidth(220)
vstack_layout_panel = QVBoxLayout(container_layout_panel)
vstack_layout_panel.setContentsMargins(10, 10, 10, 10)
vstack_layout_panel.setSpacing(6)
label_layout_panel_title = QLabel("Sections")
label_layout_panel_title.setStyleSheet("color: #ffffff; font-weight: bold; font-size: 12px;")
vstack_layout_panel.addWidget(label_layout_panel_title)
_section_sep = QFrame()
_section_sep.setFrameShape(QFrame.Shape.HLine)
_section_sep.setStyleSheet("color: #3a3a3a;")
vstack_layout_panel.addWidget(_section_sep)
_section_btn_style = (
"QPushButton { background: transparent; border: none; color: #ffffff;"
" font-size: 12px; font-weight: bold; text-align: left; padding: 4px 6px; }"
"QPushButton:!checked { color: #444444; font-weight: normal; }"
)
def _make_section_row(label_text):
btn = QPushButton(label_text)
btn.setCheckable(True)
btn.setChecked(True)
btn.setFlat(True)
btn.setStyleSheet(_section_btn_style)
return btn
chk_section_preset_browser = _make_section_row("Preset Browser")
chk_section_big_title = _make_section_row("Big Title")
chk_section_fader_row = _make_section_row("Fader Row")
chk_section_toggle_row = _make_section_row("Toggle Row")
chk_section_transport_row = _make_section_row("Transport Row")
for _btn in (chk_section_preset_browser, chk_section_big_title, chk_section_fader_row, chk_section_toggle_row, chk_section_transport_row):
vstack_layout_panel.addWidget(_btn)
del _btn
vstack_layout_panel.addStretch()
# MARK: PRESET BROWSER ROW MODULE
label_preset_browser_title = QLabel("Preset Browser")
hstack_preset_browser = QHBoxLayout()
@@ -801,6 +860,8 @@ class MainWindow(QMainWindow):
hstack_preset_row.addWidget(buttonSave) #save button
hstack_preset_row.addWidget(buttonSaveAs) #save as button
hstack_preset_row.addWidget(buttonRemove) #remove button
hstack_preset_row.addWidget(buttonDuplicate)
hstack_preset_row.addWidget(buttonRename)
hstack_preset_row.addWidget(buttonExport)
hstack_preset_row.addWidget(buttonImport)
hstack_preset_row.addWidget(checkbox_lock_preset)
@@ -931,6 +992,7 @@ class MainWindow(QMainWindow):
# build hstack for control buttons
hstack_osc_buttons.addWidget(button_osc_toggle)
hstack_osc_buttons.addWidget(button_osc_messages_log)
hstack_osc_buttons.addWidget(button_layout_panel)
hstack_osc_buttons.addStretch()
# add all rows to vstack
@@ -944,6 +1006,7 @@ class MainWindow(QMainWindow):
spinner_osc_send_port.valueChanged.connect(update_osc_server_port)
button_osc_toggle.clicked.connect(start_stop_osc_server)
button_osc_messages_log.clicked.connect(log_ui_show_hide)
button_layout_panel.clicked.connect(layout_panel_show_hide)
# add the combined stack to the row
hstack_io_row.addLayout(vstack_osc)
@@ -1061,7 +1124,16 @@ class MainWindow(QMainWindow):
button_midi_out_clear.clicked.connect(lambda: textedit_midi_out_log.clear())
container_midi_out_log.setVisible(False)
main_layout.addWidget(make_separator())
#MARK: LAYOUT PANEL
container_layout_panel.setVisible(True)
button_layout_panel.setChecked(True)
container_transport_row = QWidget()
_vbox_transport_row = QVBoxLayout(container_transport_row)
_vbox_transport_row.setContentsMargins(0, 0, 0, 0)
_vbox_transport_row.setSpacing(0)
_vbox_transport_row.addWidget(make_separator())
_vbox_transport_row.addSpacing(4)
# MARK: TRANSPORT ROW CONTROLS
global hstack_row_transport_control_buttons
@@ -1070,7 +1142,7 @@ class MainWindow(QMainWindow):
hstack_row_transport_control_buttons.addWidget(button_transport_show_hide_config)
hstack_row_transport_control_buttons.addStretch()
button_transport_show_hide_config.clicked.connect(transport_show_hide_config_toggle)
main_layout.addLayout(hstack_row_transport_control_buttons)
_vbox_transport_row.addLayout(hstack_row_transport_control_buttons)
#MARK: TRANSPORT ROW
@@ -1091,7 +1163,9 @@ class MainWindow(QMainWindow):
hstack_full_transport.addWidget(vsep_transport_right)
hstack_full_transport.addLayout(hstack_transport_zone_right)
main_layout.addLayout(hstack_full_transport)
_vbox_transport_row.addLayout(hstack_full_transport)
main_layout.addWidget(container_transport_row)
chk_section_transport_row.toggled.connect(container_transport_row.setVisible)
#MARK: PRESET BROWSER CONTAINER
global container_preset_browser
@@ -1112,22 +1186,29 @@ class MainWindow(QMainWindow):
button_preset_browser_show_hide_config.clicked.connect(preset_browser_show_hide_config_toggle)
vstack_preset_browser_container.addWidget(make_separator())
vstack_preset_browser_container.addWidget(label_preset_browser_title)
vstack_preset_browser_container.addLayout(hstack_preset_browser_controls)
vstack_preset_browser_container.addWidget(label_preset_browser_title)
vstack_preset_browser_container.addLayout(hstack_preset_browser)
main_layout.addWidget(container_preset_browser)
main_layout.addWidget(make_separator())
chk_section_preset_browser.toggled.connect(container_preset_browser.setVisible)
# MARK: CONTAINER ROWS AND WIDGETS (fader + toggle only)
global container_rows_and_widgets
global view_uuid_error_overlay
global label_overlay_message
global big_title_edit
container_rows_and_widgets = QWidget()
controls_layout = QVBoxLayout(container_rows_and_widgets)
controls_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(container_rows_and_widgets)
controls_layout.addWidget(make_separator())
container_fader_row = QWidget()
_vbox_fader_row = QVBoxLayout(container_fader_row)
_vbox_fader_row.setContentsMargins(0, 0, 0, 0)
_vbox_fader_row.setSpacing(0)
# MARK: FADER ROW CONTROLS
global hstack_row_fader_control_buttons
@@ -1138,7 +1219,19 @@ class MainWindow(QMainWindow):
hstack_row_fader_control_buttons.addStretch()
button_fader_show_hide_config.clicked.connect(fader_show_hide_config_toggle)
button_fader_clear_all.clicked.connect(clear_all_faders)
controls_layout.addLayout(hstack_row_fader_control_buttons)
_vbox_fader_row.addLayout(hstack_row_fader_control_buttons)
# MARK: BIG TITLE
big_title_edit = QLineEdit("")
big_title_edit.setPlaceholderText("Title")
big_title_edit.setAlignment(Qt.AlignmentFlag.AlignCenter)
big_title_edit.setStyleSheet(
"QLineEdit { background: transparent; border: none;"
" color: #ffffff; font-size: 28px; font-weight: bold; padding: 4px 0; }"
"QLineEdit:focus { border-bottom: 1px solid #00e676; }"
)
_vbox_fader_row.addWidget(big_title_edit)
chk_section_big_title.toggled.connect(big_title_edit.setVisible)
#MARK: FADERS ROW
global hstack_full_fader
@@ -1155,7 +1248,7 @@ class MainWindow(QMainWindow):
hstack_full_fader.addWidget(separator_vertical_fader_zone_right)
hstack_full_fader.addLayout(hstack_fader_zone_right)
controls_layout.addLayout(hstack_full_fader)
_vbox_fader_row.addLayout(hstack_full_fader)
global placeholder_fader_empty
placeholder_fader_empty = QWidget()
@@ -1171,7 +1264,10 @@ class MainWindow(QMainWindow):
_pf_btn.clicked.connect(fader_add)
_pf_layout.addWidget(_pf_btn, alignment=Qt.AlignmentFlag.AlignCenter)
_pf_layout.addWidget(_pf_lbl)
controls_layout.addWidget(placeholder_fader_empty)
_vbox_fader_row.addWidget(placeholder_fader_empty)
controls_layout.addWidget(container_fader_row)
chk_section_fader_row.toggled.connect(container_fader_row.setVisible)
controls_layout.addWidget(make_separator())
@@ -1183,6 +1279,11 @@ class MainWindow(QMainWindow):
global separator_vertical_toggle_zone_right
global hstack_toggle_zone_right
container_toggle_row = QWidget()
_vbox_toggle_row = QVBoxLayout(container_toggle_row)
_vbox_toggle_row.setContentsMargins(0, 0, 0, 0)
_vbox_toggle_row.setSpacing(0)
# MARK: TOGGLE ROW CONTROLS
global hstack_row_toggle_control_buttons
global button_toggle_show_hide_config
@@ -1192,7 +1293,7 @@ class MainWindow(QMainWindow):
hstack_row_toggle_control_buttons.addStretch()
button_toggle_show_hide_config.clicked.connect(toggle_show_hide_config_toggle)
button_toggle_clear_all.clicked.connect(clear_all_toggles)
controls_layout.addLayout(hstack_row_toggle_control_buttons)
_vbox_toggle_row.addLayout(hstack_row_toggle_control_buttons)
#MARK: TOGGLES ROW
# assemble full toggle row
@@ -1202,7 +1303,7 @@ class MainWindow(QMainWindow):
hstack_full_toggle.addWidget(separator_vertical_toggle_zone_right)
hstack_full_toggle.addLayout(hstack_toggle_zone_right)
controls_layout.addLayout(hstack_full_toggle)
_vbox_toggle_row.addLayout(hstack_full_toggle)
global placeholder_toggle_empty
placeholder_toggle_empty = QWidget()
@@ -1218,7 +1319,10 @@ class MainWindow(QMainWindow):
_pt_btn.clicked.connect(toggle_add)
_pt_layout.addWidget(_pt_btn, alignment=Qt.AlignmentFlag.AlignCenter)
_pt_layout.addWidget(_pt_lbl)
controls_layout.addWidget(placeholder_toggle_empty)
_vbox_toggle_row.addWidget(placeholder_toggle_empty)
controls_layout.addWidget(container_toggle_row)
chk_section_toggle_row.toggled.connect(container_toggle_row.setVisible)
controls_layout.addStretch()
@@ -1288,11 +1392,15 @@ class MainWindow(QMainWindow):
super().resizeEvent(event)
if container_osc_in_log.isVisible():
log_ui_window_calc()
if container_layout_panel.isVisible():
layout_panel_window_calc()
def moveEvent(self, event):
super().moveEvent(event)
if container_osc_in_log.isVisible():
log_ui_window_calc()
if container_layout_panel.isVisible():
layout_panel_window_calc()
def make_separator():
sep = QWidget()
@@ -1347,12 +1455,54 @@ def new_blank_preset():
if not ok or not name.strip():
return
name = name.strip()
loaded_presets["presets"][name] = {"faders": [], "toggles": [], "preset_uuid": "", "browser": {"back": preset_browser_back.get_state(), "fwd": preset_browser_fwd.get_state()}, "show_hide": {}}
loaded_presets["presets"][name] = {"faders": [], "toggles": [], "preset_uuid": "", "browser": {"back": preset_browser_back.get_state(), "fwd": preset_browser_fwd.get_state()}, "show_hide": {}, "big_title": ""}
loaded_presets["__last_used__"] = name
save_presets_file(loaded_presets)
populate_preset_dropdown_menu()
menuPresets.setCurrentText(name)
clear_all_strips()
big_title_edit.setText("")
chk_section_preset_browser.setChecked(True)
chk_section_big_title.setChecked(True)
chk_section_fader_row.setChecked(True)
chk_section_toggle_row.setChecked(True)
chk_section_transport_row.setChecked(True)
show_save_status()
def rename_preset():
from PyQt6.QtWidgets import QInputDialog
current_name = menuPresets.currentText()
if not current_name:
return
new_name, ok = QInputDialog.getText(app_window, "Rename Preset", "New name:", text=current_name)
if not ok or not new_name.strip() or new_name.strip() == current_name:
return
new_name = new_name.strip()
loaded_presets["presets"][new_name] = loaded_presets["presets"].pop(current_name)
if loaded_presets.get("__last_used__") == current_name:
loaded_presets["__last_used__"] = new_name
save_presets_file(loaded_presets)
populate_preset_dropdown_menu()
menuPresets.setCurrentText(new_name)
show_save_status()
def duplicate_preset():
from PyQt6.QtWidgets import QInputDialog
current_name = menuPresets.currentText()
if not current_name:
return
new_name, ok = QInputDialog.getText(app_window, "Duplicate Preset", "Name for duplicate:", text=f"{current_name} Copy")
if not ok or not new_name.strip():
return
new_name = new_name.strip()
snapshot = preset_build_snapshot(current_name)
snapshot["preset_uuid"] = str(uuid.uuid4())
loaded_presets["presets"][new_name] = snapshot
loaded_presets["__last_used__"] = new_name
save_presets_file(loaded_presets)
populate_preset_dropdown_menu()
menuPresets.setCurrentText(new_name)
label_preset_uuid.setText(snapshot["preset_uuid"])
show_save_status()
def show_save_status():
@@ -1904,12 +2054,20 @@ def preset_build_snapshot(name):
"back": preset_browser_back.get_state(),
"fwd": preset_browser_fwd.get_state(),
},
"big_title": big_title_edit.text(),
"preset_uuid": label_preset_uuid.text().strip(),
"show_hide": {
"fader": fader_show_hide_config,
"toggle": toggle_show_hide_config,
"transport": transport_show_hide_config,
"browser": preset_browser_show_hide_config,
},
"sections": {
"preset_browser": chk_section_preset_browser.isChecked(),
"big_title": chk_section_big_title.isChecked(),
"fader_row": chk_section_fader_row.isChecked(),
"toggle_row": chk_section_toggle_row.isChecked(),
"transport_row": chk_section_transport_row.isChecked(),
}
}
@@ -2024,7 +2182,16 @@ def preset_load(preset):
button_preset_browser_show_hide_config.setText("Show Parameters" if show_hide.get("browser", False) else "Hide Parameters")
button_preset_browser_show_hide_config.setChecked(show_hide.get("browser", False))
big_title_edit.setText(preset.get("big_title", ""))
label_preset_uuid.setText(preset.get("preset_uuid", ""))
sections = preset.get("sections", {})
chk_section_preset_browser.setChecked(sections.get("preset_browser", True))
chk_section_big_title.setChecked(sections.get("big_title", True))
chk_section_fader_row.setChecked(sections.get("fader_row", True))
chk_section_toggle_row.setChecked(sections.get("toggle_row", True))
chk_section_transport_row.setChecked(sections.get("transport_row", True))
check_and_index_strip_labels()
def export_preset():
@@ -2249,13 +2416,19 @@ def midi_message_route_hw_to_output_cc(fader_uid, value):
if toggle.uid == fader_uid:
out_cc = toggle.cc_spin.value()
out_ch = toggle.ch_spin.value()
print(f"[toggle convert] HW CC{toggle.hw_cc} CH{toggle.hw_channel} ({value}) → CC{out_cc} CH{out_ch} ({value})")
print(f"[toggle convert] HW CC{toggle.hw_cc} CH{toggle.hw_channel} ({value}) → CC{out_cc} CH{out_ch}")
toggle.cc_input_lbl.setText(toggle._cc_input_label(value))
if toggle.trigger_mode == "momentary":
send_cc(out_cc, 127, channel=out_ch)
toggle.cc_output_lbl.setText(toggle._cc_output_label(127))
toggle.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;")
QTimer.singleShot(150, lambda: toggle.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;"))
else:
should_be_on = value > 63
if toggle.toggle_state != should_be_on:
toggle.toggle_state = should_be_on
out_val = 127 if should_be_on else 0
send_cc(out_cc, out_val, channel=out_ch)
toggle.cc_input_lbl.setText(toggle._cc_input_label(value))
toggle.cc_output_lbl.setText(toggle._cc_output_label(out_val))
toggle._update_btn_style()
return
@@ -2312,6 +2485,20 @@ def log_ui_window_calc():
container_midi_out_log.setFixedHeight(half)
container_midi_out_log.move(geo.right() + 368, geo.top() + half + 4)
def layout_panel_show_hide(checked):
container_layout_panel.setVisible(checked)
if checked:
layout_panel_window_calc()
def layout_panel_window_calc():
geo = app_window.frameGeometry()
container_layout_panel.setFixedWidth(220)
container_layout_panel.setFixedHeight(geo.height())
container_layout_panel.move(geo.left() - 224, geo.top())
def start_osc_server(port=9000):
global _osc_server, _osc_thread
stop_osc_server()
@@ -2506,4 +2693,5 @@ app.setPalette(dark_palette)
window = MainWindow()
set_window(window)
window.show()
layout_panel_window_calc()
sys.exit(app.exec())
+62 -16
View File
@@ -143,14 +143,29 @@
}
],
"browser": {
"back_cc": 22,
"fwd_cc": 23
"back": {
"label": "Back",
"cc": 22,
"ch": 1,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "momentary"
},
"preset_uuid": "f230a4fd-edc3-4c4b-8585-f73c82168fee",
"fwd": {
"label": "Fwd",
"cc": 23,
"ch": 1,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "momentary"
}
},
"preset_uuid": "8cbceb6b-ba5d-4b2c-8989-f921e4e3150d",
"show_hide": {
"fader": false,
"toggle": false,
"transport": false
"transport": false,
"browser": false
}
},
"preset2": {
@@ -229,7 +244,22 @@
"zone_index": -1
}
],
"toggles": [],
"toggles": [
{
"uid": "e291394c-cc09-4d3c-9ac3-ed5fe2803a46",
"label": "Toggle 1",
"cc": 20,
"ch": 1,
"state": false,
"color": "Gray",
"zone": null,
"center_index": null,
"zone_index": null,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
}
],
"browser": {
"back": {
"label": "Back",
@@ -248,11 +278,11 @@
"trigger_mode": "momentary"
}
},
"preset_uuid": "e658b6bf-4bb7-4d68-bd8b-07775ee80c53",
"preset_uuid": "7483fb5b-f47f-49e4-bccb-e8d20195078c",
"show_hide": {
"fader": false,
"toggle": false,
"transport": true,
"transport": false,
"browser": true
}
},
@@ -268,14 +298,14 @@
{
"uid": "14ec66c2-6a0a-418e-9240-776cb13f6e2e",
"label": "Fader 1",
"cc": 1,
"ch": 1,
"value": 64,
"cc": 90,
"ch": 15,
"value": 85,
"color": "Gray",
"pickup": false,
"last_sent": 64,
"hw_cc": null,
"hw_channel": null,
"last_sent": 85,
"hw_cc": 67,
"hw_channel": 1,
"zone": null,
"center_index": null,
"zone_index": null
@@ -371,7 +401,22 @@
"zone_index": null
}
],
"toggles": [],
"toggles": [
{
"uid": "46f17f40-1bb2-4527-b6c5-fd26e535beed",
"label": "Toggle 1",
"cc": 20,
"ch": 1,
"state": false,
"color": "Gray",
"zone": null,
"center_index": null,
"zone_index": null,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
}
],
"browser": {
"back": {
"label": "Back",
@@ -390,6 +435,7 @@
"trigger_mode": "momentary"
}
},
"big_title": "STRING SHORTS",
"preset_uuid": "3e9d51d2-cf55-49f4-80a4-2f7f4466f959",
"show_hide": {
"fader": false,
@@ -481,10 +527,10 @@
"trigger_mode": "momentary"
}
],
"transport_show_hide": false,
"transport_show_hide": true,
"io_config": {
"midi_out": "IAC Driver Bus 5",
"midi_in": "VMPK Output",
"midi_in": "Not Connected",
"transport_out": "Not Connected",
"osc_ip": "127.0.0.1",
"osc_listen_port": 9000,
+45 -3
View File
@@ -4,7 +4,7 @@ from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy
)
from PyQt6.QtCore import Qt, QEvent
from PyQt6.QtCore import Qt, QEvent, QTimer
from palette import PALETTE_HEX, PALETTE_VIVID
from styles import TITLE_STYLE
@@ -19,6 +19,7 @@ class ToggleWidget(QWidget):
self.setFixedWidth(115)
self.current_color = PALETTE_HEX.get("Gray", "#263238")
self.toggle_state = False
self.trigger_mode = "toggle"
self.uid = str(uuid.uuid4())
self.zone = None
self.center_index = None
@@ -108,6 +109,26 @@ class ToggleWidget(QWidget):
_hbox_ch.addWidget(self.ch_spin)
_grp2_layout.addWidget(self.ch_spin_row)
# Momentary / Toggle mode selector
trigger_row = QHBoxLayout()
trigger_row.setContentsMargins(0, 0, 0, 0)
trigger_row.setSpacing(2)
self.momentary_btn = QPushButton("Mom.")
self.momentary_btn.setFixedHeight(18)
self.momentary_btn.setCheckable(True)
self.momentary_btn.setChecked(False)
self.momentary_btn.setStyleSheet("border: none;")
self.toggle_mode_btn = QPushButton("Tog.")
self.toggle_mode_btn.setFixedHeight(18)
self.toggle_mode_btn.setCheckable(True)
self.toggle_mode_btn.setChecked(True)
self.toggle_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;")
self.momentary_btn.clicked.connect(lambda: self.set_trigger_mode("momentary"))
self.toggle_mode_btn.clicked.connect(lambda: self.set_trigger_mode("toggle"))
trigger_row.addWidget(self.momentary_btn)
trigger_row.addWidget(self.toggle_mode_btn)
_grp2_layout.addLayout(trigger_row)
self.cc_output_lbl = QLabel(f"CC{default_cc} [--]")
self.cc_output_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;")
@@ -122,7 +143,6 @@ class ToggleWidget(QWidget):
self.btn = QPushButton("", btn_container)
self.btn.setFixedSize(60, 60)
self.btn.setStyleSheet("")
self.btn.clicked.connect(self.on_click)
self.led_dot = QLabel(btn_container)
@@ -289,10 +309,30 @@ class ToggleWidget(QWidget):
self.apply_color(hex_color)
self._update_btn_style()
def set_trigger_mode(self, mode):
self.trigger_mode = mode
if mode == "momentary":
self.momentary_btn.setChecked(True)
self.toggle_mode_btn.setChecked(False)
self.momentary_btn.setStyleSheet("font-weight: bold; color: #00e676;")
self.toggle_mode_btn.setStyleSheet("")
else:
self.toggle_mode_btn.setChecked(True)
self.momentary_btn.setChecked(False)
self.toggle_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;")
self.momentary_btn.setStyleSheet("")
def on_click(self):
self.toggle_state = not self.toggle_state
cc_num = self.cc_spin.value()
out_ch = self.ch_spin.value()
if self.trigger_mode == "momentary":
print(f"[toggle momentary] CC{cc_num} CH{out_ch} (127)")
send_cc(cc_num, 127, channel=out_ch)
self.cc_output_lbl.setText(self._cc_output_label(127))
self.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;")
QTimer.singleShot(150, lambda: self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;"))
else:
self.toggle_state = not self.toggle_state
value = 127 if self.toggle_state else 0
print(f"[toggle] CC{cc_num} CH{out_ch} ({value})")
send_cc(cc_num, value, channel=out_ch)
@@ -318,6 +358,7 @@ class ToggleWidget(QWidget):
"zone_index": self.zone_index,
"hw_cc": self.hw_cc,
"hw_channel": self.hw_channel,
"trigger_mode": self.trigger_mode,
}
def set_state(self, state):
@@ -341,6 +382,7 @@ class ToggleWidget(QWidget):
self.zone_btn.set_state(self.zone == "left", self.zone == "right")
self.hw_cc = state.get("hw_cc", None)
self.hw_channel = state.get("hw_channel", None)
self.set_trigger_mode(state.get("trigger_mode", "toggle"))
if self.hw_cc is not None:
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
self.cc_input_lbl.setText(self._cc_input_label())