SSPACEAGEDOCUMENTATION/
Music and Workflow

SpaceAge MIDI Architecture

Updated Aug 27, 2026   |   80.5 KB   |   docs/MIDI_Architecture.md

Physical Input Runtime Contract

  • available, open, and active are different facts. Available means the OS reports the device, open means SpaceAge owns a started juce::MidiInput, and active means accepted musical traffic has actually been observed.
  • Direct-device refresh is differential. Do not stop and reopen unchanged controllers during settings refresh, project restore, or inventory polling.
  • AUTO uses host/plugin MIDI as its fallback and opens direct hardware only when lane routing explicitly names that input. DIRECT ONLY and HOST+DIRECT may open the full visible direct-input set. This avoids duplicate or exclusive-open conflicts with the standalone wrapper path.
  • Every direct callback event carries source slot, slot generation, source device ID, MIDI message, and callback/driver time. The drain rejects an event if the slot generation no longer matches.
  • The direct queue has multiple physical producers. Producer access must be serialized unless the queue is replaced by a true MPSC structure; juce::AbstractFifo by itself is SPSC.
  • Host and direct notes, controllers, pressure, pitch bend, pedals, and sync all pass through the same source-policy decision. Do not observe or apply sync before that decision.
  • Recording compensation may backdate direct events from callback age. Live audio cannot occur before a callback arrives, so monitoring is scheduled in the earliest drain block rather than hidden behind an invented fixed offset.
  • Remaining hardening: keep direct-device timing proof honest, continue moving heavyweight engine locks/allocations out of live onset paths, and validate real USB/DIN controllers with saved receipts. Same-channel/same-note ownership is source-session scoped and covered by regression tests.

SpaceAge MIDI Architecture

2026-07-14 Ownership Baseline

MIDI performance automation has three first-class owners: Shared PTN, Clip Local, and Lane Local. AUTO LANES exposes the owner selector; project state persists every owner; playback and Arrangement export build effective expression by layering Shared PTN -> Lane Local -> Clip Local. Shared PTN edits retain the linked-clip acknowledgement guard. Clip Local and Lane Local edits are explicit overlays and do not require that guard.

This document tracks the MIDI subsystem as it becomes a first-class part of SpaceAge.

Current Baseline

SpaceAge already supports MIDI input, pitch bend, mod wheel, sustain pedal, channel volume/expression/pan, MIDI recording into Piano Roll notes, MIDI step input, selected-pattern MIDI import/export, whole-arrangement MIDI export, lane MIDI channels, lane MIDI input-channel filtering, and a strong panic path.

The current implementation is useful but scattered:

  • Incoming MIDI is handled directly inside CinematicDrumsAudioProcessor::processBlock.
  • Piano Roll notes are stored as step-based PianoNote records that now preserve the source MIDI channel.
  • Drum events are stored as step-grid Step records.
  • Chord Engine events are stored as semantic ChordClip records.
  • MIDI import/export uses 960 PPQ, but editing still mostly speaks in 16th-step units.
  • Recording captures note on/off and now records expression events such as CC and pitch bend into pattern payloads. Recording timing respects the selected pattern's stored length instead of forcing the old 64-step sequencer ceiling. Live/record monitoring preserves the source MIDI channel so pitch bend, mod wheel, sustain pedal, channel volume/expression, and pan apply to the correct held voice. Arrangement playback starts internal voices on the lane MIDI channel so lane-routed expression data and audible voices stay aligned. Each Arrangement lane can now be armed for MIDI input and filtered to Omni or a specific incoming channel, while playback/export remains controlled by the lane MIDI channel. SysEx remains deliberately excluded from the automatic performance path; the first-pass Vault/librarian flow captures, stores, and restores only behind explicit user actions. AUTO LANES editing exists for Shared PTN, Clip Local, and Lane Local performance data; effective playback and Arrangement export resolve all three through the same ownership model.

Canonical Timing

The MIDI subsystem now uses a central timing vocabulary in Source/SpaceAgeMidi.h.

  • ticksPerQuarter = 960
  • ticksPerStep = 240
  • One step currently means one 16th note.
  • One 4/4 bar is 3840 ticks.

This matches the existing MIDI export behavior and gives future MIDI clips, automation clips, hardware sync, and controller data one shared timing unit.

First-Pass Model

spaceage::midi currently defines:

  • TimingContext
  • MessageKind
  • MidiEvent
  • MidiEventList
  • MidiClipModel
  • MidiTrackRoute
  • HardwareMidiProfile
  • SysExSnapshot
  • MidiControlMapping
  • panic-message helpers
  • message classification and description helpers
  • JSON round-trip helpers that restore raw MIDI messages from saved event data

This model now participates in save/load, copy/variant payload handling, live playback, and MIDI export for pattern-level expression events. It is still intentionally conservative: notes/chords/drums keep their working storage while expression events move onto the shared MIDI spine.

Hardware Passports and SysEx snapshots now have a saved project-state model, but they deliberately do not transmit automatically. A passport can remember a friendly hardware identity, input/output device ids, MIDI channel, bank/program defaults, clock preference, and linked SysEx snapshot ids. A SysEx snapshot can remember named raw bytes, manufacturer/model notes, send delay, archive intent, and the requirement that the user explicitly confirm any future send. Arrangement lane routes can now remember the selected passport id, giving a lane stable external-device intent without reintroducing a top inspector. Current guarded Hardware Passport / SysEx Vault UI can preview and queue Bank MSB, Bank LSB, Program Change, and attached SysEx recall after explicit confirmation. This keeps the librarian safe without smuggling SysEx or device patch changes into normal playback.

Hardware Passports can also carry device-specific NRPN definitions. Each definition names the NRPN parameter MSB/LSB pair, describes the value meaning, and records whether relative Data Increment/Decrement style data is allowed. These definitions are now the review boundary for guarded setup-card queueing: standard RPN cards can queue after explicit confirmation, while NRPN cards require a queue-safe Hardware Passport definition and still need real-device receipts before a launch claim.

Hardware Passports now also carry destination pitch-bend range as MIDI-native semitones plus cents. This is separate from the processor's volatile internal/RPN-observed bend range: the runtime value explains how SpaceAge is currently scaling internal voices, while the Hardware Passport value explains what a selected external device or patch is expected to do. Attaching a reviewed RPN 0,0 Pitch Bend Range setup card writes the structured range into the passport so future UI, export, and hardware checks do not have to parse note receipts.

Readiness/export reporting should treat that Hardware Passport value as the destination truth. If a hardware-routed lane or selected clip contains pitch-bend expression but has no attached passport, or its attached passport has no bend policy, the model should warn before the user trusts slides, glides, or pitch-wheel curves on external gear. This warning belongs in the model summaries rather than ad hoc editor copy so copied reports, support bundles, and UI panels agree.

The processor now exposes small upsert/remove helpers for Hardware Passports and SysEx snapshots. These support the Settings MIDI Hardware and SysEx Vault surfaces: add or edit one record at a time, remove stale links, and keep lane passport assignments from dangling after a passport is deleted.

The processor can also create a starter Hardware Passport from an Arrangement lane. It copies the lane's MIDI channel, input/output device ids, and clock intent, gives the passport a safe generated id, and optionally attaches it back to the lane. It does not invent bank/program values.

The processor can build a hardware recall plan for a Hardware Passport. The plan is a read-only list of MIDI messages: optional Bank MSB, Bank LSB, Program Change, and optionally linked SysEx snapshots with labels, delay, and confirmation metadata. Building this plan does not send anything.

The processor also exposes buildHardwareRecallPlanSummary() as the UI-facing companion to the raw recall plan. It resolves the passport name, output device id, message labels, MIDI descriptions, byte counts, send delays, SysEx flags, confirmation flags, and validation warnings into one display packet. Hardware Passport / SysEx Vault pages should render this summary instead of parsing raw juce::MidiMessage objects in editor code.

The processor can also validate a Hardware Passport and return plain-language warnings for missing display names, missing output devices, invalid channels/ranges, clock mismatches, missing linked snapshots, mismatched passport ids, and invalid SysEx bytes. This gives hardware UI one shared source for passport readiness instead of scattered one-off checks.

The processor now has a confirmed hardware recall queue path. queueConfirmedHardwareRecall() refuses empty/unconfirmed requests, runs shared passport validation first, builds the recall plan, then queues Bank/Program and optional SysEx messages to the passport's output device through a recall-specific queue. This queue is separate from the live Arrangement lane hardware queue, so SysEx stays out of ordinary playback while explicit Hardware panel actions can still restore external hardware safely. The processor also keeps a bounded hardware recall log for queue attempts, including passport id, source id, output device, label, MIDI description, byte count, delay, queued/failure status, and SysEx status.

