Skip to content

232 improve midi clip#248

Merged
Sportinger merged 12 commits into
stagingfrom
232-improve-midi-clip
Jun 6, 2026
Merged

232 improve midi clip#248
Sportinger merged 12 commits into
stagingfrom
232-improve-midi-clip

Conversation

@kfxs

@kfxs kfxs commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

kfxs and others added 12 commits June 2, 2026 09:25
Add a shared --midi-color token and derive both the MIDI clip body and the
MIDI track header from it so audio and MIDI tracks are distinguishable at a
glance. Header uses a flat blue base (no vertical gradient) keeping the
standard top sheen; the default tint is suppressed when a custom label color
is set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lane gated its muted class on track.type === 'audio', so muting a MIDI
track only dimmed the header, not its clips. Use the shared isAudioSectionTrack
predicate (audio + midi) to match the header's mixer-track mute logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useMidiClipDraw mapped the cursor to time against the outer .timeline-track-stack,
which includes the trackHeaderWidth header column, so every new clip was shifted
later by trackHeaderWidth / zoom seconds. Measure against the lane's
.track-clip-row (time-zero origin, scroll already baked in) like the existing
right-click handler, re-reading the row rect on mouseup for scroll robustness.
Drops the now-unused trackLanesRef/scrollX props. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ClipMidiPreview, a canvas mini note view drawn inside MIDI clips: X = note
time, Y = pitch, with the pitch axis fit to the clip's own min..max range (DAW
"fit notes to view") so the used register fills the height. Windowed canvas +
DPR scaling like ClipWaveform so long/zoomed clips stay cheap. Wire it into the
clip body, hide the now-redundant duration number, and shorten the label to
"MIDI" (full clip rename to come later).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Right-click a MIDI clip → Rename enters an in-place inline edit where the label
shows. Adds store state clipRenameId/setClipRenameId and a history-tracked
renameMidiClip action; the clip swaps its label for an autofocused input
(Enter/blur commits, Escape cancels). Default "MIDI Clip" name renders as "MIDI";
a renamed clip shows its custom name. Documents the #232 timeline clip
presentation (color, note preview, label, rename, draw fix) in the MIDI plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the in-clip note preview to map clip-local time 1:1 (x = note.start *
zoom) across the full clip width, so it lines up with the timeline ruler and
shows the whole clip; tails past the clip end are clamped and notes starting
past it are skipped, mirroring the playback scheduler (clip = window onto the
notes). Drops the audio-waveform render-window sizing that was clipping notes
("missing material") and the inPoint offset.

Make the piano roll exactly the clip's real duration (contentWidth =
clipDuration * PX_PER_SEC) instead of a 4s minimum, so the editor and the clip
preview represent the same [0, duration] span; resize the clip for more room.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Overlapping clips on the same track used to trim/delete the clip
underneath. For MIDI this is wrong — overlapping clips must cohabitate
and both sound.

Add a centralized getTrackOverlapPolicy(track) helper: MIDI tracks use
'stack' (overlap allowed, never trimmed), every other type keeps the
existing 'trim' behavior, so non-MIDI editing is byte-identical.

- getPositionWithResistance: stack tracks drop at the exact requested
  position with forcingOverlap=false (no red state, no track bounce).
- trimOverlappingClips: no-op on stack tracks (safety line).
- moveClip needs no change — its trim/bounce key off the now-falsy flags.

Edge snapping is untouched, so MIDI keeps its magnetism; pushing past it
lands the clip overlapping. The scheduler already plays all clips on a
track, so both overlapping clips sound; manual silencing uses track mute.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A piano-roll window is bound to one active (editable) clip. Now it also
shows, read-only, the notes of every other MIDI clip whose timeline span
overlaps the active clip — so the user can read what coexists without
leaving the editor.

- computeGhostNotes(activeClip, allClips): pure helper that converts
  overlapping notes into the active clip's local time and clamps them to
  its window. Note data only (pitch + timing), velocity dropped.
