Add --midi-setup MIDI-learn wizard + controller profiles + osd.position (Phase 5) - #136
Merged
Conversation
…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 Report❌ Patch coverage is 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The final phase of the MIDI live-tune arc. Everything the live surface could do previously required hand-authoring
[midi_control].cc_mapTOML — 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 plainc64cast --config …run picks the mappings up with zero cc_map edits.--midi-setupwizard (c64cast/midi_setup.py)Needs the
midi+wizardextras; pure helpers + a thin questionary shell likewizard.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 → offertransport.jog) → introspect-driven target picker → review → save a profile. The learn loop reads a controller through the newmidi_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 fromLoopPresetStore's tolerant-load / atomic-write shape) stores one JSON per controller underpaths.controllers_dir()(the canonical data dir,$C64CAST_DATA_DIR-overridable).--doctorreports 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 inmidi_control.resolve_effective_cc_mapand fed to the existing later-(kind,number)-wins_parse_cc_map:cc_map_is_default):defaults + profile→ a profile can reclaim default note/CC numbers.[]):profile + user_list→ the user's entries win, defaults are not re-injected ([]still disables everything).cc_map_is_defaultis tracked in_apply_toml_sections+load_master; it'scompare=False+metadata.internalso it stays out of--describe/schema/serialized TOML (introspect._field_docsskips internal fields) and out ofConfigequality. Resolution happens inMidiControlListener.start()after_open_port, since the"auto"match needs the resolved port name.osd.positionaction +live_targets()osd.positioncc_map action: a tap flips the OSD corner, a double-tap (<400 ms) hides it, a tap while hidden re-enables it — routed throughPlaylist.cycle_osd.introspect.live_targets()— the single source of truth grouping everyLIVE_PARAMS/LIVE_CHOICESacross the effect/generator/mode/scope registries (drift-tested against those class attrs, like theLIVE_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.positiondouble-tap dispatch; config round-trip + validation with the new field.Docs
CLAUDE.md tree +
[midi_control]note;docs/architecture.md(newmidi_setup.pysection +midi_control/transportupdates);docs/usage.mdconsolidated "Live performance (MIDI control)" section;config/c64cast.example.toml; regeneratedc64cast.schema.json.Verification
ruff,mypy --strict,pyright(0 errors), 2633 tests, schema-drift.--midi-setupdispatches 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).controller_profile = "auto"run auto-loaded the profile (log: "contributed 4 net mapping(s)"), a profile-mapped knob sweptmode.dither_strengthlive on screen (OSD "DITHER_STRENGTH 2.00" + visible dither), a profile-mapped pad firedosd.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);--doctorreported the controllers dir. Silence + reset at session end.Closes the 5-phase MIDI live-tune arc.