The processor exposes buildHardwareRecallQueueProgress() for UI surfaces that need live confidence while hardware data is pending. This reports SpaceAge-side queue counts for ordinary live hardware lane traffic and confirmed recall/librarian traffic, including pending SysEx messages, pending raw bytes, planned recall delay, dropped-message warnings, phase labels, trust labels, and next-action text. It is deliberately not a hardware acknowledgement layer. First-pass restore verification receipts are the separate trust layer: they can record no verification, manual verification, device ACK, device NAK, or timeout without pretending queue progress is hardware acceptance.

SysExRestoreResponseClassification is the first model-owned bridge between incoming hardware replies and those restore verification receipts. It recognizes standard MIDI Sample Dump-style ACK, NAK, CANCEL, and WAIT replies, maps final ACK/NAK/CANCEL responses into receipt kinds, and explicitly labels unrecognized valid SysEx as unrelated. UI should render this classifier instead of interpreting raw response bytes in editor code. Device-specific response dialects belong in future Hardware Passport parser definitions.

Live Arrangement lane MIDI sent to external hardware now preserves each event's in-block sample offset through JUCE's scheduled MidiOutput::sendBlockOfMessages() path. This is intentionally limited to live lane playback messages. Confirmed hardware recall and SysEx librarian-style messages keep their separate queue and explicit delay model, because setup dumps and patch recall need safety, pacing, and progress semantics more than audio-block microtiming.

SysEx snapshot summaries include human hardware identity, archive inclusion, confirmation wording, byte count, delay, validity, warnings, and a short hex preview. The SysEx Vault renders these summaries for browsing and confidence checks, while raw full SysEx bytes remain in the saved snapshot record. The first-pass Vault can rename, annotate, set exact delay, attach, and detach snapshots without sending hardware data.

SysEx capture now has its own model layer and first live UI bridge. SysExCaptureReadinessPlan explains the preconditions for live capture: choose an input, explicitly arm capture, receive F0...F7 bytes, show progress, name the dump, and attach it to a Hardware Passport. SysExCaptureReceipt validates and classifies captured bytes, estimates DIN/send timing, and repeats that the dump was captured only, not sent. The SysEx Vault exposes this through ARM CAPTURE, CANCEL, SAVE CAPTURE, and COPY CAPTURE; it auto-disarms after one dump and stores reviewed captures as confirmation-required snapshots. This keeps live capture aligned with the Vault and Hardware Passport safety model instead of becoming an implicit restore/send shortcut.

The processor now owns a conservative live SysEx capture state. Capture is explicitly armed, the next incoming SysEx message is copied into a bounded runtime queue, a worker builds the review receipt, capture immediately disarms, and ordinary channel-note recording never sees that SysEx data. The reviewed bytes can then be committed to the SysEx Vault with confirmation required by default. UI should treat this as a device-dump inbox, not a transmission path.

SysExCaptureProgress is the runtime-facing status object for that inbox. It reports idle, armed/listening, captured/ready, or captured/needs-review phases along with byte count, transfer class, DIN timing, safety copy, trust copy, warnings, and next action. Future progress meters, Settings MIDI panels, and support reports should render this object rather than duplicating state checks in UI code.

The SysEx Vault now renders that progress object directly. Its first live-capture bridge exposes ARM CAPTURE, CANCEL, SAVE CAPTURE, and COPY CAPTURE; an armed capture listens for one incoming SysEx message, then auto-disarms into a review receipt. Saving the capture writes a normal Vault snapshot with confirmation required and archive inclusion enabled. The bridge is intentionally an inbox, not a send path: no captured data is restored to hardware from the capture action itself.

Realtime MIDI control mapping uses a small runtime queue so incoming learnable control messages are not directly applied to host parameters inside processBlock. Armed SysEx capture uses the same philosophy with a bounded fixed-size byte queue so large dump receipts are built off the live MIDI handler. Expression recording and completed-note recording use their own commit queues. These queues keep dense knob gestures, hardware dumps, and live takes responsive while reserving host notification, allocation, receipt construction, and durable pattern mutation for non-audio worker contexts. Remaining realtime cleanup should focus on note-trigger resource acquisition and any future controller-to-automation bridge.

MIDI expression recording now follows that queue pattern. Safe timeline-expression messages captured during recording enqueue pattern, tick, and MIDI message records; a worker thread appends and sorts midiExpressionEvents under the existing patternMutex. This keeps controller, pitch bend, pressure, and sustain performance recording out of direct vector mutation on the live MIDI path.

MIDI note recording also uses a commit queue for completed notes. The live path still owns note-on monitoring, note-off matching, and immediate voice start/release, but the durable PianoNote append, pattern-length update, and last-recorded-note index update happen on a worker thread under patternMutex. MIDI Health reports dropped note, expression, mapping, and SysEx queue pressure so dense live traffic can be diagnosed without guessing.

MIDI inspection report rows for tempo, meter, and key metadata are sorted by musical tick before they are exposed to import review, copy reports, and action receipts. This is a report-level correctness step only; SpaceAge still needs a real conductor-map timeline model before it can adopt, edit, or export full tempo/meter/key maps.

Timeline MIDI payload storage uses spaceage::midi::isTimelineMidiPayloadMessage() as a broad preservation gate for recording, imports, programmatic setters, and review. The older isTimelineExpressionMessage() remains as a compatibility alias, but the stored payload is broader than editable performance expression: editable CC, pitch bend, poly aftertouch, channel pressure, mod wheel, sustain, expression, pan, and volume live beside preserved setup/patch rows such as Bank MSB, Bank LSB, Program Change, RPN/NRPN selectors, and Data Entry. Playback and MIDI export must use isEditableTimelineExpressionMessage() so review-only setup data does not automatically alter hardware or exported files. UI must continue to split these into editable expression lanes versus setup/review cards. SysEx and meta events remain excluded from this automatic path.

Pattern expression payloads now have processor-level editing helpers for point creation, exact point deletion, point value updates, point moves, grouped point moves, lane replacement, lane deletion, lane-local paste, and whole-lane removal. These helpers preserve the shared timeline-expression safety boundary, sort events after edits, keep pattern length inference coherent, and let current MIDI AUTO plus future Piano Roll / automation UI change one CC, pitch, pressure, program, or bank lane without touching raw payload vectors directly.

Expression ownership is explicit in the current model. Shared PTN data is reused by linked clips and retains the acknowledge/Variant guard. Clip Local data follows one stable clip identity; Lane Local data follows the Arrangement lane. Effective playback/export layers Shared PTN, Lane Local, then Clip Local.

MIDI AUTO now uses those helpers for a compact selected-pattern MIDI automation/expression pass: it can insert playhead points, draw selectable 1/2/4/8/16-step ramps, draw/erase in the selected-lane graph, hover/select real points, delete exact points, value-drag a point, nudge point time/value, group points with Shift-click/drag, move groups, and cut/copy/paste selected points within the same expression lane. It deliberately does not author RPN, NRPN, Data Entry, Bank Select, Program Change, or channel-mode setup messages. Those messages can change external hardware state in non-obvious ways and belong in MIDI PATCH, hardware profiles, SysEx Vault, or a future grouped hardware-setup editor.

The shared MIDI layer now has summariseMidiPatchEvents() for Bank Select / Program Change readability. It scans timeline setup events and produces musician-facing rows such as Ch 04 | Bank MSB 9 / LSB 22 | Program 65 (MIDI 64), while warning when bank data appears without a following Program Change. This helper is for import wizards, expression editors, hardware profile creation, and SoundFont mapping dialogs; it does not mutate internal presets or send hardware data.

Pure expression-lane transforms now support quantizing event ticks, scaling values, offsetting values, and thinning redundant events without mutating project state. Drawable editors should preview transform counts before committing through processor-owned edit paths.

The shared MIDI model now recognizes transport, sync, and system-common status messages: MIDI Clock, Start, Continue, Stop, Song Position Pointer, Song Select, MTC quarter/full frame, and MIDI Machine Control. These are named and can be preserved through raw MIDI storage, but they are not timeline expression events. Clock/transport needs a dedicated sync workflow instead of riding through normal clip expression playback, and Song Select is report-only until a future explicit hardware workflow owns it.

Hardware profiles now store explicit sync capability metadata: whether the device responds to MIDI Clock, Song Position Pointer, MTC, MMC, whether SpaceAge should send transport on playback, and whether incoming transport chase is expected. These flags are descriptive/profile-level intent, not automatic send commands. Validation warns when clock/transport behavior is enabled without matching capability or without the required input/output device.

