Skip to content

Add --midi-setup MIDI-learn wizard + controller profiles + osd.position (Phase 5) - #136

Merged
kfox merged 1 commit into
mainfrom
feat/midi-live-tune-phase5
Jul 19, 2026
Merged

Add --midi-setup MIDI-learn wizard + controller profiles + osd.position (Phase 5)#136
kfox merged 1 commit into
mainfrom
feat/midi-live-tune-phase5

Conversation

@kfox

@kfox kfox commented Jul 19, 2026

Copy link
Copy Markdown
Owner

The final phase of the MIDI live-tune arc. Everything the live surface could do previously required hand-authoring [midi_control].cc_map TOML — knowing a controller's CC/note numbers and c64cast's internal target vocabulary (effect.decay, mode.dither_strength, transport.jog, …). This adds a learn wizard that watches the controller and writes a reusable controller profile, so a plain c64cast --config … run picks the mappings up with zero cc_map edits.

--midi-setup wizard (c64cast/midi_setup.py)

Needs the midi + wizard extras; pure helpers + a thin questionary shell like wizard.py, runs instead of playback like --init. Flow: port pick → transport/OSD-button learn (a button that emits MMC is auto-recognized) → knob auto-scan (relative-encoder detection by value pattern → offer transport.jog) → introspect-driven target picker → review → save a profile. The learn loop reads a controller through the new midi_control.classify_message (factored out of _dispatch), so a learned mapping can't disagree with how it's later dispatched.

Controller profiles + merge precedence

