Skip to content

feat: three arpeggiators, a macro view, and chords that land on the grid - #11

Merged
owenpkent merged 7 commits into
mainfrom
feat/poly-arp-three-lanes
Aug 2, 2026
Merged

feat: three arpeggiators, a macro view, and chords that land on the grid#11
owenpkent merged 7 commits into
mainfrom
feat/poly-arp-three-lanes

Conversation

@owenpkent

Copy link
Copy Markdown
Owner

Three arpeggiators instead of one, a view that shows all three at once, and a way to make a
chord land on the grid. Owen's ask: "three arpeggiators so we can get polyrhythms and keep
keeping what we currently have, but having three of them, and then being able to feed cards
into different lines."

Stacked on #10. This branch is based on feat/chord-audition-tray, because it reuses the
pad drag-and-drop hook that lands there. Targeting that branch so the diff is only this work;
GitHub will retarget to main when #10 merges.

Three lines

Three ArpEngines, each with its own rate, shape, step pattern, twelve slots, chord and chain.
ArpEngine.h did not change — it never knew how many of it there were, so three of them
cost a routing layer above and no engine work at all.

Nothing about the arpeggiator you already have has changed. Line A registers under exactly
the parameter ids it always had (arpRate, arpSwing, arpDirection), so every saved session,
every automation lane and every MCP script still lands on the arp it was written for. B and C
are arp2* / arp3*, appended, both off by default. Line A's twelve slots stay exactly where
they were in the saved tree; B and C's hang off child nodes an older build ignores, so a session
from before this needs no migration and loses nothing.

Routing is a queue per line, not a pitch mask

Each line has its own MidiMessageCollector. A chord handed to line B is fired through the
ordinary note path — so Exclusive, the Voices cap, Strum and the keybed lights all still apply —
but queued there, and only its engine drains it.

A per-pitch ownership mask was the alternative and it races: the message thread can clear a
pitch's owner before the matching note-off has been drained, stranding that note in an engine's
held set with nothing left that can release it. A queue cannot get this wrong, because the
note-off is physically in the same queue its note-on went into.

noteRefs is per destination stream for the same reason. "One note-on per sounding pitch"
is a statement about one stream; an arp line's input is a different stream with a different
consumer, so a pitch held into line B must not suppress the same pitch played to the track
output. With one shared counter it did, and the note vanished from the output while the key lit.

The macro view

A fourth tab, All, next to A/B/C at the left of the slot row. It swaps the per-line band and
step editor for three rows — one per line — each with the line switch, Latch and Keys, a
detented rate knob with < > and its Sync/Hz switch, the shape with steppers of its own,
eight knobs (Oct, Gate, Chance, Swing, Offset, Ramp, Time, Human), the chord it is holding
and its own Chain. Under them, the BPM knob and Launch Quantize they share.

It is a view, not a fourth line: the current line is untouched by it, so a chord card click
keeps one unambiguous target while all three are on screen. And the panel does not grow — the
rows take the band's space rather than joining it.

The macro view

Launch Quantize

arpQuantize: Off / 1/16 / 1/8 / 1/4 / 1/2 / 1 Bar / 2 Bars — Ableton's transport Quantization,
for the arp. It holds a gesture (clicking a chord card, launching a slot, dragging a card onto
a line tab) until the next boundary, then performs it whole, so the pattern, shape, rate and
chord all land together. Off by default, global rather than per line so the three land together,
and never applied to the keybed: playing a note is playing an instrument.

The deadline is wall clock, computed from a beat position the audio thread publishes each block
and waited out on the 1 ms strum timer rather than the 50 Hz heartbeat — 20 ms is a sixth of a
1/16 at 120 bpm, which is the sloppiness the feature exists to remove.