Hardware Passport sync-role edits use MidiHardwareSyncPolicyPreview before saving roles that arm future clock/transport sends, incoming transport chase, or warnings. The preview explicitly separates "saving this profile sends no MIDI now" from "future playback may send clock/transport" and "incoming sync may chase the timeline," so editor code does not need to invent its own safety language.

The shared MIDI layer can now build a dry-run MidiTransportPlan for a hardware profile. It can describe the planned Start/Continue/Stop, optional Song Position Pointer, and clock-stream start marker, plus any warnings caused by missing output devices or mismatched capabilities. Clock stream preview rows are marked as continuous-clock intent instead of plain one-shot messages. This is deliberately a preview object, not a sender.

The processor exposes buildHardwareTransportPlan() as the UI-facing wrapper. It looks up the saved profile, creates the timing context from SpaceAge's effective tempo/sample rate, runs the shared MIDI helper, and merges hardware-profile validation warnings into the result.

The processor also exposes buildHardwareTransportPlanSummary() for future Sync / Hardware Profile UI. It converts the transport plan into display rows with labels, message descriptions, delay values, continuous-clock flags, warnings, and headline text so editor code does not need to parse raw MIDI messages.

The shared MIDI layer can now inspect MidiEventList, MidiMessageSequence, and MidiFile data before import or conversion. A MidiInspectionReport counts tracks, PPQ, note events, controllers, pitch bend, pressure, program/bank changes, tempo/time/key metadata, SysEx, transport/sync messages, active sense, unknown messages, used MIDI channels, per-channel activity, timeline span, and plain-language warnings. This is a preflight layer: it lets the UI say "this file contains device setup data" or "this file contains transport/sync messages" before SpaceAge decides what to import, preserve, ignore, or ask the user about. The processor exposes inspectMidiFileForImport() and inspectPatternMidiPayload() so future UI can use that policy without duplicating MIDI parsing.

The next layer is a MidiImportPlan. It converts inspection facts into user-facing choices such as "Import as one clip," "Create lanes from channels," "Use channel 10 as drums," "Preserve expression," "Review song metadata," "Review device setup," "Store SysEx safely," and "Ignore transport/sync on import." This lets the future import wizard stay musical and clear while still protecting hardware users from accidental SysEx sends or surprise program changes. The processor exposes planMidiFileImport() and planPatternMidiPayloadImport() so existing files and already-stored clip payloads share the same import policy.

The import plan now also creates MidiChannelImportHint rows and MidiImportLaneCandidate rows. Channel hints turn raw per-channel counts into explainable roles such as likely drums, melodic notes, expressive melodic notes, controller-only data, or device-setup-only data. Lane candidates are one step closer to UI: they suggest a long-form drum lane, Instrument lane, expressive Instrument lane, controller review, or hardware setup review, along with suggested lane names and instrument guidance. This is intentionally heuristic and transparent: channel 10 with notes is labeled as likely drums because of General MIDI convention, while channels with notes plus pitch bend or pressure are labeled as expressive melodic material that may deserve careful lane routing. Long GM drum imports use PianoNote-backed drum payloads inside drum lanes so imported drum material can exceed the legacy 64-step Drum Composer grid without visual/audio truncation; conversion into editable classic Step Banks remains a separate future workflow.

The shared MIDI layer can also build a MidiImportApplyPreview from an import plan. This is a dry-run list of user-facing actions such as create lane, import single clip, preserve expression, review song metadata, review device setup, store SysEx snapshot, or ignore transport/sync. It does not create lanes or mutate project state; it gives the future Import Wizard a final "what will happen if I accept?" checklist.

The shared MIDI layer also builds recommended MidiImportCommitRequest objects for accepted import plans and MidiImportApplyResult receipts after mutation. Those receipts let the UI report imported clips, created lanes, preserved expression, stored SysEx, reviewed setup data, ignored transport/sync data, action lines, and warnings without reverse-engineering the import path.

MidiImportReviewDashboard is the consolidated customer-facing review model for import-like workflows. It groups the import facts into sections such as Overview, Lane Plan, Expression, Patch Setup, SysEx, Song Metadata, Transport / Sync, and Warnings. When built from a MidiEventList, it also uses summariseMidiPatchEvents() so the dashboard can show actual rows like Ch 02 | No bank select | Program 6 (MIDI 5) and warn before any internal preset, SoundFont patch, or hardware profile mapping is changed. This dashboard is still read-only policy data; it does not create lanes, send SysEx, or mutate projects.

The dashboard also carries structured setup-controller groups for RPN, NRPN, Data Entry, and related setup rows. These groups are the correct backend source for future cards such as pitch-bend range, tuning, and hardware parameter setup. Each group exposes parameter type, combined 14-bit parameter number, combined 14-bit Data Entry value when complete, complete/incomplete 14-bit labels, display labels, warnings, stable next-action kind, and human next-action label so UI does not need to parse prose or reinvent readiness rules. Complete selector rows, selector-plus-data rows, orphan Data Entry rows, and incomplete selector-only rows are all reviewable. The dashboard also carries protected channel-mode/reset review rows for Local Control, Omni, Mono/Poly, All Notes Off, All Sound Off, and Reset All Controllers. MIDI PATCH renders grouped setup cards with a guarded queue policy: complete standard RPN cards can queue after explicit confirmation, NRPN cards require a queue-safe Hardware Passport definition, and protected channel-mode/reset rows remain copy-only. Grouped RPN/NRPN editing and hardware parameter-mapping polish remain unfinished because these messages can alter hardware state or abruptly silence devices. Ordinary controller performance data should stay visible as expression; Bank Select, Program Change, Data Entry, RPN/NRPN, and channel-mode messages should stay visible as setup/review data.

The processor exposes buildMidiFileImportReviewDashboard() and buildPatternMidiPayloadImportReviewDashboard() so editor code can request the review model for either a MIDI file or already-stored pattern payload without duplicating parsing, file error handling, or patch/setup warning logic.

The shared MIDI layer now also has a persistent MidiControlMapping model. It can represent learned CC, pitch bend, channel pressure, and poly aftertouch sources; match incoming messages by channel/source; normalize values; scale into target ranges; serialize into project state; and describe the source in human-readable language. The processor owns the mapping list, exposes get/set/upsert/remove helpers, validates mappings, and saves/restores them with project state. This is the backend spine for future MIDI Learn UI, controller assignment browsing, hardware knobs, automation capture, and VST parameter control.

MIDI Learn now recognizes learnable 14-bit CC pair relationships. Coarse/MSB controllers such as CC1 can report their fine LSB companion, while LSB-only mappings such as CC33 are flagged with a warning. Runtime mapping keeps 7-bit devices working normally, but learned MSB mappings automatically merge matching LSB/fine controller traffic when both halves have been observed. MIDI Maps rows receive first-pass live activity values from assignment summaries. Future explicit pair/coarse mode UI, smoothing, and deeper coarse/fine/merged metering should continue extending MidiControlMapping, not create a parallel learn system.

The MIDI Learn backend now has a target registry. Any APVTS-backed parameter is exposed as a MidiControlTargetDescriptor using the canonical target id format param:<parameterId>. For example, master_volume becomes param:master_volume, and pad0_cutoff becomes param:pad0_cutoff. The descriptor records display name, group, target kind, range, default value, and bipolar status. This means new native synth/effect parameters become learnable by default if they are added as normal JUCE parameters.

The processor now supports arming MIDI Learn for a target, capturing the next learnable CC/pitch-bend/channel-pressure/poly-aftertouch message, creating a persisted mapping, and clearing the armed state. It also applies matching learned mappings to APVTS parameters during incoming MIDI handling. The first customer-facing UI exists through Settings MIDI Maps plus direct learn gestures on supported APVTS-backed sliders, dropdowns, and toggles. The backend covers the full loop: discover target, arm target, capture source, save mapping, and apply mapped values.

The processor also exposes whole-table MIDI mapping diagnostics, safe exact-duplicate repair, and source-replacement cleanup. removeDuplicateMidiControlMappings() is intentionally narrow: it removes only source-target duplicates while preserving intentional one-control-to-many-target mappings and unresolved diagnostic rows. removeMidiControlMappingsForSource() is the future Replace Existing primitive: it can keep one chosen mapping and remove other mappings driven by the same hardware gesture, but it should only be called after the user explicitly chooses replacement instead of macro-style control.

The processor also exposes MidiControlAssignmentSummary rows. These are intentionally UI-facing: each row resolves a raw mapping into a source label, compact label, source kind, source channel, controller/note number where relevant, target label, group, enabled state, mapped value range, target-resolution status, and warnings. MIDI assignment panels should render these summaries and the whole-table diagnostics rather than parsing raw mapping ids or re-resolving APVTS targets in the editor.

