diff --git a/CLAUDE.md b/CLAUDE.md index af57fe0..b28da7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,9 +53,16 @@ loop/repeat state — add it to the daemon loop, not to an action. **ExtState section `midigrid`** is the only state. Persisted (`M.set`/`M.get`): `active`, `hold`, `root`, `scale`, `vel`, `velstep`, `chan`, `previewdur`, `stoplead`. Transient (`M.setTemp`/`M.getTemp`, persist flag false): `seq`, -`pitches`, `dur`, `hb`, `stopat`, `stoparm`, `savedloop`, `savedrep`. Preview -traffic must stay transient — persisting it would write `reaper.ini` on every -keypress. +`pitches`, `dur`, `hb`, `stopat`, `stoparm`, `savedloop`, `savedrep`, +`savedarm`, `routed`. Preview traffic must stay transient — persisting it +would write `reaper.ini` on every keypress. + +**Preview is routed to the edited track.** `StuffMIDIMessage` feeds the virtual +MIDI keyboard, which only reaches armed, monitoring tracks, so `M.routePreview` +borrows the record arm/monitor/input of the take's track (standing down other +armed tracks) and `M.restoreRouting` hands it all back. `M.preview` re-points +lazily on every call, so switching items just works. The daemon restores when +grid mode goes off, the editor closes, or REAPER exits. **Every grid key falls back.** When grid mode is off, each bound key forwards to what it did before via `G.passThrough` (e.g. `_OSARA_PREVCHORD`, or a numeric diff --git a/MidiGrid_Daemon.lua b/MidiGrid_Daemon.lua index 0b10c86..76d532c 100644 --- a/MidiGrid_Daemon.lua +++ b/MidiGrid_Daemon.lua @@ -80,6 +80,15 @@ local function loop() end end + -- Grid mode borrows the record arm/monitor state of the edited track so + -- preview plays through its instrument. Give it back as soon as grid mode + -- is off or the editor is gone -- here rather than in an action script, so + -- closing the editor puts the project back too. + if G.getTemp("savedarm", "") ~= "" + and (not G.isActive() or not reaper.MIDIEditor_GetActive()) then + G.restoreRouting() + end + -- Restore the loop range and repeat state the loop-bar action borrowed, -- once transport has stopped. Doing it here rather than in the action -- script means the project is put back even if you stop with Space. @@ -100,6 +109,7 @@ local function loop() reaper.defer(loop) end --- Never leave a note hanging if the daemon is terminated or Reaper quits. -reaper.atexit(allOff) +-- Never leave a note hanging, or a borrowed record arm unreturned, if the +-- daemon is terminated or Reaper quits. +reaper.atexit(function() allOff() ; G.restoreRouting() end) loop() diff --git a/MidiGrid_OpenInGrid.lua b/MidiGrid_OpenInGrid.lua index ae8b259..7afcaaf 100644 --- a/MidiGrid_OpenInGrid.lua +++ b/MidiGrid_OpenInGrid.lua @@ -22,6 +22,9 @@ if not hwnd then G.say("Could not open MIDI editor") return end G.set("active", 1) +-- Preview must sound through this track's instrument, whatever else is armed. +G.routePreview(reaper.MIDIEditor_GetTake(hwnd) or take) + -- Start at the item's beginning so the first cell is the item's first cell. local pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION") reaper.SetEditCurPos(pos, true, false) diff --git a/MidiGrid_ToggleMode.lua b/MidiGrid_ToggleMode.lua index 485ab52..aac3410 100644 --- a/MidiGrid_ToggleMode.lua +++ b/MidiGrid_ToggleMode.lua @@ -6,6 +6,8 @@ local now = not G.isActive() G.set("active", now and 1 or 0) if not now then + -- Hand back the record arm/monitor state grid mode borrowed for preview. + G.restoreRouting() G.say("Grid mode off") return end @@ -17,6 +19,9 @@ if not take then return end +-- Preview must sound through the instrument of the track being edited. +G.routePreview(take) + -- Park the pitch cursor on a scale tone so the very first up/down press -- moves by a sensible degree rather than off a foreign note. local p = G.getPitch(hwnd) diff --git a/README.md b/README.md index fcac886..87995d9 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,19 @@ daemon releases everything on `atexit`, so notes cannot hang. Preview requests use **non-persisted** `ExtState` — persisting them would write to `reaper.ini` on every keypress. +**Preview routing.** Preview notes go out through `StuffMIDIMessage`, i.e. +REAPER's virtual MIDI keyboard, which is only heard by tracks that are armed +and monitoring — normally not the track you are editing, so notes would sound +through whatever unrelated instrument happened to be armed. Grid mode therefore +borrows the record routing: on entering grid mode, and again whenever the +editor moves to an item on a different track, the edited take's track is armed +and monitoring with MIDI input, and every other armed track is stood down so it +cannot answer as well. The previous arm, monitor and input setting of every +track touched is remembered and handed straight back when grid mode is switched +off, when the MIDI editor closes, or when REAPER exits — the daemon does the +restoring, in the same way it restores the loop and repeat state the loop-bar +action borrows. + ## Tunables In `ExtState` section `midigrid`: @@ -400,9 +413,11 @@ itself. ## Limitations -- Preview uses `StuffMIDIMessage`, which plays through the track's instrument - via the virtual-keyboard input, so the track must be armed / monitored — - exactly as with REAPER's own MIDI preview. +- While grid mode is on, the edited track is held record-armed and monitoring + with MIDI input so that preview can reach its instrument (see *Preview + routing* above), and any other armed track is stood down. Both are handed + back when grid mode ends, but a REAPER crash mid-session would lose that + arm state. - The grid aligns to the project timeline (bar lines), not the item start. For an item starting off-grid, the first cell will be partial. - Note octave naming follows the `midioctoffs` preference; with the default, diff --git a/midigrid_lib.lua b/midigrid_lib.lua index a28e87c..db19432 100644 --- a/midigrid_lib.lua +++ b/midigrid_lib.lua @@ -681,6 +681,90 @@ function M.nudgeVelocity(take, pitch, delta) return "default", v end +------------------------------------------------------------ preview routing + +--[[ + Preview notes leave through StuffMIDIMessage, i.e. the virtual MIDI + keyboard, and Reaper feeds that to whatever tracks happen to be armed and + monitoring. That is usually NOT the track whose item you are editing, so + the grid would sound your notes through some unrelated instrument. + + Grid mode therefore borrows the record routing: the edited take's track is + armed and monitoring with MIDI input, every other armed track is stood + down so it cannot answer as well, and the previous state of every track we + touched is remembered so it can be handed straight back. + + The borrow is undone when grid mode is switched off, when the MIDI editor + closes, and on Reaper exit -- all three by the daemon, in the same way it + restores the loop/repeat state the loop-bar action borrows. +]] + +-- "All MIDI inputs, all channels": 4096 | (device << 5) | channel, with +-- device 63 meaning all. Anything narrower may exclude the virtual keyboard. +local ALL_MIDI_IN = 4096 + 63 * 32 + +local function trackByGUID(guid) + for i = 0, reaper.CountTracks(0) - 1 do + local tr = reaper.GetTrack(0, i) + if reaper.GetTrackGUID(tr) == guid then return tr end + end +end + +local function armState(tr) + return ("%s,%d,%d,%d"):format( + reaper.GetTrackGUID(tr), + reaper.GetMediaTrackInfo_Value(tr, "I_RECARM"), + reaper.GetMediaTrackInfo_Value(tr, "I_RECMON"), + reaper.GetMediaTrackInfo_Value(tr, "I_RECINPUT")) +end + +-- Hand back every track's arm/monitor/input exactly as we found it. +function M.restoreRouting() + local saved = M.getTemp("savedarm", "") + M.setTemp("savedarm", "") + M.setTemp("routed", "") + if saved == "" then return false end + for guid, arm, mon, inp in saved:gmatch("({[^}]*}),(%-?%d+),(%-?%d+),(%-?%d+)") do + local tr = trackByGUID(guid) + if tr then + reaper.SetMediaTrackInfo_Value(tr, "I_RECARM", tonumber(arm)) + reaper.SetMediaTrackInfo_Value(tr, "I_RECMON", tonumber(mon)) + reaper.SetMediaTrackInfo_Value(tr, "I_RECINPUT", tonumber(inp)) + end + end + return true +end + +-- Point preview at the track this take lives on. Cheap to call on every +-- keypress: it only touches the project when the target has changed. +function M.routePreview(take) + if not take then return end + local tr = reaper.GetMediaItemTake_Track(take) + if not tr then return end + local guid = reaper.GetTrackGUID(tr) + if M.getTemp("routed", "") == guid then return end + + -- Give the previously borrowed track back before borrowing another. + M.restoreRouting() + + local saved = {} + for i = 0, reaper.CountTracks(0) - 1 do + local t = reaper.GetTrack(0, i) + local armed = reaper.GetMediaTrackInfo_Value(t, "I_RECARM") == 1 + if t == tr or armed then + saved[#saved + 1] = armState(t) + if t ~= tr then reaper.SetMediaTrackInfo_Value(t, "I_RECARM", 0) end + end + end + + reaper.SetMediaTrackInfo_Value(tr, "I_RECINPUT", ALL_MIDI_IN) + reaper.SetMediaTrackInfo_Value(tr, "I_RECARM", 1) + reaper.SetMediaTrackInfo_Value(tr, "I_RECMON", 1) + + M.setTemp("savedarm", table.concat(saved, ";")) + M.setTemp("routed", guid) +end + ------------------------------------------------------------------ audition --[[ @@ -694,6 +778,9 @@ end ]] function M.preview(pitches, dur) if not pitches or #pitches == 0 then return end + -- Sound through the instrument of the track being edited, not through + -- whatever else happened to be armed. + M.routePreview(select(2, M.editor())) M.setTemp("pitches", table.concat(pitches, ",")) M.setTemp("dur", dur or M.getNum("previewdur", 0.4)) M.setTemp("seq", (tonumber(M.getTemp("seq", "0")) or 0) + 1) diff --git a/test_lib.lua b/test_lib.lua index 403a15b..b3eba80 100644 --- a/test_lib.lua +++ b/test_lib.lua @@ -84,4 +84,49 @@ eq(table.concat(G.chordPitches(69, 3), ","), "69,72,76", "A minor triad") ext.root, ext.scale = "0", "1" eq(G.chordPitches(127, 3), nil, "chord past top of range") +-- Preview routing: the edited track takes over arm/monitor so preview sounds +-- through its instrument, any other armed track stands down so it cannot +-- answer as well, and restore puts every one of them back. +local tracks = { + { guid = "{A}", arm = 0, mon = 0, input = 0 }, + { guid = "{B}", arm = 0, mon = 0, input = 0 }, -- the track being edited + { guid = "{C}", arm = 1, mon = 1, input = 6112 }, -- somebody else's armed track +} +local FIELD = { I_RECARM = "arm", I_RECMON = "mon", I_RECINPUT = "input" } +reaper.CountTracks = function() return #tracks end +reaper.GetTrack = function(_, i) return tracks[i + 1] end +reaper.GetTrackGUID = function(t) return t.guid end +reaper.GetMediaTrackInfo_Value = function(t, k) return t[FIELD[k]] end +reaper.SetMediaTrackInfo_Value = function(t, k, v) t[FIELD[k]] = v end +reaper.GetMediaItemTake_Track = function(tk) return tk.track end + +local take = { track = tracks[2] } +G.routePreview(take) +eq(tracks[2].arm, 1, "edited track armed") +eq(tracks[2].mon, 1, "edited track monitoring") +eq(tracks[2].input, 6112, "edited track takes MIDI input") +eq(tracks[3].arm, 0, "other armed track stood down") +eq(tracks[1].arm, 0, "unarmed bystander untouched") + +-- Re-pointing at the same track must not re-save the borrowed state as if it +-- were the original, or restore would hand back grid mode's own settings. +G.routePreview(take) +G.restoreRouting() +eq(tracks[2].arm, 0, "edited track disarmed again") +eq(tracks[2].mon, 0, "edited track monitoring restored") +eq(tracks[2].input, 0, "edited track input restored") +eq(tracks[3].arm, 1, "other track re-armed") +eq(tracks[3].mon, 1, "other track monitoring restored") + +-- Restoring twice is harmless, and switching to a take on another track +-- returns the first one before borrowing the second. +eq(G.restoreRouting(), false, "restore with nothing borrowed") +G.routePreview(take) +G.routePreview({ track = tracks[1] }) +eq(tracks[1].arm, 1, "second track armed") +eq(tracks[2].arm, 0, "first track handed back on switch") +G.restoreRouting() +eq(tracks[1].arm, 0, "second track handed back") +eq(tracks[3].arm, 1, "bystander still armed at the end") + print(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))