Cards into lines

  • Click a chord card → the current line (a letter chip on the Pads bar says which, and
    cycles A→B→C).
  • Drag a card onto an arp slot → binds it there. This is the left-click twin Send to arp
    slot
    never had; the mouse-only exception list drops from three to two.
  • Drag a card onto a line tab → hands it to that line now.

Neither drag can lose a chord: a drop that lands copies, a drop that misses leaves the card
where it was.

Also in here

  • Per-line Keys (does this line follow the keybed, or only the cards you hand it) and
    Channel (Global, or 1–16, for driving three sounds in a multitimbral rack).
  • MCP: the four arp tools take an optional line (0–2, default 0), so existing scripts are
    unaffected; get_state reports all three.
  • Documentation swept. README, CLAUDE.md, ARCHITECTURE, CONTROLS, ARP_DESIGN, MCP and
    ABLETON_LIVE all updated. The four screenshots the docs reference were stale well before this
    branch — keys.png and arpeggiator.png still showed the Perform and Transcribe
    sections, both removed on 2026-07-30, and six lane tabs rather than ten — so all four are
    re-shot from this build, plus one of the macro view.

Testing

  • 158 cases / 2334 checks pass, including three new ones: three engines at 1/4, 1/8 and 1/8
    triplet produce 2/4/6 hits a bar; a note handed to one line never reaches another; each line's
    lanes are its own.
  • All four targets build (Keys, Keys Host, VST3, tests).
  • Verified on screen in plain Keys and Keys Host: the bar chips, all four tabs, the slot row
    and the Pads bar target chip.

Not verified

  • Nobody has heard it yet. Three lines at three rates, and Launch Quantize's feel in
    particular, are the kind of thing that either sits right immediately or doesn't.
  • Not loaded in Live. The VST3 builds and nothing here touches the bus layout or plugin
    classification, but per this repo's own rule only a real Live load proves that.
  • Per-line MIDI channel is untested against a multitimbral rack — it buys nothing in Keys
    Host with a single-timbral synth.

One pre-existing edge, made more reachable

Switching a line on while a chord is already ringing leaves that chord's note-off to be eaten by
the engine, so the pitch hangs until All Off. The single arp has always done this on its
off→on transition; three lines is three times the chances. Fixing it needs the audio thread
emitting note-offs for references the message thread owns, which I did not want to do quietly
inside this change. Written down in docs/ARP_DESIGN.md.

Keys runs three independent arpeggiator lines now, A B C, each with its own
rate, shape, step pattern, twelve slots, chord and chain. 1/8 against a 1/8
triplet against 1/4 is three chips and two dials rather than three instances.

ArpEngine.h is untouched: it never knew how many of it there were, so three of
them cost a routing layer above and no engine work. That layer is a
MidiMessageCollector per line rather than a per-pitch ownership mask, because a
mask lets the message thread clear a pitch's owner before the matching note-off
has been drained and strands that note in an engine's held set forever. noteRefs
is per destination stream for the same reason - a pitch held into line B must not
suppress the same pitch played to the track output.

Line A registers under exactly the parameter ids it always had, and B and C are
arp2* / arp3*, appended and off by default, so a session saved before this opens
sounding identical. Its twelve slots stay where they were in the saved tree; B
and C's hang off child nodes an older build ignores. Two ids do appear on A -
arpKeys and arpChannel - both defaulting to what Keys did before there were lines.