The shared MIDI layer now also builds a MidiControlMappingDashboard from assignment summaries. This is the whole-map health readout for MIDI Learn: total assignments, enabled/disabled counts, resolved/unresolved targets, duplicate source-target mappings, duplicate targets, source-family counts for CC/pitch/pressure/poly pressure, groups, warnings, and a readyForPerformance flag. The dashboard owns a plain-text report, and the project health summary embeds that report so Settings, support bundles, and a future MIDI Assignments page can show the same trustworthy status without rebuilding prose in the editor.

The processor now exposes a project-level MIDI readiness report. It summarizes lane routing, armed input state, input/output channels, route targets, attached hardware profiles, hardware/profile warnings, control mapping count, SysEx snapshot count, and pattern-level expression event/lane counts. This report is intended for Settings, a future MIDI dashboard, and preflight warnings before hardware-heavy sessions.

Hardware Passport summaries now also carry a pitch-bend test plan. This plan reports whether the profile has an output device and declared bend range, describes the conservative C4 center/up/down/center phrase, publishes safety/checklist copy, and gives a ready/not-ready next action. The actual send lives in the processor-owned confirmed pitch-bend test receipt path, which rechecks the passport, requires confirmation, queues only note/pitch-bend messages, and logs each attempted message. MIDI PATCH exposes this through TEST BEND / BEND INFO; any future Hardware Passport UI should call the same receipt path instead of inventing editor-side bend-test sends.

Readiness lane rows also carry compact routing labels and device ids so lane badges, MIDI dashboard rows, and hardware troubleshooting can show the same concise route sentence instead of inventing separate display strings.

The shared MIDI layer now also owns first-pass hardware setup scenarios through buildMidiHardwareSetupScenarios(). These scenario records describe common user intentions such as controller input, one external synth, multitimbral modules, MIDI guitar, wind controller, and drum-pad input. They include lane guidance, input/output guidance, captured MIDI families, warnings, recommended action text, channel template labels, expression template labels, hardware template labels, future automation hints, and structured flags for timing calibration, hardware profiles, expressive performance, and multichannel routing. Settings and future onboarding should render this model instead of hard-coding separate "how to connect hardware" prose in each panel.

buildMidiHardwareSetupAssistantPlan() is the first state-aware layer above those static scenarios. It consumes the project readiness report, MIDI input readiness, external output readiness/runtime reports, and device inventory, then emits ordered setup steps with complete/needs-action/blocked/optional status plus stable action kinds. Opening the guide and following NEXT ACTION remain navigation-only. The same MIDI SETUP surface now also exposes separate, explicitly confirmed actions: APPLY DRAFT can create/attach a project-side Hardware Passport draft without sending hardware MIDI, UNDO DRAFT rolls that project-only draft back, and TEST NOTE can queue only a short note-on/note-off pair after route confirmation and a copyable receipt.

MIDI input readiness distinguishes warnings from neutral explanations. A missing input device is a warning; a fixed input channel is not. Fixed-channel lanes now carry an inputChannelFilterNote that explains only that channel is accepted and other channels are ignored. Omni lanes carry the matching all-channels note. This keeps support reports honest without making deliberate channel filtering look broken.

Live channel diagnostics build on that same distinction. The processor records recently observed MIDI input channels before armed-lane filtering rejects a message, then MidiProjectReadinessReport and MidiInputReadinessReport expose the observed channel mask as readable text. If SpaceAge hears CH 01 while the armed lane accepts only CH 03, input readiness can warn about the mismatch without changing the fixed-channel route.

The processor now also exposes MidiProjectHealthSummary as a backend aggregate for future MIDI Health / MIDI Dashboard UI. It gathers the readiness report, controller assignment summaries, hardware profile summaries, SysEx snapshot summaries, warning counts, unresolved mapping counts, and expression-lane counts into one read-only packet. This is a display and preflight surface only; it does not send hardware data, recall profiles, or mutate mappings.

MidiProjectHealthSummary also owns MidiTimingCalibrationSummary. This keeps timing trust separate from protocol recognition: the protocol layer can say SpaceAge understands MIDI messages, while the timing layer can say whether the current audio block size, armed input lane, external hardware routing, and saved record-latency compensation are good enough for real playing. UI should render timingCalibration.suggestedActionKind and suggestedActionLabel rather than deriving timing state from scattered sliders.

Pulse-based calibration measurements should use buildMidiTimingCalibrationMeasurement(). It accepts sent/returned pulse timestamps, computes average round-trip timing, jitter, estimated one-way delay, suggested record compensation, and confidence/warning labels. The first-pass UI can send safe profile-based pulses, capture returned measurements, and store accepted timing records on hardware profiles. Applying those values to global/project record compensation remains an explicit future user action.

Accepted hardware timing calibrations can be stored as MidiTimingCalibrationRecord inside HardwareMidiProfile. This associates timing data with the actual device/profile path that produced it, including input/output device ids, jitter, compensation, confidence, and warnings. Loading a profile with a timing record must not automatically change global record-compensation settings; applying a calibration remains an explicit user decision.

The shared MIDI layer also exposes MidiProtocolCoverageReport, which is included in the project health summary. This is a plain-language truth table for protocol families: notes, controllers, RPN/NRPN/channel-mode setup, pitch/pressure, program/bank, SysEx, clock/transport, MIDI Learn, import, export, tempo/meter/key maps, hardware output timing, and MIDI 2.0/MPE. It deliberately separates backend recognition/preservation from customer-facing closeout readiness and flags families that require explicit confirmation or still block an honest MIDI 1.0 launch-closeout claim.

MIDI export now has the same kind of preflight layer. buildPatternMidiExportReadiness(), buildArrangementClipMidiExportReadiness(), and buildArrangementMidiExportReadiness() return MidiExportReadinessSummary packets before any file is written. The summary reports scope, BPM, PPQ, timeline range, lane count, clip count, unique pattern count, lane output channels, route targets, drum note events, piano note events, Chord Engine markers, expression events, and warnings such as silent preserved gaps or empty exports. The selected-pattern/clip export path and full/drums-only Arrangement MIDI export path now render this data in a SpaceAge-styled MIDI Export Preview before opening the save dialog.

spaceage::midi::buildMidiExportJobPlan() converts a readiness packet into the final UI-facing dry-run checklist: recommended .mid file names, one single-file row, one lane/track row per exported lane, route/channel text, included payload flags, and diagnostic rows for empty exports. Keep this as a pure MIDI-layer function so export UI, automation tests, and future batch/stem workflows all speak from the same source.

spaceage::midi::inspectMidiStemPackage() is the corresponding trust gate for exported MIDI stem packages. It accepts either a package folder or a direct manifest.json, validates the SpaceAge package format, checks required files, compares declared file sizes, recomputes FNV-1a checksums, and produces both structured state and a copyable plain-text report. Future package import and repair UI should render MidiStemPackageInspection; editor code should not parse package manifests independently.

Migration Plan

  1. Keep existing MIDI input/export behavior stable.
  2. Add model and timing tests.
  3. Persist route and event data through the existing project-state JSON path.
  4. Move live MIDI arming out of editor-only state and into processor/project state. Done: armedMidiInputLane now round-trips through project state. 4a. Separate input filtering from playback/export channel. Done: ArrangementLane::midiInputChannel now round-trips with projects and filters armed-lane note/expression recording.
  5. Upgrade recording from note-only arrays to channel-aware MIDI events. Done for backend recording: active-note tracking is channel-aware, Piano Roll notes persist/export their source channel, and safe expression is recorded into pattern payloads. The first selected-pattern MIDI AUTO editor exists; full Piano Roll / Arrangement automation lanes remain upcoming.
  6. Add CC, pitch bend, aftertouch, sustain, expression, program/bank, and SysEx storage. Partial: pattern-level midiExpressionPayloads now save/load/copy as expression event lists, record incoming expression, and route into live playback/export. SysEx is stored separately as confirmation-required hardware snapshots rather than normal clip expression. UI editing for every message kind is still upcoming.
  7. Add block-accurate MIDI playback scanning for MIDI clips.
  8. Normalize pattern export and arrangement export through shared MIDI writer helpers.
  9. Add MIDI learn and controller mapping on top of the same event/control model.
  10. Add hardware profiles for keyboards, MIDI guitars, wind controllers, drum controllers, and external synths.