transport.ControllerProfileStore (cloned from LoopPresetStore's tolerant-load / atomic-write shape) stores one JSON per controller under paths.controllers_dir() (the canonical data dir, $C64CAST_DATA_DIR-overridable). --doctor reports the dir.

New [midi_control].controller_profile ("auto" default = match the opened port by name; "<name>" = load that profile; "off" = ignore). Merge precedence is shipped-defaults < profile < explicit cc_map, resolved in midi_control.resolve_effective_cc_map and fed to the existing later-(kind,number)-wins _parse_cc_map:

  • no authored cc_map (cc_map_is_default): defaults + profile → a profile can reclaim default note/CC numbers.
  • an authored cc_map (incl. []): profile + user_list → the user's entries win, defaults are not re-injected ([] still disables everything).

cc_map_is_default is tracked in _apply_toml_sections + load_master; it's compare=False + metadata.internal so it stays out of --describe/schema/serialized TOML (introspect._field_docs skips internal fields) and out of Config equality. Resolution happens in MidiControlListener.start() after _open_port, since the "auto" match needs the resolved port name.

osd.position action + live_targets()

  • New press-only osd.position cc_map action: a tap flips the OSD corner, a double-tap (<400 ms) hides it, a tap while hidden re-enables it — routed through Playlist.cycle_osd.
  • New introspect.live_targets() — the single source of truth grouping every LIVE_PARAMS/LIVE_CHOICES across the effect/generator/mode/scope registries (drift-tested against those class attrs, like the LIVE_CHOICES[color] pin); the wizard's target picker consumes it.

Tests

New tests/test_midi_setup.py (32 cases): profile store round-trip / tolerant-load / slug stability; the merge-precedence matrix (defaults-only, is_default+profile reclaim, explicit-wins, []-disables, off, named-profile, no-port-yet); wizard pure helpers incl. encoder detection + MMC recognition + dedupe; live_targets() drift guard; osd.position double-tap dispatch; config round-trip + validation with the new field.

Docs

CLAUDE.md tree + [midi_control] note; docs/architecture.md (new midi_setup.py section + midi_control/transport updates); docs/usage.md consolidated "Live performance (MIDI control)" section; config/c64cast.example.toml; regenerated c64cast.schema.json.

Verification

  • Full gates green: ruff, mypy --strict, pyright (0 errors), 2633 tests, schema-drift.
  • No-hardware: --midi-setup dispatches and walks the learn flow; the learn I/O path (_read_burst + classify_message) validated against a live virtual MIDI port (absolute knob vs. relative encoder distinguished, pad learned).
  • Real hardware (a U64 + HDMI capture + MIDI controller, driven via a virtual port): a controller_profile = "auto" run auto-loaded the profile (log: "contributed 4 net mapping(s)"), a profile-mapped knob swept mode.dither_strength live on screen (OSD "DITHER_STRENGTH 2.00" + visible dither), a profile-mapped pad fired osd.position (OSD flipped to top, "OSD TOP"), a profile-mapped pad jumped scenes; an explicit cc_map entry on the same CC overrode the profile (OSD "MOTION_SMOOTHING", log "contributed 0 net mapping(s)", defaults not re-injected); --doctor reported the controllers dir. Silence + reset at session end.

Closes the 5-phase MIDI live-tune arc.

…on (Phase 5)

The final phase of the MIDI live-tune arc. Everything the live surface could
do previously required hand-authoring [midi_control].cc_map TOML — knowing a
controller's CC/note numbers AND c64cast's internal target vocabulary. This
adds a learn wizard that watches the controller and writes a reusable profile,
so a plain run picks the mappings up with zero cc_map edits.

New c64cast/midi_setup.py (midi + wizard extras; pure helpers + a thin
questionary shell like wizard.py, runs instead of playback like --init):
port pick -> transport/OSD-button learn (a button that emits MMC is
auto-recognized) -> knob auto-scan (relative-encoder detection by value
pattern -> offer transport.jog) -> introspect-driven target picker -> review
-> save a controller profile. The learn loop reads a controller through the
new midi_control.classify_message (factored out of _dispatch) so a learned
mapping can't disagree with how it's later dispatched.

Controller profiles (transport.ControllerProfileStore, cloned from
LoopPresetStore's tolerant-load/atomic-write shape) live in the canonical data
dir, paths.controllers_dir() (~/.local/share/c64cast/controllers/
<port-slug>.json, $C64CAST_DATA_DIR-overridable). --doctor reports the dir.

New [midi_control].controller_profile ("auto" default = match the opened port
by name; "<name>" = load that profile; "off" = ignore). Merge precedence is
shipped-defaults < profile < explicit cc_map, resolved in
midi_control.resolve_effective_cc_map and fed to the existing
later-(kind,number)-wins _parse_cc_map: effective = defaults+profile when the
user authored no cc_map (a non-persisted MidiControlCfg.cc_map_is_default,
set False whenever any TOML layer specifies cc_map — tracked in
_apply_toml_sections + load_master), so a profile can reclaim the default
note/CC numbers; effective = profile+user_list when a cc_map is authored, so
the user's entries win and the defaults aren't re-injected (cc_map = []
still disables everything). Resolution happens in MidiControlListener.start()
after _open_port, since the "auto" match needs the resolved port name.
cc_map_is_default is compare=False + metadata.internal, so it stays out of
--describe/schema/serialized TOML (introspect._field_docs skips internal
fields) and out of Config equality.

New osd.position cc_map action (press-only): a tap flips the OSD corner, a
double-tap (<400ms, tracked per playlist on the listener) hides it, a tap
while hidden re-enables it — routed through Playlist.cycle_osd.

New introspect.live_targets() is the single-source-of-truth grouping of every
LIVE_PARAMS/LIVE_CHOICES across the effect/generator/mode/scope registries
(drift-tested against those class attrs, like the LIVE_CHOICES<->[color] pin),
and the wizard's target picker consumes it.

Tests: tests/test_midi_setup.py (profile store round-trip/tolerant/slug;
merge-precedence matrix; wizard pure helpers incl. encoder detection; MMC
recognition; live_targets drift; osd.position double-tap; config round-trip
with the new field). Docs: CLAUDE.md tree + [midi_control] note,
docs/architecture.md (midi_setup.py section + midi_control/transport updates),
docs/usage.md consolidated "Live performance (MIDI control)" section,
c64cast.example.toml, regenerated c64cast.schema.json.
@codecov

codecov Bot commented Jul 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 53.97590% with 191 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.02%. Comparing base (533a8e3) to head (2e68023).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
c64cast/midi_setup.py 20.90% 174 Missing ⚠️
c64cast/midi_control.py 84.93% 7 Missing and 4 partials ⚠️
c64cast/cli.py 25.00% 2 Missing and 1 partial ⚠️
c64cast/config.py 80.00% 1 Missing and 1 partial ⚠️
c64cast/introspect.py 98.27% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #136      +/-   ##
==========================================
- Coverage   80.56%   80.02%   -0.55%     
==========================================
  Files          87       88       +1     
  Lines       19843    20242     +399     
  Branches     2969     3034      +65     
==========================================
+ Hits        15987    16199     +212     
- Misses       3163     3344     +181     
- Partials      693      699       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@kfox
kfox merged commit 41cac57 into main Jul 19, 2026
10 checks passed
@kfox
kfox deleted the feat/midi-live-tune-phase5 branch July 19, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant