-------------------------------------------------------------------------------- --! @title Latch --! @brief Latch / Hold with mid-hold toggle safety --! @category NoteProcessing --! --! Latches incoming notes: while Latch is on, played notes keep sounding --! after key-up. A second key-down on the same note clears it. The Clear --! button releases everything at once. --! --! The point of this example is the bookkeeping pattern that prevents stuck --! and orphan notes when the user toggles Latch mid-hold: --! --! - The behaviour at note-on (forward immediately vs. forward and hold) --! depends on the current Latch state. --! - The matching note-off must do the right thing for THIS note's press, --! not for the current Latch state — otherwise toggling Latch while a --! key is held leaks voices. --! --! The fix is to remember the per-note decision in a `latchedAtPress` --! table at the time of the press, then mirror it at release. Any callback --! whose action depends on a mutable state should follow this pattern. --! --! @demonstrates OnOffButton, Button, postEvent, releaseVoice, per-note bookkeeping -------------------------------------------------------------------------------- LatchOn = OnOffButton{"LatchOn", false, displayName = "Latch", tooltip = "When on, notes sustain after key-up"} ClearAll = Button{"ClearAll", displayName = "Clear", tooltip = "Release every latched note"} -- For each note, store the voiceId of the playing voice when it was latched. -- Without this we cannot release latched voices on demand. local latchedVoice = {} -- note -> voiceId -- Per-note flag: was Latch on at the moment of the press? -- The release must mirror that decision regardless of the Latch state now. local latchedAtPress = {} local function clearAll() for note, vid in pairs(latchedVoice) do releaseVoice(vid) latchedVoice[note] = nil latchedAtPress[note] = nil end end function onNote(e) -- Re-pressing a latched note: clear it, do not forward. if latchedVoice[e.note] then releaseVoice(latchedVoice[e.note]) latchedVoice[e.note] = nil latchedAtPress[e.note] = nil return end -- New press: always start the voice. We capture its voiceId so we can -- release it later (either via a re-press, ClearAll, or onRelease). local vid = postEvent(e) latchedAtPress[e.note] = LatchOn.value if LatchOn.value then latchedVoice[e.note] = vid end end function onRelease(e) -- Mirror the press: if the press was latched, hold the voice. If the -- press was a normal pass-through, forward the release so the engine -- ends the voice cleanly. Note that we never look at LatchOn.value here. if latchedAtPress[e.note] then -- The press was latched -> swallow the release; voice keeps sounding. latchedAtPress[e.note] = nil return end -- The press was unlatched -> release normally. latchedAtPress[e.note] = nil postEvent(e) end ClearAll.changed = function(self) clearAll() end -- Turning Latch off mid-hold: release everything currently latched. Notes -- whose key is still pressed will be released here too, which is the -- expected musical behaviour ("flush the latch"). LatchOn.changed = function(self) if not self.value then clearAll() end end setSize(280, 100)