Design Rules

  • Do not embed large MIDI event vectors directly inside ArrangementClip. Clips are timeline instances; payloads should be referenced by ID.
  • Clone should share a payload; Variant should duplicate the payload.
  • Undo/save/load must use the same state path, or MIDI data will drift from project state.
  • MIDI data is more than notes. CC, pitch bend, aftertouch, sustain, and program changes need explicit representation in clips. SysEx uses a separate explicit librarian/snapshot workflow, not normal clip playback.
  • Automatic expression payloads are channel-voice performance data. Sanitize SysEx at recording and setter/restore boundaries so helper code cannot accidentally send device dumps during playback/export.
  • MIDI Clock, Song Position Pointer, Song Select, MTC, and MMC are transport/sync/status data. Recognize and describe them, but keep them out of normal note/expression playback until a dedicated sync or hardware-librarian policy owns them.
  • Hardware sync capability flags are guide rails, not permission slips. respondsToClock, respondsToSongPositionPointer, respondsToMtc, and respondsToMmc help the UI explain what a device can follow; actual send/chase behavior still needs an explicit sync policy.
  • buildTransportPlanForProfile() is a dry-run helper. Future UI may render it and ask for confirmation, but playback/project-load code should not treat plan creation as permission to send.
  • Future editor UI should use buildHardwareTransportPlan() instead of duplicating profile lookup, timing context setup, and warning merging.
  • Sync / Hardware UI should render buildHardwareTransportPlanSummary() when it needs readable Start/Continue/Stop, Song Position Pointer, or MIDI Clock preview rows. Do not parse raw transport messages in editor code.
  • Hardware recall UI should render buildHardwareRecallPlanSummary() rows, not raw HardwareRecallMessage objects. The summary is a preview/readiness layer only; sending remains restricted to queueConfirmedHardwareRecall() after explicit user confirmation.
  • Future MIDI import UI should call buildMidiFileImportReviewDashboard() / planMidiFileImport() before converting a file into notes, clips, hardware profile defaults, or expression payloads. The plan is allowed to include the lower-level inspection report, but UI should present the dashboard/plan choices first so musicians see intended actions rather than raw MIDI statistics.
  • importPatternMidi() preserves safe timeline MIDI payload messages through midiExpressionEvents using spaceage::midi::isTimelineMidiPayloadMessage() / the legacy isTimelineExpressionMessage() alias. Do not add ad hoc import filters in editor code; if a message should import as editable musical expression or preserved setup payload, change the shared MIDI boundary first and keep the UI split between editable expression lanes and setup/review cards.
  • MIDI import must continue to quarantine SysEx as confirmation-required SysExSnapshot vault records and keep transport/sync messages out of normal clip playback. Imported expression payloads may include program/bank events as timeline data, but they must not silently mutate internal presets, SoundFont patches, or hardware profiles.
  • Channel role hints are advisory, not commands. They should inform default selections in an import wizard, but the user must remain able to override lane type, destination instrument, and routing.
  • Import lane candidates are UI suggestions, not hidden mutations. The Import Wizard may preselect them, but creating lanes, assigning instruments, preserving expression, and storing hardware setup data must remain visible user choices.
  • MIDI import apply previews are dry-run rows. Building MidiImportApplyPreview must never create lanes, send SysEx, change tempo, change presets, or write hardware profiles.
  • GM channel 10 drum lane imports may store long-form drum hits as PianoNote payloads with pad set to the GM drum pad index. Drum-lane playback/export must read both the classic patterns[pattern][pad][step] grid and these long-form drum note payloads. Do not force imported drum files back into the 64-step Drum Composer grid.
  • MIDI control mappings are project configuration, not performance events. They should be learned, displayed, edited, cleared, and validated through a dedicated UI instead of being hidden inside individual synth tabs.
  • MIDI Learn target ids use namespaced strings. Current APVTS parameter targets use param:<parameterId>. Future lane state should use lane:<laneIndex>:<field>, future clip state should use clip:<clipId>:<field>, and future commands should use action:<name>.
  • The target registry should prefer automatic APVTS discovery over hard-coded target lists. Hand-written descriptors are acceptable only for non-parameter state such as lane mute/solo, clip transpose, transport actions, and future VST wrapper targets.
  • Applying learned mappings to APVTS parameters is the current bridge for hardware control. Runtime mapping uses a non-blocking try_lock so UI mapping edits cannot stall incoming MIDI processing; if that path later causes host-notification or deeper audio-thread concerns, replace it with a lock-free/control-queue layer rather than forking a second MIDI Learn system.
  • A learned control source should be reusable across target types. Do not create separate CC-learn systems for Redshift, effects, lane volume, VST parameters, and hardware controls; use MidiControlMapping as the shared contract.
  • MIDI mapping diagnostics belong in the processor, not the editor. Assignment dialogs should render validateMidiControlMappings() warnings rather than duplicating duplicate-source or invalid-target checks.
  • MIDI duplicate repair belongs in the processor, not the editor. UI can expose a REPAIR button, but the processor must decide what counts as an exact duplicate so future Settings, setup assistants, and support tools all clean the same way.
  • MIDI source replacement belongs in the processor too. Future direct-control learn dialogs should call the shared source-replacement helper after a user chooses Replace Existing; they should not manually delete rows from PluginEditor.
  • MIDI assignment lists should render getMidiControlAssignmentSummaries() rows rather than raw MidiControlMapping objects. Raw mappings are storage/control contracts; summaries are the customer-facing readout.
  • MIDI Learn dashboards should render buildMidiControlMappingDashboard() or the embedded MidiProjectHealthSummary::controlMappingDashboard instead of recomputing duplicate/unresolved counts in editor code.
  • Project-level MIDI dashboards should call buildMidiProjectReadinessReport() rather than manually walking lanes, profiles, mappings, SysEx snapshots, and expression payloads in editor code.
  • External hardware output panels should call buildMidiExternalOutputReadinessReport() after the project readiness report. It answers which lanes are routed to hardware, which output devices/profiles are missing, and whether SpaceAge should allow a trusted send attempt. It is a configuration preflight, not proof that the OS device is currently open.
  • External hardware runtime UI should pair readiness with buildMidiExternalOutputRuntimeReport(). Readiness answers "configured correctly"; runtime answers "requested MIDI output devices are actually open, and whether messages have been dropped."
  • External hardware output UI should render buildMidiExternalOutputTrustGateReport() when the user needs one plain answer. The trust gate combines readiness, runtime, and device inventory into idle/blocked/ready status plus reason, next action, and live-performance/setup-action boundaries. Do not rebuild this decision in editor code.
  • MIDI device selection and troubleshooting UI should render buildMidiDeviceInventoryReport() rather than directly calling juce::MidiInput::getAvailableDevices() / juce::MidiOutput::getAvailableDevices() from scattered editor widgets. The report is where saved lane/profile device ids are compared against currently visible OS devices.
  • Live MIDI input UI should render buildMidiInputReadinessReport() to answer which lane is armed, which channel filter is active, and whether a selected input device is visible. Do not make the user infer input focus from clip selection, lane selection, and controller state.
  • Live MIDI input UI and MIDI Health should render buildMidiInputTrustGateReport() when the user needs one plain answer. The trust gate combines armed-lane state, visible input readiness, source-policy filtering, channel-filter evidence, and direct-input dropped-message diagnostics into idle/blocked/ready status plus reason, next action, and live-performance/setup-action boundaries. Do not rebuild this decision in editor code.
  • Physical input-source identity is available only before JUCE flattens incoming device callbacks into the processor MidiBuffer. In standalone builds, a future source-aware input manager must attach near the MidiInputCallback / AudioDeviceManager layer where MidiInput* source is still known, store { message, sample position, source device id } in a realtime-safe bridge, and then feed SpaceAge through a source-aware sibling of the current live MIDI handler. Do not attempt to recover source device identity inside AudioProcessor::processBlock; by that point the normal JUCE path has already reduced the event stream to messages and sample offsets. Plugin builds should keep treating the host as the physical-routing owner unless a host API provides reliable source identity.
  • Direct physical input timing is a different trust boundary from host/plugin MIDI timing. Host/plugin MIDI arrives in the processor MidiBuffer with native in-block offsets. SpaceAge-opened direct physical MIDI preserves device identity, but the callback bridge timestamps messages and drains them into the audio block; sync/transport keeps the calculated timing sample while live note/controller performance data is scheduled at the earliest drain-block sample to avoid extra monitoring lag. UI should disclose that boundary through MidiInputReadinessReport::directInputTimingNote and should guide users toward HOST ONLY / DIRECT ONLY comparison, audio buffer checks, driver checks, and timing calibration rather than hiding fixed latency offsets in unrelated live-input code.
  • MIDI Health / Dashboard UI should prefer buildMidiProjectHealthSummary() when it needs the combined project snapshot. That summary now carries both inputTrustGate and outputTrustGate, so the visible Health panel, copied reports, and future setup assistants should read those fields instead of rebuilding MIDI IN / MIDI OUT verdicts in PluginEditor. Do not rebuild a parallel dashboard model in editor code, and do not call this helper from high-frequency paint/timer paths unless the result is cached or throttled.
  • MIDI Timing Calibration UI should render MidiProjectHealthSummary::timingCalibration. Do not imply a measured hardware round-trip exists until an actual calibration pass has produced one; the current summary is readiness, recommendation, and saved-compensation state.
  • MIDI Timing Calibration measurement UI should use buildMidiTimingCalibrationMeasurement() for pulse result math. Do not duplicate latency, jitter, confidence, or compensation calculations in PluginEditor.
  • MIDI export dialogs should render buildPatternMidiExportReadiness(), buildArrangementClipMidiExportReadiness(), or buildArrangementMidiExportReadiness() before opening the save/write path. Do not manually walk clips in editor code to guess channels, pattern counts, expression data, or drums-only omissions.
  • MIDI export dialogs should call buildMidiExportJobPlan() after readiness and render the plan rows. Readiness answers "what exists?"; the job plan answers "what will the export action present and write?"
  • MIDI stem package import/repair dialogs should call inspectMidiStemPackage(), then buildMidiStemPackageImportPreview(), then buildMidiStemPackageImportPlan() once the preview is clean enough to describe a safe import. Inspection is the fact layer; the preview is the safety/action layer; the plan is the "what would be created?" layer; buildRecommendedMidiStemPackageImportCommitRequest() is the explicit user-intent layer; CinematicDrumsAudioProcessor::applyMidiStemPackageImport() is the mutation boundary; and MidiStemPackageImportApplyResult is the receipt/audit layer. UI should render the preview's ready/repair/blocking state, then the plan's lane stems, support files, channel/event counts, warnings, and plain-text report before any import or repair action is offered. Do not parse package manifests directly in editor code, and do not mutate the project from inspection or preview alone.
  • Hardware/SysEx vault pages should render getHardwareMidiProfileSummaries() and getSysExSnapshotSummaries() rows rather than raw profile/snapshot structs. Raw structs preserve exact device data; summary rows are the safety/readability layer.
  • Hardware profile summaries expose timing calibration labels and warning state. UI should show this as device-specific timing knowledge, not as proof that the current audio interface/buffer/controller path still matches the saved calibration.
  • MIDI import dialogs should render buildMidiImportReviewDashboard() or buildMidiImportSummary() rather than rebuilding file/channel/safety text in editor code. The inspection report is the fact layer, the import plan is the policy layer, and the summary/dashboard is the customer-facing trust layer.
  • Keep raw controller counts and musical-expression counts separate. controllerEvents and per-channel controller counts are raw MIDI inspection facts; expression summaries and dashboards should subtract setup controllers and Bank Select so hardware setup data does not masquerade as ordinary mod-wheel/expression performance.
  • Piano Roll expression editors should render getMidiExpressionLaneSummaries() / summariseMidiExpressionEvents() before exposing raw MIDI events. The summary tells the UI which CC, pitch bend, pressure, program, or bank lanes exist and their value ranges.
  • Piano Roll expression editors should use addMidiExpressionEvent(), removeMidiExpressionLane(), and replaceMidiExpressionLane() for edits. Do not mutate midiExpressionEvents from editor code; that would bypass message filtering, sorting, and pattern length policy. Direct authoring should stay limited to ordinary performance expression until grouped RPN/NRPN and hardware setup editors exist.
  • Treat clip-local expression as future architecture, not a UI skin over midiExpressionEvents. If SpaceAge adds arrangement-specific expression overlays, the feature must define how overlays interact with pattern variants, linked clips, paste-over-gap behavior, full-song MIDI export, and hardware-lane playback.
  • Controller mappings should distinguish source identity from destination range. CC74 on channel 3 is the source; "Redshift cutoff 20 Hz to 20 kHz" is the target/range decision.
  • Panic should stop internal sound and send conservative external reset traffic: pitch bend center, mod wheel zero, sustain pedal up, All Sound Off, Reset All Controllers, and All Notes Off for each channel. This must reach both plugin MIDI output and every unique direct hardware device currently routed from Arrangement lanes.
  • Sustain follows the normal pedal contract: CC64 values >= 64 latch note-offs on the same MIDI channel; CC64 values < 64 release pending voices for that channel. Panic and Reset All Controllers clear the latch.
  • Raw MIDI thru should never be accidental. It can create hardware feedback loops.
  • Customer-facing MIDI labels should distinguish the task: ARM / MIDI IN means live input and recording; INPUT CH means the incoming channel filter; PLAY/EXPORT CH means playback/export channel. Do not collapse these back into a generic MIDI label.
  • When a lane is armed with a fixed input channel, non-reset incoming MIDI on other channels is ignored for live input, recording, and performance-controller capture. This prevents cross-lane controller contamination. Panic/reset messages still pass through safely.
  • Raw selected-pattern export uses a documented pattern-local convention: drum steps export on General MIDI channel 10, Chord Engine markers export on channel 1, and Piano Roll notes preserve their stored source channels. Selected Arrangement clip export is lane-aware: it uses the clip's source range, repeats, transpose, and lane playback/export MIDI channel.
  • Lane-owned MidiTrackRoute state now persists the intended input device id, output device id, and route target. The Lane MIDI Routing card exposes input channel filter, saved input-device intent/visibility, source-identity status, playback/export channel, hardware output assignment, and route target as saved lane-local intent.
  • The Lane MIDI Routing card now also exposes saved hardware profile assignment when profiles exist. This is still identity/intent only: selecting a profile does not send bank/program or SysEx data.
  • Live generated arrangement MIDI now has a processor-owned hardware output router. Lane edits open/update hardware output slots outside the audio callback; the audio callback only tags generated note/controller messages with a lane index and pushes fixed-size messages into a bounded queue. The router thread drains that queue and talks to juce::MidiOutput. This deliberately does not turn incoming MIDI into MIDI thru.
  • Route target semantics are live during Arrangement playback: Internal Instrument plays the lane instrument and emits the normal plugin MIDI stream; Hardware Only suppresses internal audio and plugin MIDI while still allowing the hardware owner to send generated lane MIDI; Internal + Hardware does both; Export Only stays silent during live playback but remains present in MIDI export.
  • SysEx remains a separate librarian path. Do not push arbitrary SysEx dumps through the generated-note hardware queue; SysEx needs explicit user confirmation, profile naming, and progress/error reporting.
  • Hardware profiles and SysEx snapshots are project data, not performance events. Saving them is safe; sending them must remain an explicit action with confirmation, device context, throttling, and a visible log.
  • Lane-owned MidiTrackRoute::hardwareProfileId is a saved association, not a command. Do not interpret loading a project or selecting a lane as permission to send bank/program changes or SysEx to external hardware.
  • Bank Select and Program Change can exist as timeline expression data for import/export/hardware performance, but they must not silently change SpaceAge internal Instrument Bay presets or SoundFont patch selections. Any conversion from timeline MIDI patch data into lane/profile defaults needs an explicit user-facing import or mapping action.
  • Program/bank UI should render summariseMidiPatchEvents() rows before offering conversion actions. Do not ask the user to interpret raw CC0/CC32/Program Change events.
  • RPN/NRPN selector traffic is not ordinary knob movement. CC99/98 and CC101/100 choose a hardware or synth parameter, while CC6/38 and CC96/97 edit that chosen parameter. Preserve and sort those messages, but do not expose them as loose learnable controls. Current review text shows selector rows and orphan-Data-Entry warnings, and the shared MIDI layer exposes structured MidiSetupControllerSummary groups. Complete standard RPN cards can queue after explicit confirmation; NRPN cards require a queue-safe Hardware Passport definition; protected channel-mode/reset rows stay copy-only from MIDI PATCH.
  • Standard RPN and Data Entry setup cards are guarded send candidates only through the confirmed MIDI PATCH queue path. Unknown NRPNs remain review/copy data until a Hardware Passport defines the selector as queue-safe. Protected channel-mode messages remain copy-only until a separate confirmed policy exists.
  • Channel-mode messages such as Local Control, Omni Off/On, Mono Mode, and Poly Mode are hardware/setup commands. They may need import inspection and explicit hardware-profile recall, but they must stay out of MIDI Learn and ordinary expression editing unless the user is deliberately editing hardware setup data.
  • buildHardwareRecallPlan() is intentionally not a send function. Current Hardware Passport / SysEx Vault UI should render that plan, let the user confirm, then use the separate throttled sender with progress/error logging.
  • validateHardwareMidiProfile() is the shared preflight helper for hardware UI. Do not duplicate profile/snapshot safety checks in editor paint/layout code.
  • queueConfirmedHardwareRecall() is the backend bridge for confirmed recall UI, not a project-load hook. Never call it from project restore, lane selection, route normalization, or playback start.
  • getHardwareRecallLog() is the backend ledger for explicit recall UI. Keep it about explicit recall attempts; do not mix live playback traffic into it.
  • Program numbering has two worlds: MIDI wire values are 0-127, while many musicians and hardware manuals display programs as 1-128. Keep internal recall bytes exact, but make any future UI label explicit about the convention it is showing.

