-------------------------------------------------------------------------------- --! @title Arpeggiator --! @brief Classic chord arpeggiator (Up / Down / UpDown, octave range) --! @category Sequencing --! --! A simplified port of the built-in UVI arpeggiator: takes chord input, --! plays a single time-aligned pattern across the held notes, with the --! standard modes and an octave-range knob. --! --! Architecture worth studying: --! --! 1. **One shared timeline, not one coroutine per note.** A single arp --! coroutine is spawned on the first note-down and torn down on the --! last note-up via an epoch token (`arpId`). Notes added or removed --! mid-pattern simply update the shared held-notes set; the running --! loop sees the change on its next step. This is what gives the arp --! a stable beat grid regardless of how many keys come and go. --! --! 2. **Per-note mirror for safe Enable toggle.** When Enable is off the --! script forwards notes (pass-through). When on it swallows them and --! feeds the arp. The matching release MUST do whatever the press --! did, regardless of the current Enable state, otherwise toggling --! Enable mid-hold leaks stuck or orphan voices. We remember the --! decision per-note in `forwarded`. --! --! @demonstrates spawn, waitBeat, beat2ms, postEvent, playNote, --! OnOffButton, Menu, Knob, per-note bookkeeping -------------------------------------------------------------------------------- local resolutions = {1.0, 0.5, 1.0/3, 0.25, 1.0/6, 0.125} local resolutionNames = {"1/4", "1/8", "1/4t", "1/16", "1/8t", "1/32"} local modes = {"Up", "Down", "UpDown"} ------------------------------------------------------------------------ -- UI ------------------------------------------------------------------------ Enable = OnOffButton{"Enable", true, displayName = "Arp On", tooltip = "Off = pass-through"} Mode = Menu{"Mode", modes, selected = 1, displayName = "Mode"} Speed = Menu{"Speed", resolutionNames, selected = 2, displayName = "Speed"} Octaves = Knob{"Octaves", 1, 1, 4, true, displayName = "Octaves", tooltip = "Octaves the pattern spans"} Gate = Knob{"Gate", 0.8, 0.05, 1.0, unit = Unit.PercentNormalized, displayName = "Gate"} ------------------------------------------------------------------------ -- Held-note set -- Tones are kept sorted ascending by pitch so each step indexes into a -- stable, musical order regardless of the order the user pressed keys. ------------------------------------------------------------------------ local heldByNote = {} local held = {} local function rebuildSorted() local arr = {} for _, ev in pairs(heldByNote) do arr[#arr + 1] = ev end table.sort(arr, function(a, b) return a.note < b.note end) held = arr end ------------------------------------------------------------------------ -- Per-note bookkeeping (see header comment). ------------------------------------------------------------------------ local forwarded = {} ------------------------------------------------------------------------ -- Arp coroutine: one shared timeline. -- The arpId epoch token lets older spawns die cleanly when the chord -- empties or Enable is turned off. ------------------------------------------------------------------------ local arpId = 0 local function noteForStep(step) local n = #held if n == 0 then return nil end local octs = math.max(1, Octaves.value) local span = n * octs -- total tones across octaves local m = Mode.value local idx, octIdx if m == 1 then -- Up local s = step % span idx, octIdx = (s % n) + 1, math.floor(s / n) elseif m == 2 then -- Down local s = (span - 1) - (step % span) idx, octIdx = (s % n) + 1, math.floor(s / n) else -- UpDown (palindrome) local cycle = (span > 1) and (2 * (span - 1)) or 1 local s = step % cycle if s >= span then s = cycle - s end idx, octIdx = (s % n) + 1, math.floor(s / n) end return held[idx], octIdx end local function runArp(myId) local step = 0 while myId == arpId do local tone, octIdx = noteForStep(step) if tone then local note = tone.note + 12 * octIdx if note > 127 then note = 127 end local beats = resolutions[Speed.value] playNote{note = note, velocity = tone.velocity, duration = beat2ms(beats) * Gate.value} waitBeat(beats) else waitBeat(0.0625) -- idle yield end step = step + 1 end end ------------------------------------------------------------------------ -- Callbacks ------------------------------------------------------------------------ function onNote(e) if not Enable.value then forwarded[e.note] = true postEvent(e) return end forwarded[e.note] = nil heldByNote[e.note] = {note = e.note, velocity = e.velocity} rebuildSorted() if #held == 1 then -- first key down: start the arp arpId = arpId + 1 spawn(runArp, arpId) end end function onRelease(e) -- Mirror the press decision regardless of the current Enable state. if forwarded[e.note] then forwarded[e.note] = nil postEvent(e) return end if heldByNote[e.note] then heldByNote[e.note] = nil rebuildSorted() if #held == 0 then arpId = arpId + 1 -- last key up: kill the arp end end end -- Turning Enable off mid-chord silences the arp loop. Notes already -- forwarded (Enable was off at their press) keep their entry in -- `forwarded` and will release correctly on key-up. Enable.changed = function(self) if not self.value then arpId = arpId + 1 held = {} heldByNote = {} end end setSize(720, 140) makePerformanceView()