-------------------------------------------------------------------------------- --! @title DrumSequencer --! @brief Eight-Track Drum Grid With Mixed Step Resolutions --! @category Sequencing --! --! Eight drum tracks on one bar, each with its own MIDI note, mute, solo and a --! grid you draw velocities into. Three lanes per track (velocity, pan, tune), --! a write resolution from 1/4 down to 1/64T, swing, per-track pattern tools, --! pattern files and MIDI export. See @ref ExStepSequencer "StepSequencer" for --! the same idea reduced to its smallest possible form. --! --! Two mechanisms here are worth studying before writing a sequencer of your own. --! --! 1. **The write grid.** Each track and lane owns TWO Tables sharing the same --! bounds. `Steps` holds the pattern at the finest resolution the sequencer --! supports (96 steps per bar) and is never clicked. `Paint` sits on top of --! it, is resized to the resolution the user draws at, and writes into --! `Steps`. That indirection is what lets 1/4 and 1/64T hits coexist in one --! sequence: a coarse edit still lands on the fine grid, so it never --! destroys what was drawn at a finer resolution. Drawing goes through --! `writeCell()` whatever triggers it, the mouse or a tool. --! --! 2. **The clock.** A micro-thread walks the fine grid and is cancelled by --! bumping a generation counter, so two loops can never end up running at --! once, however fast the transport is toggled. --! --! @demonstrates Table, spawn, waitBeat, playNote, saveData, createMidiFile, Euclidean rhythms -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Configuration -------------------------------------------------------------------------------- local NUM_TRACKS = 8 -- Short on purpose: the track column is kept narrow so the grid gets the width instead. local TRACK_NAMES = {"Kick", "Snare", "Clap", "HH", "HH ped", "HH open", "Crash", "Ride"} local DEFAULT_NOTES = {36, 38, 39, 42, 44, 46, 49, 51} -- General MIDI drum map local TRACK_COLOURS = {"ed6b51", "bb8d00", "b5b107", "81c781", "7fe0e0", "02b0ff", "a398e8", "be88cc"} -- 96 steps per bar is the smallest grid that divides every write resolution below into a whole -- number of steps. That is the whole reason resolutions can be mixed in one pattern. local STEPS_PER_BAR = 96 local NUM_BARS = 1 local NUM_STEPS = STEPS_PER_BAR * NUM_BARS local STEP_BEATS = 4 / STEPS_PER_BAR -- one storage step, in beats local RESOLUTION_NAMES = {"1/4", "1/8", "1/8T", "1/16", "1/16T", "1/32", "1/32T", "1/64T"} local RESOLUTION_CELLS = { 4, 8, 12, 16, 24, 32, 48, 96} local DEFAULT_RESOLUTION = 4 -- 1/16 -- Lane 1 is the trigger lane: a cell drawn there fires one note. The others are continuous, so a -- cell drawn there holds its value for its whole span and the note picks up whatever value sits -- under it, however coarsely it was drawn. local LANE_NAMES = {"Velocity", "Pan", "Tune"} local LANE_MIN = {0, -100, -12} local LANE_MAX = {127, 100, 12} local LANE_CONTINUOUS = {false, true, true} local NUM_LANES = #LANE_NAMES local SWING_NAMES = {"1/16", "1/8", "1/4"} local SWING_STEPS = {STEPS_PER_BAR / 16, STEPS_PER_BAR / 8, STEPS_PER_BAR / 4} local DEFAULT_SWING = 2 -- 1/8 local DEFAULT_TRIGGER_NOTE = 60 -- above the drum map, so it cannot collide with a track note local TRIGGER_KEY_COLOUR = "#1DA462" local EUCLID_VELOCITY = 100 local PATTERN_EXT = "json" -- saveData writes JSON, so name the file for what it is -- The panel is painted flat instead of being left on the host's textured default, because two -- things need its exact value: the grid lines are a shade lighter than it, and the gaps between -- rows are masked back to it. The grid line colour is the factory script's own; the track -- separators sit a step brighter again, since they carry the coarser structure of the two. local PANEL_COLOUR = "232326" local GRID_LINE_COLOUR = "2b2b2f" local SEPARATOR_COLOUR = "35353a" setBackgroundColour(PANEL_COLOUR) -- Resting value of a paint cell, parked just above zero rather than at zero: `changed` only fires -- when a value really changes, so from zero a cell could never be dragged to zero and erasing a -- step would silently do nothing. local PAINT_IDLE = 0.001 -------------------------------------------------------------------------------- -- Helpers -------------------------------------------------------------------------------- local NOTE_LETTERS = {"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"} -- "C-2" .. "G8": one list shared by every menu that picks a note. local NOTE_NAMES = {} for note = 0, 127 do NOTE_NAMES[note + 1] = NOTE_LETTERS[note % 12 + 1] .. (math.floor(note / 12) - 2) end local function stepId(lane, track) return "Steps_" .. lane .. "_" .. track end -------------------------------------------------------------------------------- -- Layout -------------------------------------------------------------------------------- -- 480 px is the height ceiling, so the rows get the space and everything else is trimmed to fit: -- eight 43 px rows plus a transport bar and a utility bar land on exactly 480. local MARGIN = 10 -- left edge shared by the track names, the transport and the utility bar local CAPTION_Y = 6 local CONTROL_Y = 20 local PLAYHEAD_Y = 56 local ROW_Y = 64 local ROW_H = 43 -- the factory script's track height: tall enough for the bars to be worth reading local ROW_GAP = 5 local ROW_PITCH = ROW_H + ROW_GAP local GRID_X = 230 local GRID_W = 480 -- 96 storage steps at exactly 5 px each local GRID_H = (NUM_TRACKS - 1) * ROW_PITCH + ROW_H local STEP_W = GRID_W / NUM_STEPS local UTILITY_Y = ROW_Y + GRID_H + 6 -- 449 with 8 tracks: the bar rides just under the last row local function caption(text, x, w) return Label{text, bounds = {x, CAPTION_Y, w, 12}, fontSize = 10, textColour = "888888", persistent = false, interceptsMouseClicks = false} end -------------------------------------------------------------------------------- -- Transport bar -------------------------------------------------------------------------------- local Play = OnOffButton{"Play", false, bounds = {MARGIN, CONTROL_Y, 56, 24}, displayName = "Play", tooltip = "Start or stop the sequence", persistent = false} caption("TRIGGER", 76, 78) local TriggerNote = Menu{"Trigger_Note", NOTE_NAMES, DEFAULT_TRIGGER_NOTE + 1, bounds = {76, CONTROL_Y + 1, 78, 22}, showLabel = false, tooltip = "Key that starts and stops the sequence"} caption("WRITE GRID", 164, 78) local Resolution = Menu{"Write_Resolution", RESOLUTION_NAMES, DEFAULT_RESOLUTION, bounds = {164, CONTROL_Y + 1, 78, 22}, showLabel = false, persistent = false, tooltip = "Resolution you draw at. The pattern itself is always stored at 1/64T."} caption("LANE", 252, 78) local Lane = Menu{"Lane", LANE_NAMES, 1, bounds = {252, CONTROL_Y + 1, 78, 22}, showLabel = false, persistent = false, tooltip = "Which lane the grid and the tools edit"} caption("SWING DIV", 340, 68) local SwingDivision = Menu{"Swing_Division", SWING_NAMES, DEFAULT_SWING, bounds = {340, CONTROL_Y + 1, 68, 22}, showLabel = false, tooltip = "Period over which swing pushes and pulls"} caption("SWING", 418, 40) local Swing = Knob{"Swing", 0, 0, 1, bounds = {418, CONTROL_Y - 2, 34, 34}, showLabel = false, displayName = "Swing", tooltip = "Swing amount", unit = Unit.PercentNormalized} caption("GATE", 470, 40) local Gate = Knob{"Gate", 0.5, 0.05, 4, bounds = {470, CONTROL_Y - 2, 34, 34}, showLabel = false, displayName = "Gate", tooltip = "Note length, as a proportion of a 1/16 note", unit = Unit.PercentNormalized} caption("DRUM SEQUENCER KISS", 560, 144) -------------------------------------------------------------------------------- -- Utility bar -------------------------------------------------------------------------------- -- Commands, not state: none of these are persistent. local SavePattern = Button{"Save_Pattern", bounds = {MARGIN, UTILITY_Y, 64, 24}, displayName = "Save", tooltip = "Write the pattern to a file", persistent = false} local LoadPattern = Button{"Load_Pattern", bounds = {78, UTILITY_Y, 64, 24}, displayName = "Load", tooltip = "Read a pattern back from a file", persistent = false} local ExportMidi = Button{"Export_Midi", bounds = {146, UTILITY_Y, 88, 24}, displayName = "MIDI file", tooltip = "Render the velocity lane to a MIDI file", persistent = false} local MidiDrag = Button{"Midi_Drag", bounds = {238, UTILITY_Y, 88, 24}, displayName = "Drag MIDI", tooltip = "Drag this into your host", persistent = false, visible = false} -- The exported file stops matching as soon as anything it bakes in is edited, so hide the drag -- source again rather than let it hand out something stale. local function invalidateMidi() MidiDrag.visible = false end -------------------------------------------------------------------------------- -- Playhead -------------------------------------------------------------------------------- -- One cell per 1/16 note rather than one per storage step: the position is just as readable and -- the widget is touched 16 times a bar instead of 96. local PLAYHEAD_CELLS = 16 * NUM_BARS local PLAYHEAD_SPAN = NUM_STEPS / PLAYHEAD_CELLS local Playhead = Table{"Playhead", PLAYHEAD_CELLS, 0, 0, 1, true, bounds = {GRID_X, PLAYHEAD_Y, GRID_W, 5}, enabled = false, persistent = false, fillStyle = "solid", sliderColour = "#dcdcdc", backgroundColour = "#00000000"} -------------------------------------------------------------------------------- -- Grid -------------------------------------------------------------------------------- -- The write grid is drawn by thin vertical bars sitting behind the pattern, as the factory script -- does it: one per storage step, shown only on the boundaries of the current write resolution. -- -- A Table's inner edges cannot stand in for these -- with its cells at rest there is no filled -- slider for an edge to be drawn around -- and the paint overlay cannot draw them either, since -- it is hidden outright (see below). local writeGridBars = {} for step = 1, NUM_STEPS do local onBeat = (step - 1) % (STEPS_PER_BAR / 4) == 0 -- beats get a wider line writeGridBars[step] = Label{"", bounds = {GRID_X + (step - 1) * STEP_W, ROW_Y, onBeat and 2 or 1, GRID_H}, backgroundColour = GRID_LINE_COLOUR, persistent = false, interceptsMouseClicks = false} end -- Close the grid on the right: the end of the bar is a cell boundary at every resolution, and a -- beat boundary at that, so it gets the wide line the other beats get. Label{"", bounds = {GRID_X + GRID_W - 2, ROW_Y, 2, GRID_H}, backgroundColour = GRID_LINE_COLOUR, persistent = false, interceptsMouseClicks = false} -- Those bars run the whole height, so they would tie the eight rows into one block. Each gap -- between rows is masked back to the panel colour to break them apart, then given a single rule -- running the whole width of the script, track column included, so a row reads as one band from -- its name across to the far end of its grid. The mask is what keeps that rule clean: without it -- the vertical bars would cross it every few pixels. for gap = 1, NUM_TRACKS - 1 do local y = ROW_Y + (gap - 1) * ROW_PITCH + ROW_H Label{"", bounds = {GRID_X, y, GRID_W, ROW_GAP}, backgroundColour = PANEL_COLOUR, persistent = false, interceptsMouseClicks = false} Label{"", bounds = {MARGIN, y + 2, GRID_X + GRID_W - MARGIN, 1}, backgroundColour = SEPARATOR_COLOUR, persistent = false, interceptsMouseClicks = false} end local Name, Mute, Solo, Note, Tools = {}, {}, {}, {}, {} local Steps, Paint = {}, {} local suspendPaint = false local function paintCells() return RESOLUTION_CELLS[Resolution.value] * NUM_BARS end -- The single write path into the pattern. A trigger lane puts the value on the first storage step -- of the cell and clears the rest, or a 1/4 note would turn into `span` consecutive hits; a -- continuous lane fills the whole span so the value is found under every step it covers. local function writeCell(lane, track, cell, cells, value) local span = NUM_STEPS / cells local first = (cell - 1) * span + 1 for step = first, first + span - 1 do Steps[lane][track]:setValue(step, (LANE_CONTINUOUS[lane] or step == first) and value or 0) end end local function readCell(lane, track, cell, cells) return Steps[lane][track]:getValue((cell - 1) * NUM_STEPS / cells + 1) end for lane = 1, NUM_LANES do Steps[lane], Paint[lane] = {}, {} end for track = 1, NUM_TRACKS do local y = ROW_Y + (track - 1) * ROW_PITCH local buttonY = y + math.floor((ROW_H - 20) / 2) -- the row is taller than its own controls local menuY = y + math.floor((ROW_H - 22) / 2) -- A Label's first argument is both its text and its parameter id, so track names have to stay -- unique for renamed tracks to come back with the preset. Name[track] = Label{TRACK_NAMES[track], bounds = {MARGIN, y, 60, ROW_H}, editable = true, textColour = TRACK_COLOURS[track], textColourWhenEditing = TRACK_COLOURS[track], backgroundColour = "#00000000", backgroundColourWhenEditing = "#00000000", tooltip = "Rename this track"} -- 22 px, not 18: a one-letter caption still needs room for the widest letter plus the button's -- own padding, or it gets replaced by an ellipsis. Mute[track] = OnOffButton{"Mute_" .. track, false, bounds = {73, buttonY, 22, 20}, displayName = "M", tooltip = "Mute " .. TRACK_NAMES[track]} Solo[track] = OnOffButton{"Solo_" .. track, false, bounds = {97, buttonY, 22, 20}, displayName = "S", tooltip = "Solo " .. TRACK_NAMES[track]} Note[track] = Menu{"Note_" .. track, NOTE_NAMES, DEFAULT_NOTES[track] + 1, bounds = {122, menuY, 56, 22}, showLabel = false, textColour = TRACK_COLOURS[track], tooltip = "MIDI note played by " .. TRACK_NAMES[track]} for lane = 1, NUM_LANES do -- Storage grid: the pattern, always at full resolution. This is what plays and what -- the host saves. It is never clicked, because the overlay below covers it. Steps[lane][track] = Table{stepId(lane, track), NUM_STEPS, 0, LANE_MIN[lane], LANE_MAX[lane], true, bounds = {GRID_X, y, GRID_W, ROW_H}, fillStyle = "solid", drawInnerEdge = false, sliderColour = TRACK_COLOURS[track], backgroundColour = "#00000000", tooltip = TRACK_NAMES[track] .. " " .. LANE_NAMES[lane]} -- Paint overlay: created after the storage grid so it sits on top and takes the mouse. -- Its length is the current write resolution, so one cell here spans several storage -- steps, and it holds no pattern data (hence persistent = false). -- -- alpha = 0 hides it whole, which is the only reliable way to keep it out of sight: a -- fully transparent sliderColour comes out as opaque black, and clearing the cell from -- inside its own `changed` callback fights the drag the widget is in the middle of and -- makes the row flicker. Hidden outright it draws nothing at any point, in or out of a -- drag -- which is also why the write grid needs its own widgets. Paint[lane][track] = Table{"Paint_" .. lane .. "_" .. track, RESOLUTION_CELLS[DEFAULT_RESOLUTION] * NUM_BARS, 0, LANE_MIN[lane], LANE_MAX[lane], false, bounds = {GRID_X, y, GRID_W, ROW_H}, fillStyle = "solid", drawInnerEdge = false, alpha = 0, persistent = false, tooltip = "Draw " .. TRACK_NAMES[track] .. " " .. LANE_NAMES[lane] .. " here"} Paint[lane][track].changed = function(self, cell) if suspendPaint then return end -- re-parking the overlay is not the user drawing on it -- The overlay is a float table (it has to hold PAINT_IDLE), the pattern is integer. writeCell(lane, track, cell, self.length, math.floor(self:getValue(cell) + 0.5)) invalidateMidi() end end end -- Park one overlay row back on its resting value. Needed after anything that changes the pattern -- without going through the mouse: a cell still holding the value it was last dragged to could -- not be dragged to that same value again, so a step a tool has just written would refuse to be -- erased by hand. local function parkPaintCells(lane, track) local cells = paintCells() suspendPaint = true Paint[lane][track].length = cells for cell = 1, cells do Paint[lane][track]:setValue(cell, PAINT_IDLE, false) end suspendPaint = false end local function refreshWriteGrid() local span = NUM_STEPS / paintCells() for step = 1, NUM_STEPS do writeGridBars[step].visible = (step - 1) % span == 0 end for lane = 1, NUM_LANES do for track = 1, NUM_TRACKS do parkPaintCells(lane, track) end end end -------------------------------------------------------------------------------- -- Pattern tools -------------------------------------------------------------------------------- -- The first entry is the blank resting state this menu returns to after every command. A blank -- label is what lets the widget stay narrow enough to fit the row -- any real word would be cut -- down to an ellipsis at this width, and the tooltip carries the meaning anyway. It has to be a -- space rather than an empty string: a menu entry cannot be empty. local TOOL_ITEMS = {" ", "Rotate left", "Rotate right", "Reverse", "Clear"} local EUCLID_FIRST = #TOOL_ITEMS + 1 for hits = 1, 16 do TOOL_ITEMS[#TOOL_ITEMS + 1] = "Euclid/" .. hits .. " hits" end -- Tools read the whole row at the write resolution and write it back through writeCell(), so -- anything drawn finer than the current write grid is lost -- exactly as if the row had been -- redrawn by hand. Pick the write resolution before reaching for a tool. local function remapCells(lane, track, cells, mapping) local values = {} for cell = 1, cells do values[cell] = readCell(lane, track, cell, cells) end for cell = 1, cells do writeCell(lane, track, mapping(cell, cells), cells, values[cell]) end end -- The one exception to "every edit goes through writeCell()": a wipe writes zero, which is a -- valid cell content at every resolution, so it can hit the fine grid directly -- and it should, -- to take steps drawn finer than the current write grid with it. local function clearLane(lane, track) for step = 1, NUM_STEPS do Steps[lane][track]:setValue(step, 0) end end -- A Euclidean rhythm spreads `hits` onsets as evenly as possible over `cells`. Bjorklund's -- algorithm reduces to this test, which is why it fits on one line: cell i carries an onset when -- (i * hits) mod cells lands below hits. local function euclid(track, hits, cells) for cell = 1, cells do local onset = (cell - 1) * hits % cells < hits writeCell(1, track, cell, cells, onset and EUCLID_VELOCITY or 0) end end for track = 1, NUM_TRACKS do Tools[track] = Menu{"Tools_" .. track, TOOL_ITEMS, 1, bounds = {182, ROW_Y + (track - 1) * ROW_PITCH + math.floor((ROW_H - 22) / 2), 44, 22}, showLabel = false, persistent = false, hierarchical = true, tooltip = "Rotate, reverse, clear or fill this track with a Euclidean rhythm"} Tools[track].changed = function(self) local choice = self.value self:setValue(1, false) -- a command list, not a state: notify = false, so no recursion local lane, cells = Lane.value, paintCells() if choice == 2 then -- rotate left remapCells(lane, track, cells, function(cell, count) return (cell - 2) % count + 1 end) elseif choice == 3 then -- rotate right remapCells(lane, track, cells, function(cell, count) return cell % count + 1 end) elseif choice == 4 then -- reverse remapCells(lane, track, cells, function(cell, count) return count - cell + 1 end) elseif choice == 5 then -- clear clearLane(lane, track) elseif choice >= EUCLID_FIRST then lane = 1 -- a Euclidean rhythm is a trigger pattern: always the velocity lane euclid(track, choice - EUCLID_FIRST + 1, cells) end parkPaintCells(lane, track) -- so the mouse can still erase what the tool just wrote invalidateMidi() end end -- Editing only ever flows from the overlay into the pattern, never back. So a preset loads with -- its pattern showing and a blank overlay, and drawing at 1/4 over a 1/64T fill only rewrites the -- cell being touched. local function refreshLaneVisibility() for lane = 1, NUM_LANES do local shown = lane == Lane.value for track = 1, NUM_TRACKS do Steps[lane][track].visible = shown Paint[lane][track].visible = shown end end end -------------------------------------------------------------------------------- -- Pattern files -------------------------------------------------------------------------------- -- Preset state already survives on its own: every pattern widget is natively persistent, so the -- host saves it with the preset and there is no onSave/onLoad here. Files are a different need -- -- patterns you can ship, swap and share -- and that is all this pair does. local function collectPattern() local data = {} for lane = 1, NUM_LANES do for track = 1, NUM_TRACKS do local steps = {} for step = 1, NUM_STEPS do steps[step] = Steps[lane][track]:getValue(step) end data[stepId(lane, track)] = steps end end for track = 1, NUM_TRACKS do data["Note_" .. track] = Note[track].value end return data end local function applyPattern(data) for lane = 1, NUM_LANES do for track = 1, NUM_TRACKS do local steps = data[stepId(lane, track)] if steps then for step = 1, NUM_STEPS do Steps[lane][track]:setValue(step, steps[step] or 0) end end end end for track = 1, NUM_TRACKS do local note = data["Note_" .. track] if note then Note[track]:setValue(note, false) end end refreshWriteGrid() -- the loaded pattern did not come from the overlays: park them all invalidateMidi() end -------------------------------------------------------------------------------- -- MIDI file export -------------------------------------------------------------------------------- local midiExportCounter = 0 -- Swing delays each step by a sine taken over the swing division. Summed over a whole division -- that correction is exactly zero, so the pattern cannot drift out of tempo whatever the -- division. Playback and the MIDI export share this one implementation of the rule, so the two -- cannot drift apart. `phase` is the step's position within its division, in 0..1. local function swungStepBeats(phase, amount) return STEP_BEATS * (1 + amount * math.sin(phase * 2 * math.pi)) end -- Only the velocity lane survives the trip: pan and tune are voice parameters, not note data. -- Mute and solo are not applied either -- the file carries the pattern and the mix stays live, -- which is how the factory script exports as well. local function exportMidi() createMidiFile(1, NUM_TRACKS * NUM_STEPS * 2, function(task) local mf = task.midi local ppq = mf.division local swingSpan = SWING_STEPS[SwingDivision.value] local gateTicks = ppq * 0.25 * Gate.value for track = 1, NUM_TRACKS do local note = Note[track].value - 1 local tick = 0 for step = 1, NUM_STEPS do local velocity = Steps[1][track]:getValue(step) if velocity > 0 then local onset = math.floor(tick + 0.5) local release = onset + math.floor(gateTicks + 0.5) mf:insertEvent(1, uvi.MidiEvent(onset, 0, Event.NoteOn, note, velocity)) mf:insertEvent(1, uvi.MidiEvent(release, 0, Event.NoteOff, note, velocity)) end local phase = (step - 1) % swingSpan / swingSpan tick = tick + ppq * swungStepBeats(phase, Swing.value) end end -- A fresh name every time: a host that already imported this path may otherwise hand back -- its cached copy instead of re-reading the file. midiExportCounter = midiExportCounter + 1 local path = getLocation("Temp") .. "/DrumSequencerKISS_" .. midiExportCounter .. ".mid" saveMidi(mf, path, function() MidiDrag.dragAndDropFilepath = path MidiDrag.visible = true end) end) end -------------------------------------------------------------------------------- -- Playback -------------------------------------------------------------------------------- local playGeneration = 0 local playheadCell local function clearPlayhead() for cell = 1, PLAYHEAD_CELLS do Playhead:setValue(cell, 0) end playheadCell = nil end local function showPlayhead(step) local cell = math.floor((step - 1) / PLAYHEAD_SPAN) + 1 if cell ~= playheadCell then if playheadCell then Playhead:setValue(playheadCell, 0) end Playhead:setValue(cell, 1) playheadCell = cell end end local function anySolo() for track = 1, NUM_TRACKS do if Solo[track].value then return true end end return false end local function playStep(step, gateMs) local soloing = anySolo() for track = 1, NUM_TRACKS do local audible = Solo[track].value or not (soloing or Mute[track].value) local velocity = Steps[1][track]:getValue(step) if audible and velocity > 0 then -- Continuous lanes fill their whole cell, so the pan and tune under this step are -- the ones the user drew, whatever resolution they drew them at. local pan = Steps[2][track]:getValue(step) / 100 local tune = Steps[3][track]:getValue(step) playNote(Note[track].value - 1, velocity, gateMs, nil, nil, nil, 1, pan, tune) end end end local function playLoop(generation) local step = 1 local swingSpan = SWING_STEPS[SwingDivision.value] local swingAmount = Swing.value while generation == playGeneration do playStep(step, beat2ms(0.25 * Gate.value)) showPlayhead(step) -- Both swing controls are re-read on division boundaries only, so moving them mid-division -- cannot unbalance the division in progress. local phase = (step - 1) % swingSpan / swingSpan if phase == 0 then swingSpan = SWING_STEPS[SwingDivision.value] swingAmount = Swing.value end -- waitBeat is tempo-relative, so the sequence follows the host without any tempo tracking. waitBeat(swungStepBeats(phase, swingAmount)) step = step % NUM_STEPS + 1 end end local function startPlaying() playGeneration = playGeneration + 1 -- also cancels a loop that is already running Play:setValue(true, false) -- notify = false, so this cannot recurse through Play.changed spawn(playLoop, playGeneration) end local function stopPlaying() playGeneration = playGeneration + 1 -- the running loop exits at its next wake-up Play:setValue(false, false) clearPlayhead() end local function refreshTriggerKey() for note = 0, 127 do resetKeyColour(note) end setKeyColour(TriggerNote.value - 1, TRIGGER_KEY_COLOUR) end -------------------------------------------------------------------------------- -- Callbacks -------------------------------------------------------------------------------- Play.changed = function(self) if self.value then startPlaying() else stopPlaying() end end Resolution.changed = refreshWriteGrid Lane.changed = refreshLaneVisibility TriggerNote.changed = function() refreshTriggerKey() stopPlaying() -- the key that started the sequence is not the trigger key any more end -- browseForFile is asynchronous: it returns immediately and the callback runs once the user has -- picked a path, with task.success telling you whether they went through with it. SavePattern.changed = function() browseForFile("save", "Save pattern", "", "*." .. PATTERN_EXT, function(task) if task.success then saveData(collectPattern(), task.result) end end) end LoadPattern.changed = function() browseForFile("open", "Load pattern", "", "*." .. PATTERN_EXT, function(task) if task.success then loadData(task.result, applyPattern) end end) end ExportMidi.changed = exportMidi -- The MIDI file bakes in more than the pattern: track notes, swing and gate land in it too. for track = 1, NUM_TRACKS do Note[track].changed = invalidateMidi end Swing.changed = invalidateMidi Gate.changed = invalidateMidi SwingDivision.changed = invalidateMidi function onNote(e) if e.note == TriggerNote.value - 1 then startPlaying() else -- playNote() rather than postEvent(): if the trigger key is moved while a key is held, the -- matching onRelease would stop forwarding its note-off and the voice would hang. A -- duration of -1 releases the voice with the incoming key instead. playNote(e.note, e.velocity, -1, e.layer, e.channel, e.input, e.vol, e.pan, e.tune, e.slice) end end function onRelease(e) if e.note == TriggerNote.value - 1 then stopPlaying() end end refreshWriteGrid() refreshLaneVisibility() refreshTriggerKey() invalidateMidi() setSize(720, 480) makePerformanceView()