Next Implementation Targets

  • Polish the first-pass Hardware Profile / SysEx Vault UI around the saved HardwareMidiProfile and SysExSnapshot model. Current interactions cover capture/import, name/tag, attach to lane route through MidiTrackRoute::hardwareProfileId, summary browsing, and confirmed recall; next work should deepen progress/error handling without weakening the no-autosend boundary.
  • Build the customer-facing MIDI Learn UI around MidiControlMapping: arm learn for a target, capture the next valid CC/pitch/pressure gesture, show the source description, assign min/max response, and provide a controller assignment browser.
  • Add a lane action such as Create Hardware Profile From This Lane that calls the processor helper, then opens the profile editor for manufacturer/model/bank/program/SysEx notes.
  • Expand lane-owned MidiTrackRoute as external input/output devices are exposed. Current bridge: arrangement lanes normalize midiInputChannel and midiChannel into MidiTrackRoute, while legacy fields remain the save/load compatibility mirror.
  • Continue the pattern-level expression payload migration into editor UI. midiExpressionPayloads are persisted as pattern-owned event lists, recorded from MIDI input, imported from safe MIDI file expression, and scanned during live playback/export for normal channel expression. SysEx is intentionally not auto-recorded, auto-imported, auto-played, or auto-exported.
  • Keep note-recording and expression-recording timing on the shared compensation helper so latency correction, wraparound, and future MIDI 2.0/MPE timing stay in one policy path.
  • Melodic MIDI recording uses the armed Arrangement Lane as the route owner when one is armed: input filtering follows midiInputChannel, while stored notes and recorded MIDI AUTO/expression events use the lane instrument and lane play/export MIDI channel.
  • Replace-record mode must clear the target's native data type plus old MIDI AUTO/expression data. Drum-lane recording clears drum steps and long-form note payloads for the target pattern; melodic-lane recording clears Piano Roll notes. Both clear MIDI AUTO data so old sustain, pitch bend, pressure, or controller movement cannot leak into a fresh take. Do not route every replace-record action through Piano Roll cleanup.
  • Drum-lane recording must not call Piano Roll instrument readiness helpers. Those helpers can initialize or alter melodic synth-pad state and are valid only for melodic Piano Roll lanes. Drum recording belongs to the pad grid and should not mutate an unrelated melodic instrument while arming or starting a take.
  • Extend the processor-owned armed MIDI input lane into full monitor/record policy.
  • Polish the external MIDI output device-owner layer: keep output enumeration/opening, timestamped lane playback queues, panic/reset paths, preflight, jitter diagnostics, and real-device validation in one shared policy instead of scattering hardware-output behavior through editor code.
  • Split the current processBlock MIDI handling into small named helper functions.
  • Continue the expressive recording migration by adding musician-facing editing lanes for CC, pitch bend, aftertouch, sustain, and program/bank data, plus deeper tests for recording beyond the first block of long clips.
  • Add pressure and program/bank behavior deliberately. Poly aftertouch and channel pressure now reconstruct correctly from saved field data and are available as internal pitch/filter modulation sources in the MOD / CHOKE route matrix. Future pressure work should add musician-facing pressure lanes, smoothing, and controller-profile defaults rather than hard-wiring private behavior into individual engines. Program/bank should feed an explicit SoundFont/hardware preset workflow instead of silently changing internal engines.

