feat: three arpeggiators, a macro view, and chords that land on the grid - #11
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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."
Three lines
Three
ArpEngines, each with its own rate, shape, step pattern, twelve slots, chord and chain.ArpEngine.hdid not change — it never knew how many of it there were, so three of themcost 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 wherethey 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 theordinary 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.
noteRefsis 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.
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
cycles A→B→C).
slot never had; the mouse-only exception list drops from three to two.
Neither drag can lose a chord: a drop that lands copies, a drop that misses leaves the card
where it was.
Also in here
Channel (Global, or 1–16, for driving three sounds in a multitimbral rack).
line(0–2, default 0), so existing scripts areunaffected;
get_statereports all three.ABLETON_LIVE all updated. The four screenshots the docs reference were stale well before this
branch —
keys.pngandarpeggiator.pngstill showed the Perform and Transcribesections, 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
triplet produce 2/4/6 hits a bar; a note handed to one line never reaches another; each line's
lanes are its own.
and the Pads bar target chip.
Not verified
particular, are the kind of thing that either sits right immediately or doesn't.
classification, but per this repo's own rule only a real Live load proves that.
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.