On screen: A/B/C on the arp bar where one On used to be, three tabs at the left
of the slot row choosing which line the panel edits (34px inside a row already 58
tall, so the panel's height is unchanged), and a letter chip on the Pads bar
saying which line a chord-card click feeds. Dragging a chord card onto an arp
slot binds it there, or onto a tab to hand it over now - the left-click twin
"Send to arp slot" never had. Hold off stays one button and releases all three.
Every doc that described one arpeggiator now describes three, and the four
screenshots the docs reference are re-shot from this build.

- MCP.md: the optional `line` on all four arp tools, defaulting to 0 so scripts
  written before the lines still drive the one they were written for; the
  arp2*/arp3* parameter naming; `arpLines` in get_state; a three-call polyrhythm.
- ARCHITECTURE.md: noteRefs is per destination stream and why; the arp stage is
  three lines merged; the parameter set exists three times under arpParamId, with
  the audio thread reading cached pointers rather than building ids; the session
  tree's line-0-flat, B-and-C-as-children shape.
- CONTROLS.md: a "three lines" section, the per-line Keys and Channel controls,
  and four ways to get a chord into a line rather than two.
- ARP_DESIGN.md, CLAUDE.md, README.md, ABLETON_LIVE.md: line switches where a
  single On was, Hold off documented as deliberately releasing all three.

Send to arp slot is no longer a right-click-only path: dragging a chord card onto
a slot does the same job, so the mouse-only exception list drops from three to
two in every place it was written down. CLAUDE.md keeps the retired entry with
its reasoning, so the rule is not reinvented.

The screenshots were stale well before this branch - keys.png and arpeggiator.png
still showed the Perform and Transcribe sections, both removed on 2026-07-30, and
six lane tabs rather than ten. All four are current.

Also recorded in CLAUDE.md: the accessible names of the new per-line controls, and
two capture-window.ps1 traps found driving it (SetValues runs before InvokeButtons,
so a value inside a folded section needs two runs; a SetValues that throws leaves a
popup that poisons every later lookup, with a ~156x159 capture as the tell).
Two things the three lines were missing to be performable.

**The macro view.** A fourth tab, All, joins A B C at the left of the slot row and
swaps the per-line band and step editor for three rows - one per line, each with
that line's switch, rate and unit, shape, gate, chance, swing, the chord it holds
and its own Chain - over a shared row carrying a BPM knob and Launch Quantize.
Owen's shape for it: "a fourth option for a simplified version that shows a little
bit of all of them ... the goal is to be able to create complex polyrhythms from
one view."

It is a view and not a fourth line, so the current line is untouched by it and a
chord card click still has one unambiguous target while all three are on screen.
The panel does not grow: the rows take the band's space rather than joining it,
which is the whole reason this is a tab and not a fourth band. Each row's
attachments bind to its own line for good, where the band's rebind on every tab
change - three lines at once cannot each be "the current line".

**Launch Quantize** (arpQuantize: Off, 1/16, 1/8, 1/4, 1/2, 1 bar, 2 bars) is
Ableton's transport Quantization, for the arp - the setting Owen described but
could not name. It defers the *gesture*, not the notes: a slot launch moves that
line's shape and rate as well as its chord, and all of it lands on the boundary
together. Public holdArpChord / holdArpChordFromPad / launchArpSlot defer; the
*Now twins beside them are the same gesture with the wait already served, and are
what the chain calls (already on a bar line) and what a launch calls for its own
chord (it has already waited). The deadline is wall clock, computed from a beat
position the audio thread publishes each block and waited out on the 1ms strum
timer rather than the 50Hz heartbeat, which at 120bpm is a sixth of a 1/16.

Off by default, global rather than per line so the three land together, and never
applied to the keybed: playing a note is playing an instrument.

Screenshots re-shot to include the new tab, plus one of the macro view itself.
Gate, Chance and Swing in the macro rows are the skin's machined rotary now,
matching what the band above already draws the same three settings as (Owen: "I
was thinking more knobs rather than slider"). A row of linear sliders read as a
different panel bolted on. The column heading is written once at the top and every
row reserves the same strip whether or not it draws one, so the three knob columns
line up down the view; the row grows from 40px to 66 to hold a knob and its
readout, which keeps the macro view between the shape and pattern heights.

Two things found while looking at it:

- The All tab toggled the view, so clicking it while it was already up dropped you
  back to the band. A tab selects: you leave the macro view by clicking A, B or C,
  exactly as clicking B while B is up means B.
- An empty STEPS box was ruled beside the band on every plain shape. Coming back
  from the macro view restored all five groups, where STEPS belongs to the step
  editor and has to keep following Shape.

Verified in Keys Host as well as plain Keys: the bar chips, all four tabs and the
Pads bar target chip are all there and laid out.
Owen: "what other knobs can we have? should be like regular arp settings", and
rate as a detented knob rather than a stepper readout.

A macro row now carries the line switch, Latch and Keys, a rate knob (detented on
the divisions, its < > steppers kept because a knob is a drag target and those are
the click-only path to every value in both units), the shape with steppers of its
own, eight knobs - Oct, Gate, Chance, Swing, Offset, Ramp, Time, Human - the held
chord and that line's Chain. Every setting a regular arpeggiator has, three lines
deep, on one screen.

The knobs are the band's own machined rotary, and each column heading is written
once on the top row while every row reserves the same strip, so the columns align
without three copies of the same word. Rate reuses the band's two-attachment dance
per row: one attachment alive at a time, swapped on the unit change, deferred while
a drag is open.

One layout trap paid for on the way: the knob strip is reserved out of the row
before Shape takes its cut. Laying the knobs last and giving the final one
"whatever remains" starved it to nothing as soon as the row got tight, so eight
knobs drew as seven with no other symptom. Shape absorbs the slack instead, and the
column headings are placed from the control they name rather than by walking a
second copy of the layout.

Docs swept for all of it. CLAUDE.md had picked up neither the macro view nor Launch
Quantize - an earlier unasserted replace silently no-opped - so both are in now,
with the accessible names of every new control and the two layout traps written
down. ARCHITECTURE.md gains the macro view and the two arp parameters that are
deliberately global rather than per line (bpm, arpQuantize).
Owen: "I need to be able to drag chords in the poly view. to each arp."

The macro rows are drop targets now. Dragging a chord card out of the pad strip
and letting go anywhere on row A, B or C hands that chord to that line - a target
the size of the row rather than the size of the tab that names it, which is the
difference between aiming and just dropping. Three rows on screen with a different
chord on each is how a polyrhythm gets built out of cards.

The whole row lights while a card is over it, knobs included: externalDropLineAt
walks *up* from whatever Desktop::findComponentAt returns, so a knob under the
cursor resolves to the line it sits on rather than swallowing the hit.

A drop sets the current line but no longer moves the view. setEditLine grew a
`leaveMacroView` flag that only the tabs pass true for: a tab click means "show me
this line", where a drop is routing a chord, and being thrown out of the macro view
into one line's deep controls is the opposite of what the gesture asked for.
Takes the generator fixes (the Notes range really reaching 11, the stuck reference-box
highlight, the stale Chord::type after Lean) and resolves the two doc conflicts by
keeping both sides: the generator's voicing paragraph and this branch's arp-lines
bullet are additive, they only collided over where they sit in the file.

Two things this branch needed on top:

- onDragEnd clears the *arp panel's* external drop target too. This branch added a
  second taker to onDragOutside - a line tab or a slot lights up as a card is dragged
  over it - and it had the same half-a-gesture bug the reference box did: only a drop
  that lands off the row reaches onDropOutside, so dragging out over a line tab and
  then back onto a pad left it highlighted with no drag in progress.

- runArpLines copies the non-note events back into the host buffer instead of
  swapWith. The swap hands our prepareToPlay-sized storage to the host and keeps the
  host's in streamRest, so the buffer sized for this job is given away on the first
  block and every block after partitions into whatever capacity the host had. It
  settles, but settling is not what the no-allocation-on-the-audio-thread rule asks
  for; the copy is a handful of CC bytes a block.
@owenpkent
owenpkent changed the base branch from feat/chord-audition-tray to main August 2, 2026 18:01
@owenpkent
owenpkent merged commit 1775fb1 into main Aug 2, 2026
4 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.

1 participant