Files
Paul Lipscomb f7ac34141a Check in Claude session memory as CLAUDE_MEMORY.md, imported from CLAUDE.md
Auto-memory is normally stored outside the repo, keyed to the working
directory's absolute path, so it doesn't follow a clone to a new machine.
This snapshot travels with the repo instead, so Claude has the same
project/feedback context when opened from a different machine (e.g. the
Windows VM being set up for the extension-reaper-macos port).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 18:55:42 -04:00

158 lines
6.8 KiB
Markdown

# Virtual Controller — Project Context
@CLAUDE_MEMORY.md
## 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