- PianoRoll renders ghosts beneath the real notes as flat grey bars with
  pointerEvents:none and no handlers, so they can't be selected/edited.
- Active vs ghost is decided per window, so two open piano rolls each
  ghost the other's overlapping notes.

Tests: tests/unit/ghostNotes.test.ts (local-time conversion, clamping,
exclusion of active/non-overlapping/non-MIDI clips).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A MIDI clip is now an unbounded note canvas plus a movable window. Notes
keep their absolute timeline position while the clip is resized — only
the window (in/out point) moves, not the data.

Model (single source of truth: services/midi/midiClipTiming.ts):
- note.start is content time (fixed; changes only when the note is edited)
- [inPoint, outPoint] is the visible/playable window (outPoint = inPoint + duration)
- a note at content time t plays at startTime + (t - inPoint)
- inPoint === 0 for existing clips, so this reduces to the old behavior
  (backward compatible)

Routed every consumer through the model: scheduler, piano roll
(render + draw/move/resize), clip mini-preview (new inPoint prop), ghost
notes, and the offline export renderer. Notes outside the window are
hidden but preserved, so shrink-then-enlarge brings them back.

Both edges enlarge freely: MIDI is now an infinite trim source. The
infinite-source list was duplicated in three places (trim commit, handle
affordances, and the live on-timeline resize preview) and only two had
midi — so the clip resized on commit but did not visibly grow/shrink
during the drag. Consolidated into one shared utils/infiniteTrimSource.ts
used by all three, fixing the live-resize feedback.

Also fixes a latent bug where left-shrinking shifted notes (scheduler
read startTime + note.start while trim moved startTime/inPoint).

Tests: tests/unit/midiClipTiming.test.ts (window slide stability,
round-trip, negative in-point pre-roll, hidden-not-moved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A media split keeps both halves as windows onto one immutable source file.
A MIDI clip has no external source — the note data IS the content — so
splitClip now special-cases source.type === 'midi' and partitions the notes
into two standalone clips via partitionMidiNotesAtCut instead of sharing one
note array behind complementary windows.

Each half owns only its own notes, rebased to inPoint = 0 (left notes shift by
inPoint, right by the cut), with absolute timeline positions unchanged. Notes
are assigned WHOLE by where their start falls (same rule as isNoteStartInWindow)
and are never sliced: a note starting just before the cut stays in the left
clip with its full duration and rings out past the cut, as before. Notes outside
the visible window are dropped (already silent — a standalone clip keeps no
ghosts). Because the data is genuinely separated, the clip preview, piano roll
and scheduler each show/fit only that half's notes with no special-casing.

This is deliberately distinct from a resize, which still uses the reveal-on-
enlarge window model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Cubase-style Glue tool (Cut tool group, school-glue-bottle icon), the
inverse of the MIDI cut. Click any MIDI clip to merge its contiguous run of
touching/overlapping clips — expanding both directions, so clicking either side
of an adjacent pair glues the same run, and a gap breaks the run (glue never
jumps a space). Alt-click force-merges every following clip on the track, gaps
included. Non-MIDI/locked clicks are no-ops.

Notes are combined via mergeMidiNotes (the inverse of partitionMidiNotesAtCut):
every note keeps its absolute timeline position, only in-window notes are taken
(no resurrected ghosts), overlapping clips contribute polyphony, and gaps stay
silent. So cut -> glue round-trips cleanly.

- mergeMidiNotes + MidiMergeSegment in services/midi/midiClipTiming.ts
- merge-midi-clips edit operation + applyMergeMidiClipsOperation
  (contiguousRunContaining resolver) in stores/timeline/editOperations
- glue tool wired through the registry, defaults, icons, cursor, shortcuts,
  pointer dispatcher, and guided-replay tool mapping
- GlueBottleIcon custom icon + matching cursor
- docs + unit tests (mergeMidiNotes round-trip, resolver run/gap/overlap/alt/no-op,
  cut-group cycle order)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Sportinger Sportinger merged commit ac0a565 into staging Jun 6, 2026
3 checks passed
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.

2 participants