2026-07-12 19:24 - MIDI multi-controller source routing fix

  • Fixed a two-controller regression by making MIDI Auto source arbitration message-specific instead of stream-global. Host/direct duplicate suppression now compares recent host and direct note timestamps in both directions, rather than muting all host or all direct input after one source appears.
  • Direct physical MIDI discovery now opens all visible input devices. Lane-specific input-device choices are still enforced later in the live-routing gate, but discovery no longer makes a second connected controller disappear because another lane named a controller.
  • Built test package: SPACEAGE_MIDI_MULTI_CONTROLLER_FIX_20260712-192341.zip.

Persistent automation ownership

  • Clip-local MIDI automation is owned by stable clip IDs; lane-local automation is owned by stable lane IDs.
  • Lane row indices remain valid for current UI selection and live routing, but are translated to stable IDs at the processor ownership boundary.
  • Project restore stages automation payloads, restores and repairs lanes/clips, then reconciles payloads against verified owners.
  • Duplicate serialized owners receive fresh IDs and independent payload copies. Orphan payload rows are pruned.
  • This identity contract is the prerequisite for safe lane reorder/delete, SpaceAge Scenes, instrument reassignment, and future hardware/VST routing persistence.

Real-time recording commit boundary (2026-07-14)

  • MIDI callbacks are producers, not project-document editors. Completed notes/drum steps enter a bounded note queue; timeline expression enters a separate bounded expression queue.
  • Worker threads own mutation of pianoNotes, drum-step documents, MIDI expression lists, sorting, commit receipts, and final pattern-length inference.
  • Queue capacity is finite by design. Overflow increments health counters rather than allocating or blocking on the audio callback.
  • A queued note includes its minimum Arrangement-owned pattern length. Timing coordinates and the clip-length contract travel together to the worker.
  • Current limitation: the callback still takes a short lock to snapshot armed-lane/clip routing, and Arrangement playback still reads under patternMutex. Immutable playback/record-context snapshots are the next real-time boundary.
  • Live note and recording ownership now use a fixed source-session/channel/note key. Host MIDI has one stable source bucket; each direct input uses its router slot plus device generation. Two controllers may therefore play the same channel/note without the first release clearing the other controller's live-note indication, voice, or recorded-note slot.
  • Source-session note ownership is extended by the source-aware controller and proactive generation-retirement sections below.

Source-aware controller and channel-mode ownership (2026-07-14)

  • Sustain, pitch bend, modulation, channel volume, expression, and pan now have fixed source-session/channel state in addition to the compatibility channel summary.
  • A voice retains both its routed output channel and its input control channel. Controller messages therefore keep affecting the correct performer when a lane remaps input channel 4 to output channel 15.
  • CC 64 pedal release, CC 120 All Sound Off, CC 121 Reset All Controllers, and CC 123 All Notes Off operate only on the matching source session and input control channel. Panic remains intentionally global.
  • Reusing a direct-input slot with a new device generation retires stale voices, ownership, controller state, and open recording slots before the new generation performs.
  • The implementation uses fixed arrays and integer source keys; it adds no maps, strings, allocation, or container growth to the audio callback.

Proactive direct-input generation retirement (2026-07-14)

  • The direct-input router publishes a fixed 16-slot generation snapshot at every audio-block drain. A generation uses release/acquire ordering so device removal and slot reuse become visible before queued MIDI is accepted.
  • Reconciliation happens before queue consumption. When a slot generation changes, SpaceAge closes that source's open recording notes at the current recording position, retires its voices, clears live ownership, restores its controller defaults, releases its sustain contribution, and invalidates its recording slots.
  • Cleanup is scoped to the retired slot and generation. Another controller may hold the same channel and note, with sustain down, without being released or reset.
  • Queued events carrying the retired generation are rejected. If another device has already reused the slot, its newer generation survives and performs normally in the same drain.
  • The normal path uses fixed arrays and bounded scans; it adds no allocation, map growth, or device-name comparison to the audio thread.
  • Physical unplug discovery is now processor-owned and editor-independent. A JUCE message-thread timer is the sole authority for device enumeration and router synchronization, so closing the editor does not stop hot-plug detection.
  • The poller reads a locked snapshot of requested lane input IDs rather than walking mutable Arrangement data. Lane reset, restore, and route edits publish a new snapshot and request an immediate poll.
  • prepareToPlay, source-policy changes, and UI refresh requests no longer open or close ports directly. They wake the single owner, preventing overlapping check/open/install transactions and duplicate device slots.
  • Processor destruction explicitly stops the timer and waits out any in-flight callback before the input router is destroyed. Enumeration, open/start/stop, strings, and inventory locks remain outside the audio callback.
  • The normal cadence is one second, with immediate wake requests after relevant configuration changes.

