From 3a6cc67edfa6cacb4623e15c48681b436040df94 Mon Sep 17 00:00:00 2001 From: wretcher207 Date: Tue, 16 Jun 2026 20:39:36 -0400 Subject: [PATCH] Timing engine: per-note tempo lookup for ms->PPQ conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_timing used to sample Master_GetTempo() once at function entry, derive a single ppq_per_ms ratio, then apply that same ratio to every note. Any project with tempo automation produced wrong PPQ offsets for notes after the first tempo change — by exactly the ratio of (new BPM / sampled BPM). Fix: round-trip each note's ms offset through project time. local function ms_offset_to_ppq(take, ppq_pos, offset_ms) local t_sec = reaper.MIDI_GetProjTimeFromPPQPos(take, ppq_pos) local target_ppq = reaper.MIDI_GetPPQPosFromProjTime(take, t_sec + offset_ms * 0.001) return target_ppq - ppq_pos end Costs 2 API calls per note instead of 1 multiplication, but APPLY is user- initiated, not per-frame — sub-millisecond total cost for typical drum content (~500 notes per item). Note duration is preserved in PPQ (n.new_endppq = new_start + (n.endppq - n.ppq)), keeping musical-grid duration stable across a tempo change inside the note. Only the note start gets the per-note ms->PPQ adjustment. get_tempo_info() is unchanged; it powers the timing-slider tooltip preview, which is a rough display estimate by design. Mirrored to Scripts/dehumanizer-pro.lua. Syntax verified with luac. Co-Authored-By: Claude Opus 4.7 --- Scripts/dehumanizer-pro.lua | 25 ++++++++++++++++++------- dehumanizer-pro.lua | 25 ++++++++++++++++++------- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/Scripts/dehumanizer-pro.lua b/Scripts/dehumanizer-pro.lua index 1bb5c2f..ada0a4e 100644 --- a/Scripts/dehumanizer-pro.lua +++ b/Scripts/dehumanizer-pro.lua @@ -267,13 +267,20 @@ end -- ================= TIMING ENGINE ================= +-- Convert a millisecond offset, evaluated at ppq_pos, into a PPQ delta that +-- respects project tempo automation. The old apply_timing derived a single +-- ppq_per_ms ratio from Master_GetTempo() and reused it for every note, +-- which is wrong as soon as the project has tempo changes mid-item. +-- Round-tripping through project time is the only way to stay accurate +-- across tempo curves. Costs 2 API calls per note (APPLY-time only). +local function ms_offset_to_ppq(take, ppq_pos, offset_ms) + local t_sec = reaper.MIDI_GetProjTimeFromPPQPos(take, ppq_pos) + local target_ppq = reaper.MIDI_GetPPQPosFromProjTime(take, t_sec + offset_ms * 0.001) + return target_ppq - ppq_pos +end + local function apply_timing(take, notes, timing_settings, timing_var_curve) - local bpm = reaper.Master_GetTempo() - local ms_per_beat = 60000.0 / bpm - local ppq_per_beat = reaper.MIDI_GetPPQPosFromProjQN(take, 1) - - reaper.MIDI_GetPPQPosFromProjQN(take, 0) - local ppq_per_ms = ppq_per_beat / ms_per_beat - local pitch_role = build_pitch_role_lookup() + local pitch_role = build_pitch_role_lookup() -- Compute min/max PPQ from notes for normalized phrase position local min_p, max_p = math.huge, 0 @@ -302,7 +309,11 @@ local function apply_timing(take, notes, timing_settings, timing_var_curve) local frac = n.qn - math.floor(n.qn) local on_beat = (frac < 0.1 or frac > 0.9) local eff_var = scaled_variance * (on_beat and 0.3 or 1.0) - local offset_ppq = biased_rand_timing(ts.lean_ms, eff_var) * ppq_per_ms + -- Per-note tempo lookup. Note duration is preserved in PPQ, which + -- keeps musical-grid duration even across a tempo change inside the + -- note. Only the start gets the per-note ms->PPQ adjustment. + local offset_ms = biased_rand_timing(ts.lean_ms, eff_var) + local offset_ppq = ms_offset_to_ppq(take, n.ppq, offset_ms) local new_start = math.max(0, n.ppq + offset_ppq) n.new_ppq = new_start n.new_endppq = new_start + (n.endppq - n.ppq) diff --git a/dehumanizer-pro.lua b/dehumanizer-pro.lua index 1bb5c2f..ada0a4e 100644 --- a/dehumanizer-pro.lua +++ b/dehumanizer-pro.lua @@ -267,13 +267,20 @@ end -- ================= TIMING ENGINE ================= +-- Convert a millisecond offset, evaluated at ppq_pos, into a PPQ delta that +-- respects project tempo automation. The old apply_timing derived a single +-- ppq_per_ms ratio from Master_GetTempo() and reused it for every note, +-- which is wrong as soon as the project has tempo changes mid-item. +-- Round-tripping through project time is the only way to stay accurate +-- across tempo curves. Costs 2 API calls per note (APPLY-time only). +local function ms_offset_to_ppq(take, ppq_pos, offset_ms) + local t_sec = reaper.MIDI_GetProjTimeFromPPQPos(take, ppq_pos) + local target_ppq = reaper.MIDI_GetPPQPosFromProjTime(take, t_sec + offset_ms * 0.001) + return target_ppq - ppq_pos +end + local function apply_timing(take, notes, timing_settings, timing_var_curve) - local bpm = reaper.Master_GetTempo() - local ms_per_beat = 60000.0 / bpm - local ppq_per_beat = reaper.MIDI_GetPPQPosFromProjQN(take, 1) - - reaper.MIDI_GetPPQPosFromProjQN(take, 0) - local ppq_per_ms = ppq_per_beat / ms_per_beat - local pitch_role = build_pitch_role_lookup() + local pitch_role = build_pitch_role_lookup() -- Compute min/max PPQ from notes for normalized phrase position local min_p, max_p = math.huge, 0 @@ -302,7 +309,11 @@ local function apply_timing(take, notes, timing_settings, timing_var_curve) local frac = n.qn - math.floor(n.qn) local on_beat = (frac < 0.1 or frac > 0.9) local eff_var = scaled_variance * (on_beat and 0.3 or 1.0) - local offset_ppq = biased_rand_timing(ts.lean_ms, eff_var) * ppq_per_ms + -- Per-note tempo lookup. Note duration is preserved in PPQ, which + -- keeps musical-grid duration even across a tempo change inside the + -- note. Only the start gets the per-note ms->PPQ adjustment. + local offset_ms = biased_rand_timing(ts.lean_ms, eff_var) + local offset_ppq = ms_offset_to_ppq(take, n.ppq, offset_ms) local new_start = math.max(0, n.ppq + offset_ppq) n.new_ppq = new_start n.new_endppq = new_start + (n.endppq - n.ppq)