From 170b2e4994e77b2397ac8e259b4a824f456f1216 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 3 Aug 2026 14:40:50 -0400 Subject: [PATCH 1/2] Throttle new-content enumeration to 500ms, off the 30/s OnTimer tick --- src/shell/panel/panel_input.cpp | 29 ++++++++++++++++++++--------- src/shell/panel/panel_state.h | 21 +++++++++++++++++---- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 7eb5b79..5941b87 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -135,8 +135,9 @@ void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, } // One detection tick: REAPER exposes no "item/track added" callback, so this diffs live -// GUIDs against the baseline and auto-tags the new ones into the active mode. Runs every -// timer tick regardless of panel open/close. READ-ONLY on the project; mutates only the +// GUIDs against the baseline and auto-tags the new ones into the active mode. Called every +// timer tick regardless of panel open/close, but the enumeration itself is throttled to +// kDetectIntervalMs (see the gate below). READ-ONLY on the project; mutates only the // in-memory membership index — deliberately OUTSIDE any Undo block (auto-tag is a // background metadata update, not a destructive edit; an Undo block here would flood // REAPER's history with an entry per tick that sees new content). @@ -146,6 +147,14 @@ void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, bool detectNewContent() { if (!g_panel.session) return false; + // Throttles the enumeration (O(T+I) REAPER calls + allocations) to kDetectIntervalMs, + // independent of how often the caller ticks. A skipped tick leaves reloadPending/the + // baseline untouched, so the guards below still run before the NEXT diff whenever this + // gate next opens — only the diff's cadence changes, not its correctness. + const unsigned int now = GetTickCount(); + if (now - g_panel.lastDetectTick < kDetectIntervalMs) return false; + g_panel.lastDetectTick = now; + ReaProject* proj = EnumProjects(-1, nullptr, 0); // A project (re)load re-arms the first-poll guard so we never diff across two @@ -538,17 +547,19 @@ void unregisterAccel() { namespace reasampler { void bankPanelNotifyProjectLoaded() { - // Arms the new-content detector to re-baseline on its next tick so the just-loaded - // project's pre-existing content is the baseline (nothing new) rather than diffed - // against the previous project and mass-tagged. A flag, not an inline reset, because - // detectNewContent owns the baseline and runs later in the SAME OnTimer tick. + // Arms the new-content detector to re-baseline on its next ENUMERATING tick (the flag + // persists across any throttled/skipped ticks in between) so the just-loaded project's + // pre-existing content is the baseline (nothing new) rather than diffed against the + // previous project and mass-tagged. A flag, not an inline reset, because detectNewContent + // owns the baseline and drains this before its own diff. panel::g_panel.reloadPending = true; } void bankPanelRefresh() { - // New-content auto-tag detection runs EVERY tick regardless of panel open/close: - // tracks/items are created in the arrange view, not the panel. READ-ONLY on the - // project; only mutates the in-memory membership index. + // New-content auto-tag detection is CALLED every tick regardless of panel open/close + // (tracks/items are created in the arrange view, not the panel), but the enumeration + // it drives is throttled — see kDetectIntervalMs. READ-ONLY on the project; only + // mutates the in-memory membership index. const bool tagged = panel::detectNewContent(); // Lane minting runs ONLY when detection just tagged new content — a track can only diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index 7abebf8..9d046ba 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -206,6 +206,13 @@ inline constexpr unsigned int kTooltipDelayMs = 500; inline constexpr int kTooltipCharPx = 7; inline constexpr int kTooltipTextH = 14; +// New-content detection's enumeration cadence — matches the VST3 side's own +// kSyncTimerIntervalMs (editor_platform.cpp), this codebase's established interval for a +// background poll nothing visible depends on. Auto-tag is an invisible metadata update +// (see detectNewContent), so a 15x cadence cut (30/s -> 2/s) costs latency no one watches +// for, not correctness. +inline constexpr unsigned int kDetectIntervalMs = 500; + // Client area top to bottom: top toolbar | split body | bottom toolbar | footer. // 26, not 24: Font::RegionTitle's line box (19px em + Segoe UI's leading) is ~25px, and // DT_VCENTER clips to the rect. @@ -345,12 +352,18 @@ struct PanelState { bool previewActive = false; bool previewInited = false; // guards double init / deinit - // Each timer tick diffs the live track+item GUID set against the previous tick to - // auto-tag new content. GuidBaseline self-arms on first observe() so pre-existing - // content is never mass-tagged. Project-load re-arm is driven by persist's load - // signal, not a ReaProject* compare — a recycled address previously mis-tagged tracks. + // Each enumerating tick (kDetectIntervalMs-throttled) diffs the live track+item GUID + // set against the prior enumeration to auto-tag new content. GuidBaseline self-arms on + // first observe() so pre-existing content is never mass-tagged. Project-load re-arm is + // driven by persist's load signal, not a ReaProject* compare — a recycled address + // previously mis-tagged tracks. GuidBaseline contentBaseline; bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick + + // Throttles enumerateLiveGuids to kDetectIntervalMs regardless of how often + // bankPanelRefresh itself is called (the OnTimer poll, ~30/s, plus a handful of + // post-capture/ingest/bake call sites). 0 forces the very first tick through. + unsigned int lastDetectTick = 0; }; // Defined in panel_window.cpp (the lifecycle owner). From 32c7e959aee60755daaff1b5587c83190791ec74 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 3 Aug 2026 14:58:33 -0400 Subject: [PATCH 2/2] Extract elapsedAtLeast to core/util, dedupe the throttle rationale, and correct the alloc-count/PLAN citations --- docs/PLAN.md | 20 ++++++---- src/core/util/CMakeLists.txt | 5 +++ src/core/util/elapsed_at_least.h | 19 ++++++++++ src/shell/panel/panel_drag.cpp | 2 +- src/shell/panel/panel_input.cpp | 28 ++++++++------ src/shell/panel/panel_state.h | 14 +++++-- tests/test_elapsed_at_least.cpp | 65 ++++++++++++++++++++++++++++++++ 7 files changed, 129 insertions(+), 24 deletions(-) create mode 100644 src/core/util/elapsed_at_least.h create mode 100644 tests/test_elapsed_at_least.cpp diff --git a/docs/PLAN.md b/docs/PLAN.md index eca1fed..e9770d7 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -850,20 +850,24 @@ the projects where the switch is slowest. `enumerateLiveGuids` and their cadence inside `bankPanelRefresh`). **Does not own:** any other panel TU, `view.cpp`, or the auto-tag RULES — only how often they are asked. -**Behavior.** `detectNewContent` (`panel_input.cpp:146`) runs `enumerateLiveGuids` (`:106-135`) -every tick of REAPER's `"timer"` registration (`main.cpp:341`, ~30/sec), regardless of panel -state — O(T + I) REAPER string calls plus ~4(T+I) allocations per tick, on every project. Throttle -the ENUMERATION, not the detection semantics: the same GUIDs must still be detected, the -first-poll and `reloadPending` re-baseline guards must still fire before any diff, and the -lane-minting pass must still run only on a tick that tagged. A slower cadence is a latency -change to an invisible background tag, and the track states the chosen interval and why. +**Behavior.** `detectNewContent` (`panel_input.cpp:147`) runs `enumerateLiveGuids` (`:106-135`) +every tick of REAPER's `"timer"` registration (`main.cpp:354`, ~30/sec), regardless of panel +state — O(T + I) REAPER string calls plus roughly 5 allocations per track and 6 per item per +tick (a per-element-type count, not the earlier `~4(T+I)` derivation, which undercounted; the +ratio the throttle buys is unaffected by the correction). Throttle the ENUMERATION, not the +detection semantics: the same GUIDs must still be detected, the first-poll and `reloadPending` +re-baseline guards must still fire before any diff, and the lane-minting pass must still run +only on a tick that tagged. A slower cadence is a latency change to an invisible background +tag, and the track states the chosen interval and why. **Acceptance criteria.** - New tracks and items are still auto-tagged into the active mode, and a project reload still re-baselines rather than mass-tagging. - The Ρ-W1-T1 rule survives: an added GUID that already carries a membership record is still dropped, so a render-in-place result is not re-tagged to Design. -- The per-tick allocation count is measured before and after. +- The per-tick allocation count is derived, not measured: it needs live REAPER-side track/item + counts and a runtime allocation trap neither CTest nor this repo's tooling provides. + `[verify — DAW]`. - The panel's own repaint cadence is unaffected. **Prerequisites.** None beyond Ω-W1. **Discharges:** Ω.7's polling cost. diff --git a/src/core/util/CMakeLists.txt b/src/core/util/CMakeLists.txt index b6d9031..3d2bb40 100644 --- a/src/core/util/CMakeLists.txt +++ b/src/core/util/CMakeLists.txt @@ -5,3 +5,8 @@ reasampler_test(file_bytes LINK file_bytes) add_library(curve_law INTERFACE) target_include_directories(curve_law INTERFACE ${REASAMPLER_SRC_DIR}) reasampler_test(curve_law LINK curve_law) + +# The GetTickCount()-wraparound-safe throttle-gate test is header-only, hence INTERFACE. +add_library(elapsed_at_least INTERFACE) +target_include_directories(elapsed_at_least INTERFACE ${REASAMPLER_SRC_DIR}) +reasampler_test(elapsed_at_least LINK elapsed_at_least) diff --git a/src/core/util/elapsed_at_least.h b/src/core/util/elapsed_at_least.h new file mode 100644 index 0000000..3631685 --- /dev/null +++ b/src/core/util/elapsed_at_least.h @@ -0,0 +1,19 @@ +#pragma once +// elapsed_at_least — the ONE "has at least this long passed" test behind every +// GetTickCount()-style throttle gate (panel_input's detect cadence, panel_drag's tooltip +// delay). Unsigned subtraction is the deliberate implementation, not an oversight: it is +// exactly correct across a GetTickCount() wraparound (~49.7 days uptime) because `now - +// since`, computed mod 2^32, IS the true forward millisecond distance even when `now`'s +// raw value is numerically less than `since`'s. A signed or `now < since` guard would +// instead wedge the gate shut for up to the wrap period. + +namespace reasampler::util { + +// True once at least `interval` ms separate `since` from `now` (both GetTickCount()-style +// millisecond counters). `since == 0, interval == 0` reads true, so a zero-initialized +// "last fired" tick opens the gate on its very first call. +inline bool elapsedAtLeast(unsigned int now, unsigned int since, unsigned int interval) { + return now - since >= interval; +} + +} // namespace reasampler::util diff --git a/src/shell/panel/panel_drag.cpp b/src/shell/panel/panel_drag.cpp index 6d62efd..796fb64 100644 --- a/src/shell/panel/panel_drag.cpp +++ b/src/shell/panel/panel_drag.cpp @@ -322,7 +322,7 @@ void maybeShowTooltip() { const HoverKind k = g_panel.hovered.kind; if (k != HoverKind::TopBarButton && k != HoverKind::BottomBarButton) return; const unsigned int now = GetTickCount(); - if (now - g_panel.hoverSinceTick >= kTooltipDelayMs) { + if (elapsedAtLeast(now, g_panel.hoverSinceTick, kTooltipDelayMs)) { g_panel.tooltipShown = true; invalidatePanel(); } diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 5941b87..3a06328 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -136,8 +136,8 @@ void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, // One detection tick: REAPER exposes no "item/track added" callback, so this diffs live // GUIDs against the baseline and auto-tags the new ones into the active mode. Called every -// timer tick regardless of panel open/close, but the enumeration itself is throttled to -// kDetectIntervalMs (see the gate below). READ-ONLY on the project; mutates only the +// timer tick regardless of panel open/close; the enumeration's throttle and its correctness +// argument are the gate immediately below. READ-ONLY on the project; mutates only the // in-memory membership index — deliberately OUTSIDE any Undo block (auto-tag is a // background metadata update, not a destructive edit; an Undo block here would flood // REAPER's history with an entry per tick that sees new content). @@ -147,12 +147,18 @@ void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, bool detectNewContent() { if (!g_panel.session) return false; - // Throttles the enumeration (O(T+I) REAPER calls + allocations) to kDetectIntervalMs, - // independent of how often the caller ticks. A skipped tick leaves reloadPending/the - // baseline untouched, so the guards below still run before the NEXT diff whenever this - // gate next opens — only the diff's cadence changes, not its correctness. + // Throttles the enumeration (O(T+I) REAPER calls + allocations) to kDetectIntervalMs via + // elapsedAtLeast (core/util; wraparound-safe against GetTickCount()'s rollover, see its + // header). A skipped tick leaves reloadPending/the baseline untouched, so the guards below + // still run before the NEXT diff whenever this gate next opens — only the diff's cadence + // changes, not its correctness. + // + // KNOWN CONSEQUENCE, not new: a new item's mode resolves from + // preExistingTrackModes()/activeModeId() at ENUMERATION time (below), not creation time, so + // switching mode or moving the item before the next tick can land it in a different mode + // than a tighter cadence would — the window existed at ~33 ms; now widened ~16x. const unsigned int now = GetTickCount(); - if (now - g_panel.lastDetectTick < kDetectIntervalMs) return false; + if (!elapsedAtLeast(now, g_panel.lastDetectTick, kDetectIntervalMs)) return false; g_panel.lastDetectTick = now; ReaProject* proj = EnumProjects(-1, nullptr, 0); @@ -556,10 +562,10 @@ void bankPanelNotifyProjectLoaded() { } void bankPanelRefresh() { - // New-content auto-tag detection is CALLED every tick regardless of panel open/close - // (tracks/items are created in the arrange view, not the panel), but the enumeration - // it drives is throttled — see kDetectIntervalMs. READ-ONLY on the project; only - // mutates the in-memory membership index. + // New-content auto-tag detection runs every tick regardless of panel open/close (tracks/ + // items are created in the arrange view, not the panel); detectNewContent (panel_input.cpp, + // above) is the authoritative comment for its throttle, correctness argument, and + // read-only/mutation contract. const bool tagged = panel::detectNewContent(); // Lane minting runs ONLY when detection just tagged new content — a track can only diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index 9d046ba..ec72919 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -47,6 +47,7 @@ #include "core/ui/tab_strip.h" #include "core/ui/theme.h" #include "core/ui/tooltip.h" +#include "core/util/elapsed_at_least.h" #include "core/version/app_version.h" #include "core/view/guid_diff.h" #include "core/view/lane_keys.h" @@ -182,6 +183,9 @@ using version::channelCommandId; using version::dockIdent; using version::dockTitle; +// core/util +using util::elapsedAtLeast; + // core/wire using wire::buildInstrumentDropPreset; @@ -208,9 +212,8 @@ inline constexpr int kTooltipTextH = 14; // New-content detection's enumeration cadence — matches the VST3 side's own // kSyncTimerIntervalMs (editor_platform.cpp), this codebase's established interval for a -// background poll nothing visible depends on. Auto-tag is an invisible metadata update -// (see detectNewContent), so a 15x cadence cut (30/s -> 2/s) costs latency no one watches -// for, not correctness. +// background poll nothing visible depends on. The throttle's correctness argument lives at +// detectNewContent's own comment (panel_input.cpp) — the authoritative home; not restated here. inline constexpr unsigned int kDetectIntervalMs = 500; // Client area top to bottom: top toolbar | split body | bottom toolbar | footer. @@ -362,7 +365,10 @@ struct PanelState { // Throttles enumerateLiveGuids to kDetectIntervalMs regardless of how often // bankPanelRefresh itself is called (the OnTimer poll, ~30/s, plus a handful of - // post-capture/ingest/bake call sites). 0 forces the very first tick through. + // post-capture/ingest/bake call sites); the gate's correctness argument lives at + // detectNewContent (panel_input.cpp), not restated here. 0 forces the very first tick + // through PROVIDED system uptime is already >= kDetectIntervalMs at that call — always + // true in practice, but an assumption elapsedAtLeast takes, not one it guarantees. unsigned int lastDetectTick = 0; }; diff --git a/tests/test_elapsed_at_least.cpp b/tests/test_elapsed_at_least.cpp new file mode 100644 index 0000000..d04e3e0 --- /dev/null +++ b/tests/test_elapsed_at_least.cpp @@ -0,0 +1,65 @@ +// Standalone tests for reasampler::util::elapsedAtLeast — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure tests. +// +// Covers: the ordinary in-range case; the exact boundary (elapsed == interval opens the +// gate, elapsed == interval - 1 does not); the zero/zero first-call case; and the +// GetTickCount() WRAPAROUND — the one property in this idiom a reader has to think about. +// A `now` that has numerically wrapped past `since` must still open the gate once the true +// forward elapsed time reaches `interval`, never wedge it shut. + +#include "../src/core/util/elapsed_at_least.h" + +#include +#include + +using namespace reasampler::util; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static void testOrdinaryCase() { + CHECK(elapsedAtLeast(1000u, 0u, 500u)); // well past the interval + CHECK(!elapsedAtLeast(400u, 0u, 500u)); // not there yet +} + +static void testExactBoundary() { + CHECK(elapsedAtLeast(500u, 0u, 500u)); // exactly the interval: gate opens + CHECK(!elapsedAtLeast(499u, 0u, 500u)); // one ms short: gate stays shut +} + +static void testZeroZeroFirstCallOpensImmediately() { + // A zero-initialized "last fired" tick (lastDetectTick's documented default) must not + // block the very first call. + CHECK(elapsedAtLeast(0u, 0u, 0u)); + CHECK(elapsedAtLeast(1u, 0u, 0u)); +} + +// GetTickCount() wraps every ~49.7 days (2^32 ms). `since` sits near the top of the range; +// `now` has wrapped back around to a small value. Unsigned subtraction computes the correct +// forward distance (the gap to UINT_MAX plus the distance past zero) rather than reading as +// a huge negative gap that a signed/naive comparison would misinterpret as "not yet". +static void testWraparoundOpensRatherThanWedgesShut() { + const unsigned int kMax = std::numeric_limits::max(); + const unsigned int since = kMax - 49u; // 50 ms of headroom before the counter wraps + const unsigned int interval = 500u; + + // 450 ms after the wrap: 51 ms (headroom to wrap) + 450 ms = 501 ms true elapsed -> open. + CHECK(elapsedAtLeast(450u, since, interval)); + // 439 ms after the wrap: 51 + 439 = 490 ms true elapsed -> still shut. + CHECK(!elapsedAtLeast(439u, since, interval)); + // Exactly at the wrap instant (now == 0): 51 ms true elapsed -> shut. + CHECK(!elapsedAtLeast(0u, since, interval)); + // now == since (no time passed, mid-wrap-approach): never wedges into "always open". + CHECK(!elapsedAtLeast(since, since, interval)); +} + +int main() { + testOrdinaryCase(); + testExactBoundary(); + testZeroZeroFirstCallOpensImmediately(); + testWraparoundOpensRatherThanWedgesShut(); + if (g_fail == 0) std::printf("elapsed_at_least: all tests passed\n"); + else std::printf("elapsed_at_least: %d FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +}