Files
virtual-controller/CLAUDE.md
T
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

156 lines
6.8 KiB
Markdown

# 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