Realtime control and capture contracts (2026-07-15)

The control-mapping queue is a two-stage fixed-capacity handoff: the MIDI callback publishes a compact JUCE message plus the current Learn generation, a worker drains the callback FIFO, and AsyncUpdater performs Learn mutation and setValueNotifyingHost() on the message thread. Mapping storage is copied while briefly holding midiProfileMutex, then host notification occurs after that lock is released. Cancel/arm increments the generation, so stale queued gestures cannot capture a later target. RPN/NRPN setup traffic is rejected by the learnable-message gate and remains owned by the grouped hardware setup workflow.

Live SysEx capture is deliberately one-shot. Arming establishes a 60-second listening window; the first structurally valid SysEx message enters a preallocated two-slot, two-megabyte queue and immediately disarms capture. A worker constructs the receipt and publishes review state. Saving creates a confirmation-required Vault snapshot and may attach it to the selected Hardware Passport. The capture receipt proves receipt by SpaceAge, not device identity, semantic completeness, checksum validity, or external acknowledgement. Source-identity pinning and device-dialect parsing remain explicit future layers.

SysEx restore-response conversation contract (2026-07-15)

A confirmed restore send becomes active only after every byte is accepted by the hardware output queue. Active attempts retain the SysEx snapshot identity, Hardware Passport identity, output destination, label, and queue timestamp. They are session runtime evidence, not project-content migration state.

Incoming SysEx first obeys explicit user intention: an armed one-shot capture owns the message. Otherwise, when a restore attempt exists, a bounded four-slot/two-megabyte response queue copies the message and wakes a worker. ACK, NAK, and CANCEL finalize the oldest pending attempt; WAIT refreshes its timeout; unrelated or non-final SysEx does not consume it. The minimum response window plus grace prevents slow hardware from being declared dead immediately.

Verification receipts are deliberately precise. Queue acceptance proves only that SpaceAge submitted bytes. A standard hardware reply proves that a matching response class arrived, while device-specific semantic state, checksum interpretation, and source identity still require a profile dialect and physical validation. MIDI Health publishes active conversations, final receipts, queue drops, and oversized replies from this runtime path.

High-resolution Learn and soft-takeover contract (2026-07-15)

A learned control mapping is identified independently from its source and target, allowing one physical source to intentionally control several targets as a macro while duplicate source-target rows remain repairable. A source conflict is never silently resolved: capture creates a preview, and confirmation chooses either replacement or shared-source macro behavior. Cancellation leaves the existing mapping graph untouched.

For the standard MIDI 1.0 14-bit CC families, SpaceAge stores the MSB controller as the canonical learned source. Per-channel fixed arrays retain the latest MSB and LSB values. Automatic resolution emits the coarse MSB value immediately and refines it when the LSB arrives; required-14-bit resolution emits only after both halves are available; forced seven-bit ignores the fine half. An LSB without a current MSB cannot mutate a parameter.

Pickup state is per mapping ID, not merely per source. Until the physical value enters a small neighborhood of the software parameter or crosses it between consecutive messages, the target remains unchanged. Runtime activity is published for UI summaries, and host parameter notification occurs only after releasing the MIDI profile mutex. Mapping replacement, removal, or state restore clears paired-controller and pickup activity so stale hardware motion cannot leak into a new assignment graph.

Owner timing domains

The three expression owners do not share one clock:

  • Shared PTN: source-relative. Events repeat whenever the shared pattern source repeats.
  • Clip Local: source-relative to one stable Arrangement clip identity. Events repeat with that clip's source and repeats, but do not affect linked clips.
  • Lane Local: absolute Arrangement timeline. Events remain attached to the instrument lane, continue through silent clip gaps, and never restart merely because a new clip begins.

Runtime evaluation preserves the audible precedence Shared PTN -> Lane Local -> Clip Local while supplying source steps to Shared/Clip data and absolute timeline steps to Lane data. Export follows the same rule: pattern/clip layers are rendered per source occurrence; Lane Local is rendered once over the lane timeline, or only over intersecting absolute spans for selected-clip export. Never merge these containers first and then apply one timing transform.

Atomic arranger mutation boundary

Commands that can expand Arrangement state must follow: calculate target -> preflight every required slot -> create one Undo checkpoint -> mutate -> normalize. CUT, paste, and clone must refuse before mutation when pattern, clip, section, or preserved-gap fragment capacity is insufficient. Partial success is not an accepted fallback.

Sample-domain authored expression dispatch (2026-07-16)

Sequencer expression retains its absolute sample delay until the current block owns the event. External plugin MIDI and hardware routing are published immediately with that sample offset. Internal synth state is instead copied into a fixed-capacity per-block queue, stably ordered by sample while preserving enqueue order for ties. The renderer consumes that queue immediately before each matching sample and then consumes incoming performance MIDI, making authored automation deterministic while allowing the live player to override it at the same instant.

This queue must remain separate from incoming performance dispatch. Authored expression must not pass through MIDI Learn, recording, armed-lane filtering, direct-device ownership, or callback-age compensation.

Hardware-output cancellation replacement contract (2026-07-16 audit)

The current output worker's FIFO reset is not a valid cancellation barrier while a producer or consumer may own a reservation. The replacement must never reset a running FIFO. Queued traffic must carry lane-route, device-lifetime, recall, and global safety epochs; panic, reroute, disconnect, profile mutation, and project reset retire stale work logically. Final epoch validation and device send occur under one send gate so disconnect is linearizable.

The worker must own deadline ordering rather than wait inside each dequeued item. A reserved safety-command queue is always serviced before live, recall, or scheduled traffic. Panic expands one guaranteed-capacity command per open device into all-channel release/reset messages. Audio-thread publication reads immutable prepared destinations and never opens a device, copies a device string, or waits on the output driver's send lock.

2026-07-16 - Hardware output execution model

Hardware-bound lane traffic uses a stable route publication. Each lane owns atomics for endpoint slot, route target, output channel, and an odd/even route epoch. The callback retries a read if the epoch changed, preventing mixed routes assembled from two updates.

Every queued item captures the lane route epoch, device generation, global queue epoch, lane index, and monotonic sequence. Immediately before sending, the worker revalidates these identities. Rerouting invalidates old lane traffic; reset invalidates all prior queue traffic.

MIDI Clock destinations are prepared when hardware profiles change and published as fixed endpoint slots. processBlock performs only clock arithmetic and bounded enqueue operations; it does not copy profile collections, build strings, enumerate devices, or open outputs.

The worker merges live and recall traffic into a pre-reserved min-heap ordered by effective deadline and sequence. It waits only for the next deadline and remains interruptible. A separate safety FIFO has priority for Panic and channel-release messages.

Lane reroute emits channel-scoped pitch-center, modulation-zero, sustain-up, and all-notes/sound-off messages to the old endpoint. Full Panic emits the same safety family over all channels and retires ordinary queued traffic.

Deterministic hardware-output test seam (2026-07-16)

MidiHardwareOutputRouter uses the same dispatch path in production and tests. Under SPACEAGE_AUDIO_SELF_TEST only, virtual device slots replace OS MidiOutput handles, a manual monotonic clock supplies deadlines, and an ordered trace records successful musical and safety sends. Tests can therefore populate the real FIFOs and deadline heap, retire route or queue epochs, issue Panic, advance time, and assert the exact dispatch result.

The seam intentionally does not bypass queue collection, epoch validation, heap ordering, pending-count bookkeeping, or safety dispatch. It replaces only the operating-system endpoint and wall clock. This keeps regression evidence representative while preventing test-only state from entering release builds.

Coalesced safety intent and endpoint generations (2026-07-16)

Emergency output is represented as one atomic packed intent per endpoint: the upper word is the endpoint generation and the lower word is a MIDI-channel release mask. A full Panic sets all channel bits. Producers merge intent with compare/exchange, so duplicate requests collapse and no finite message FIFO can silently reject the action. The worker exchanges the intent to zero and expands each requested channel into the defined release/reset family.

Each output slot also owns the stable JUCE device identifier and a monotonically advancing generation. Message-thread inventory reconciliation advances the generation before closing a disappeared endpoint, invalidates lane and clock publications that referenced its slot, and reopens only stable IDs still demanded by current routes. Queued musical and safety entries must match the current generation immediately before dispatch.

Target-mode changes that do not alter the physical destination or output channel do not retire the route epoch. This preserves pending note-offs when a lane changes between external-only and internal-plus-external. When multiple lanes share one physical endpoint/channel, lane reroute suppresses channel-wide cleanup to avoid silencing the other lane; such sharing remains an advanced configuration without independent per-lane note ownership.